ai-foreman 1.4.0 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/dist/adapters/codex.js +1 -0
  2. package/dist/agentRun.d.ts +4 -2
  3. package/dist/agentRun.js +32 -9
  4. package/dist/branch/git.d.ts +3 -0
  5. package/dist/branch/git.js +31 -5
  6. package/dist/branch/github.d.ts +2 -2
  7. package/dist/branch/github.js +2 -2
  8. package/dist/branch/gitlab.d.ts +3 -2
  9. package/dist/branch/gitlab.js +10 -4
  10. package/dist/branch/planner.d.ts +3 -1
  11. package/dist/branch/planner.js +24 -2
  12. package/dist/branch/runner.d.ts +10 -1
  13. package/dist/branch/runner.js +41 -13
  14. package/dist/branch/types.d.ts +4 -0
  15. package/dist/buildRuns.d.ts +35 -0
  16. package/dist/buildRuns.js +149 -0
  17. package/dist/cli/runtimeAuthPrompt.d.ts +1 -1
  18. package/dist/cli/runtimeAuthPrompt.js +2 -2
  19. package/dist/cli/start.js +153 -16
  20. package/dist/cli/status.d.ts +2 -0
  21. package/dist/cli/status.js +71 -55
  22. package/dist/cli/tickets.js +154 -57
  23. package/dist/foreman.d.ts +3 -1
  24. package/dist/foreman.js +46 -4
  25. package/dist/index.js +0 -0
  26. package/dist/interviews.d.ts +90 -0
  27. package/dist/interviews.js +251 -0
  28. package/dist/log.d.ts +1 -1
  29. package/dist/roles.d.ts +11 -0
  30. package/dist/roles.js +8 -0
  31. package/dist/runtimeAuth.d.ts +1 -1
  32. package/dist/runtimeAuth.js +6 -25
  33. package/dist/runtimeReadiness.d.ts +14 -0
  34. package/dist/runtimeReadiness.js +122 -0
  35. package/dist/ticketPlanning.d.ts +83 -0
  36. package/dist/ticketPlanning.js +287 -0
  37. package/dist/tickets/commands.js +16 -0
  38. package/dist/tickets/delivery.d.ts +38 -0
  39. package/dist/tickets/delivery.js +152 -0
  40. package/dist/tickets/importer.js +4 -0
  41. package/dist/tickets/renderMarkdown.js +9 -0
  42. package/dist/tickets/setupConfig.d.ts +14 -0
  43. package/dist/tickets/setupConfig.js +59 -2
  44. package/dist/tickets/sourceFetch.d.ts +24 -0
  45. package/dist/tickets/sourceFetch.js +246 -0
  46. package/dist/tickets/stateDb.d.ts +10 -0
  47. package/dist/tickets/stateDb.js +24 -0
  48. package/dist/tickets/ticketLoader.js +18 -0
  49. package/dist/tickets/ticketSchema.d.ts +93 -0
  50. package/dist/tickets/ticketSchema.js +17 -0
  51. package/dist/tickets/validate.js +12 -0
  52. package/package.json +33 -2
@@ -2,6 +2,7 @@ import { Command } from "commander";
2
2
  import { resolve, join } from "node:path";
3
3
  import { existsSync, readdirSync, readFileSync } from "node:fs";
4
4
  import { findResumableBranchSessions, formatBranchContinueCommand } from "../branch/resume.js";
5
+ import { recoverableBuildRuns } from "../buildRuns.js";
5
6
  function fail(message) {
6
7
  console.error(`foreman: ${message}`);
7
8
  process.exit(1);
@@ -10,65 +11,80 @@ export function buildStatusCommand() {
10
11
  return new Command("status")
11
12
  .description("Summarize the most recent foreman run for a project.")
12
13
  .argument("<project>", "path to the project directory")
13
- .action((project) => {
14
- const projectDir = resolve(project);
15
- const dir = join(projectDir, ".foreman");
16
- if (!existsSync(dir))
14
+ .action((project) => runStatus(project));
15
+ }
16
+ /** Print the existing Foreman status report for a resolved project. */
17
+ export function runStatus(project) {
18
+ const projectDir = resolve(project);
19
+ const dir = join(projectDir, ".foreman");
20
+ const recoverable = recoverableBuildRuns(projectDir);
21
+ if (!existsSync(dir))
22
+ fail(`no foreman runs found under ${dir}`);
23
+ const logs = readdirSync(dir)
24
+ .filter((f) => f.endsWith(".jsonl"))
25
+ .sort();
26
+ if (logs.length === 0) {
27
+ if (recoverable.length === 0)
17
28
  fail(`no foreman runs found under ${dir}`);
18
- const logs = readdirSync(dir)
19
- .filter((f) => f.endsWith(".jsonl"))
20
- .sort();
21
- if (logs.length === 0)
22
- fail(`no foreman runs found under ${dir}`);
23
- const latest = join(dir, logs[logs.length - 1]);
24
- const records = readFileSync(latest, "utf8")
25
- .split("\n")
26
- .filter(Boolean)
27
- .map((line) => JSON.parse(line));
28
- const steps = records.filter((r) => r.event === "step");
29
- const escalations = records.filter((r) => r.event === "escalation");
30
- const branchEvents = records.filter((r) => String(r.event).startsWith("branch-"));
31
- const prEvents = records.filter((r) => ["pr-created", "pr-existing", "pr-failed"].includes(String(r.event)));
32
- const latestFailure = findLatestGitHubFailure(records);
33
- const batchEnd = [...records].reverse().find((r) => r.event === "batch-end");
34
- console.log(`foreman: latest run ${logs[logs.length - 1]}`);
35
- console.log(`foreman: ${steps.length} step record(s), ${escalations.length} escalation(s)`);
36
- if (branchEvents.length || prEvents.length) {
37
- const completed = branchEvents.filter((r) => r.event === "branch-complete").length;
38
- const issues = branchEvents.filter((r) => r.event === "branch-issue").length;
39
- const prs = prEvents.filter((r) => r.event === "pr-created" || r.event === "pr-existing").length;
40
- console.log(`foreman: branch mode — ${completed} completed, ${issues} issue(s), ${prs} PR(s)`);
41
- }
42
- if (batchEnd) {
43
- console.log(`foreman: outcome — ${batchEnd.outcome} (${batchEnd.completed}/${batchEnd.requested})`);
44
- if (batchEnd.detail)
45
- console.log(`foreman: ${batchEnd.detail}`);
46
- }
47
- else {
48
- console.log("ai-foreman: run is still in progress or did not finish");
49
- }
50
- if (latestFailure) {
51
- console.log(`foreman: latest GitHub failure — ${latestFailure.code}: ${latestFailure.message}`);
52
- if (latestFailure.repairCommands.length > 0) {
53
- console.log("foreman: repair and verify:");
54
- for (const command of latestFailure.repairCommands) {
55
- console.log(` ${command}`);
56
- }
57
- }
58
- const retry = findRetryCommand(projectDir, dir, latestFailure);
59
- if (retry) {
60
- console.log("foreman: retry with:");
61
- console.log(` ${retry}`);
62
- }
63
- if (latestFailure.output) {
64
- console.log("foreman: last command output:");
65
- console.log(indent(limitLines(latestFailure.output, 8)));
29
+ printRecoverable(projectDir, recoverable);
30
+ return;
31
+ }
32
+ const latest = join(dir, logs[logs.length - 1]);
33
+ const records = readFileSync(latest, "utf8")
34
+ .split("\n")
35
+ .filter(Boolean)
36
+ .map((line) => JSON.parse(line));
37
+ const steps = records.filter((r) => r.event === "step");
38
+ const escalations = records.filter((r) => r.event === "escalation");
39
+ const branchEvents = records.filter((r) => String(r.event).startsWith("branch-"));
40
+ const prEvents = records.filter((r) => ["pr-created", "pr-existing", "pr-failed"].includes(String(r.event)));
41
+ const latestFailure = findLatestGitHubFailure(records);
42
+ const batchEnd = [...records].reverse().find((r) => r.event === "batch-end");
43
+ console.log(`foreman: latest run ${logs[logs.length - 1]}`);
44
+ console.log(`foreman: ${steps.length} step record(s), ${escalations.length} escalation(s)`);
45
+ if (branchEvents.length || prEvents.length) {
46
+ const completed = branchEvents.filter((r) => r.event === "branch-complete").length;
47
+ const issues = branchEvents.filter((r) => r.event === "branch-issue").length;
48
+ const prs = prEvents.filter((r) => r.event === "pr-created" || r.event === "pr-existing").length;
49
+ console.log(`foreman: branch mode — ${completed} completed, ${issues} issue(s), ${prs} PR(s)`);
50
+ }
51
+ if (batchEnd) {
52
+ console.log(`foreman: outcome — ${batchEnd.outcome} (${batchEnd.completed}/${batchEnd.requested})`);
53
+ if (batchEnd.detail)
54
+ console.log(`foreman: ${batchEnd.detail}`);
55
+ }
56
+ else {
57
+ console.log("ai-foreman: run is still in progress or did not finish");
58
+ }
59
+ if (latestFailure) {
60
+ console.log(`foreman: latest GitHub failure — ${latestFailure.code}: ${latestFailure.message}`);
61
+ if (latestFailure.repairCommands.length > 0) {
62
+ console.log("foreman: repair and verify:");
63
+ for (const command of latestFailure.repairCommands) {
64
+ console.log(` ${command}`);
66
65
  }
67
66
  }
68
- for (const esc of escalations) {
69
- console.log(` escalated: ${esc.tool} — ${esc.reason}`);
67
+ const retry = findRetryCommand(projectDir, dir, latestFailure);
68
+ if (retry) {
69
+ console.log("foreman: retry with:");
70
+ console.log(` ${retry}`);
70
71
  }
71
- });
72
+ if (latestFailure.output) {
73
+ console.log("foreman: last command output:");
74
+ console.log(indent(limitLines(latestFailure.output, 8)));
75
+ }
76
+ }
77
+ for (const esc of escalations) {
78
+ console.log(` escalated: ${esc.tool} — ${esc.reason}`);
79
+ }
80
+ if (recoverable.length > 0)
81
+ printRecoverable(projectDir, recoverable);
82
+ }
83
+ function printRecoverable(projectDir, runs) {
84
+ console.log(`foreman: ${runs.length} unfinished/recoverable build run(s):`);
85
+ for (const run of runs)
86
+ console.log(` ${run.runId} ${run.currentTicket ?? run.tickets[0] ?? "unknown"} ${run.active ? "active" : run.status} checkpoint=${run.checkpoint}`);
87
+ console.log(`foreman: recover with \`rafi build:resume ${projectDir}\``);
72
88
  }
73
89
  function findLatestGitHubFailure(records) {
74
90
  for (let i = records.length - 1; i >= 0; i--) {
@@ -13,7 +13,9 @@ import { validateDocsRoot } from "../tickets/config.js";
13
13
  import { importExternalSources, importFromMarkdown, validateExternalSourceAccess } from "../tickets/importer.js";
14
14
  import { formatValidationIssues } from "../tickets/validate.js";
15
15
  import { assertEffortLevel, createRoleBuilder, makeLogPath, } from "../agentRun.js";
16
- import { DEFAULT_TICKET_SETUP, defaultAppName, ensureRafiConfigForTicketSetup, externalSources, hasTicketSetupConfig, loadTicketSetupConfig, loadTicketSetupConfigWithDefaults, localSourcePaths, mergeTicketSetup, recommendedBuildDefaults, saveTicketSetupConfig, } from "../tickets/setupConfig.js";
16
+ import { DEFAULT_TICKET_SETUP, configuredPlanningSources, defaultAppName, ensureRafiConfigForTicketSetup, externalSources, hasTicketSetupConfig, loadTicketSetupConfig, loadTicketSetupConfigWithDefaults, localSourcePaths, mergeTicketSetup, recommendedBuildDefaults, saveTicketSetupConfig, urlSources, } from "../tickets/setupConfig.js";
17
+ import { fetchAndSnapshotUrl, snapshotExternalLocalFile } from "../tickets/sourceFetch.js";
18
+ import { checkpointInterview, completeInterview, createInterviewRecord, failInterview, } from "../interviews.js";
17
19
  function fail(msg) {
18
20
  console.error(`foreman tickets: ${msg}`);
19
21
  process.exit(1);
@@ -138,73 +140,114 @@ async function cmdSetupInitCli(opts) {
138
140
  const dir = cwd(opts);
139
141
  if (!existsSync(dir))
140
142
  fail(`project directory not found: ${dir}`);
143
+ let interview = beginTicketSetupInterview(dir, "tickets-setup-init", opts);
141
144
  if (hasTicketSetupConfig(dir)) {
142
145
  console.log("foreman tickets setup: existing setup found; opening setup:update");
143
146
  await cmdSetupUpdateCli(opts);
147
+ if (interview)
148
+ completeInterview(dir, interview);
144
149
  return;
145
150
  }
146
- const answers = await collectTicketSetup(dir, opts, undefined);
147
- ensureRafiConfigForTicketSetup(dir, {
148
- appName: opts.appName ?? defaultAppName(dir),
149
- docsRoot: opts.docsRoot,
150
- targets: parseRuntimeTargets(opts.runtime),
151
- });
152
- saveTicketSetupConfig(dir, answers, {
153
- appName: opts.appName ?? defaultAppName(dir),
154
- docsRoot: opts.docsRoot,
155
- targets: parseRuntimeTargets(opts.runtime),
156
- });
157
- console.log(`foreman tickets setup: saved ticket setup in ${join(dir, "rafi-config.yaml")}`);
158
- await validateConfiguredSourcesIfRequested(answers, Boolean(opts.skipAccessCheck));
159
- if (!isTicketsInitialized(dir)) {
160
- cmdInit(dir, {
151
+ try {
152
+ const answers = await collectTicketSetup(dir, opts, undefined);
153
+ if (interview)
154
+ interview = checkpointInterview(dir, interview, { checkpoint: "save-setup", answers: { setup: answers } });
155
+ await validateConfiguredSourcesIfRequested(dir, answers, Boolean(opts.skipAccessCheck));
156
+ ensureRafiConfigForTicketSetup(dir, {
161
157
  appName: opts.appName ?? defaultAppName(dir),
162
- timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
163
158
  docsRoot: opts.docsRoot,
159
+ targets: parseRuntimeTargets(opts.runtime),
164
160
  });
165
- console.log("foreman tickets setup: initialized .tickets/");
166
- }
167
- if (shouldPrompt(opts)) {
168
- const populate = await confirm({
169
- message: "Run ticket population now?",
170
- initialValue: answers.sources.length > 0,
161
+ saveTicketSetupConfig(dir, answers, {
162
+ appName: opts.appName ?? defaultAppName(dir),
163
+ docsRoot: opts.docsRoot,
164
+ targets: parseRuntimeTargets(opts.runtime),
171
165
  });
172
- if (isCancel(populate))
173
- process.exit(0);
174
- if (populate) {
175
- await cmdPopulateCli({ project: dir, yes: true });
166
+ console.log(`foreman tickets setup: saved ticket setup in ${join(dir, "rafi-config.yaml")}`);
167
+ if (!isTicketsInitialized(dir)) {
168
+ if (interview)
169
+ interview = checkpointInterview(dir, interview, { checkpoint: "initialize-tracker" });
170
+ cmdInit(dir, {
171
+ appName: opts.appName ?? defaultAppName(dir),
172
+ timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
173
+ docsRoot: opts.docsRoot,
174
+ });
175
+ console.log("foreman tickets setup: initialized .tickets/");
176
176
  }
177
+ if (shouldPrompt(opts)) {
178
+ if (interview)
179
+ interview = checkpointInterview(dir, interview, { checkpoint: "populate-confirmation" });
180
+ const populate = await confirm({
181
+ message: "Run ticket population now?",
182
+ initialValue: answers.sources.length > 0,
183
+ });
184
+ if (isCancel(populate))
185
+ return;
186
+ if (populate) {
187
+ await cmdPopulateCli({ project: dir, yes: true });
188
+ }
189
+ }
190
+ else {
191
+ console.log(`foreman tickets setup: next — run \`foreman tickets populate --project ${shellQuote(dir)}\``);
192
+ }
193
+ if (interview)
194
+ completeInterview(dir, interview);
177
195
  }
178
- else {
179
- console.log(`foreman tickets setup: next — run \`foreman tickets populate --project ${shellQuote(dir)}\``);
196
+ catch (error) {
197
+ if (interview)
198
+ failInterview(dir, interview, interview.checkpoint, error);
199
+ throw error;
180
200
  }
181
201
  }
182
202
  async function cmdSetupUpdateCli(opts) {
183
203
  const dir = cwd(opts);
184
204
  if (!existsSync(dir))
185
205
  fail(`project directory not found: ${dir}`);
186
- const current = loadTicketSetupConfigWithDefaults(dir);
187
- const patch = await collectTicketSetupPatch(dir, opts, current);
188
- const next = mergeTicketSetup(current, patch);
189
- ensureRafiConfigForTicketSetup(dir, {
190
- appName: opts.appName ?? defaultAppName(dir),
191
- docsRoot: opts.docsRoot,
192
- targets: parseRuntimeTargets(opts.runtime),
193
- });
194
- saveTicketSetupConfig(dir, next, {
195
- appName: opts.appName ?? defaultAppName(dir),
196
- docsRoot: opts.docsRoot,
197
- targets: parseRuntimeTargets(opts.runtime),
198
- });
199
- console.log(`foreman tickets setup: updated ticket setup in ${join(dir, "rafi-config.yaml")}`);
200
- await validateConfiguredSourcesIfRequested(next, Boolean(opts.skipAccessCheck));
201
- if (isTicketsInitialized(dir)) {
202
- const rows = cmdQueue(dir, 1);
203
- if (rows.length === 0) {
204
- console.log(`foreman tickets setup: queue is empty; run \`foreman tickets populate --project ${shellQuote(dir)}\` to import or refresh tickets.`);
206
+ let interview = beginTicketSetupInterview(dir, "tickets-setup-update", opts);
207
+ try {
208
+ const current = loadTicketSetupConfigWithDefaults(dir);
209
+ const patch = await collectTicketSetupPatch(dir, opts, current);
210
+ const next = mergeTicketSetup(current, patch);
211
+ if (interview)
212
+ interview = checkpointInterview(dir, interview, { checkpoint: "save-setup", answers: { setup: next } });
213
+ await validateConfiguredSourcesIfRequested(dir, next, Boolean(opts.skipAccessCheck));
214
+ ensureRafiConfigForTicketSetup(dir, {
215
+ appName: opts.appName ?? defaultAppName(dir),
216
+ docsRoot: opts.docsRoot,
217
+ targets: parseRuntimeTargets(opts.runtime),
218
+ });
219
+ saveTicketSetupConfig(dir, next, {
220
+ appName: opts.appName ?? defaultAppName(dir),
221
+ docsRoot: opts.docsRoot,
222
+ targets: parseRuntimeTargets(opts.runtime),
223
+ });
224
+ console.log(`foreman tickets setup: updated ticket setup in ${join(dir, "rafi-config.yaml")}`);
225
+ if (isTicketsInitialized(dir)) {
226
+ const rows = cmdQueue(dir, 1);
227
+ if (rows.length === 0) {
228
+ console.log(`foreman tickets setup: queue is empty; run \`foreman tickets populate --project ${shellQuote(dir)}\` to import or refresh tickets.`);
229
+ }
205
230
  }
231
+ if (interview)
232
+ completeInterview(dir, interview);
233
+ }
234
+ catch (error) {
235
+ if (interview)
236
+ failInterview(dir, interview, interview.checkpoint, error);
237
+ throw error;
206
238
  }
207
239
  }
240
+ function beginTicketSetupInterview(projectDir, workflow, opts) {
241
+ if (!shouldPrompt(opts))
242
+ return undefined;
243
+ const record = createInterviewRecord({
244
+ workflow,
245
+ invocation: { projectDir, ...opts },
246
+ checkpoint: "setup-section",
247
+ outputs: ["rafi-config.yaml", ".tickets/config.yaml"],
248
+ });
249
+ return checkpointInterview(projectDir, record, { checkpoint: "setup-section" });
250
+ }
208
251
  async function collectTicketSetup(dir, opts, current) {
209
252
  const patch = await collectTicketSetupPatch(dir, opts, current ?? DEFAULT_TICKET_SETUP);
210
253
  const build = patch.build ?? {};
@@ -229,8 +272,11 @@ async function collectTicketSetupPatch(dir, opts, current) {
229
272
  buildPatch.auto_merge_timeout_minutes = parseOptionalPositiveInteger(opts.autoMergeTimeoutMinutes, "--auto-merge-timeout-minutes");
230
273
  }
231
274
  if (!shouldPrompt(opts)) {
275
+ const prefill = current.sources.length === 0
276
+ ? configuredPlanningSources(dir).map((path) => ({ type: "local", paths: [path] }))
277
+ : current.sources;
232
278
  return {
233
- sources: nonInteractiveSources.length > 0 ? nonInteractiveSources : current.sources,
279
+ sources: nonInteractiveSources.length > 0 ? nonInteractiveSources : prefill,
234
280
  populate: populatePatch,
235
281
  build: Object.keys(buildPatch).length > 0 ? buildPatch : {},
236
282
  };
@@ -246,7 +292,12 @@ async function collectTicketSetupPatch(dir, opts, current) {
246
292
  });
247
293
  if (isCancel(section))
248
294
  process.exit(0);
249
- let sources = nonInteractiveSources.length > 0 ? nonInteractiveSources : current.sources;
295
+ // Do not silently persist planning hints over a completed ticket setup. They
296
+ // are only the initial local-source suggestion for a fresh setup.
297
+ const planningPrefill = current.sources.length === 0
298
+ ? configuredPlanningSources(dir).map((path) => ({ type: "local", paths: [path] }))
299
+ : current.sources;
300
+ let sources = nonInteractiveSources.length > 0 ? nonInteractiveSources : planningPrefill;
250
301
  if (section === "sources" || section === "all") {
251
302
  sources = await promptTicketSources(dir, current.sources);
252
303
  }
@@ -333,6 +384,7 @@ async function promptTicketSources(dir, current) {
333
384
  { value: "local", label: "Local docs, files, folders, or globs" },
334
385
  { value: "linear", label: "Linear" },
335
386
  { value: "jira", label: "Jira Cloud" },
387
+ { value: "url", label: "Public URL (HTML, text, Markdown, or PDF)" },
336
388
  { value: "none", label: "No saved source" },
337
389
  ],
338
390
  });
@@ -365,6 +417,17 @@ async function promptTicketSources(dir, current) {
365
417
  filter: String(filter).trim() || null,
366
418
  }];
367
419
  }
420
+ if (kind === "url") {
421
+ const existing = current.find((source) => source.type === "url");
422
+ const answer = await text({
423
+ message: "Public HTTP(S) URL:",
424
+ initialValue: existing?.url,
425
+ validate: (value) => /^https?:\/\//i.test(String(value ?? "")) ? undefined : "Enter an HTTP(S) URL",
426
+ });
427
+ if (isCancel(answer))
428
+ process.exit(0);
429
+ return [{ type: "url", url: String(answer).trim() }];
430
+ }
368
431
  const site = await text({
369
432
  message: "Jira Cloud site URL:",
370
433
  placeholder: "https://your-domain.atlassian.net",
@@ -410,9 +473,11 @@ function sourcesFromSetupOptions(opts) {
410
473
  jql: opts.jiraJql,
411
474
  });
412
475
  }
476
+ for (const url of opts.urlSource ?? [])
477
+ sources.push({ type: "url", url });
413
478
  return sources;
414
479
  }
415
- async function validateConfiguredSourcesIfRequested(setup, skip) {
480
+ async function validateConfiguredSourcesIfRequested(projectDir, setup, skip) {
416
481
  if (skip)
417
482
  return;
418
483
  for (const source of externalSources(setup)) {
@@ -424,6 +489,12 @@ async function validateConfiguredSourcesIfRequested(setup, skip) {
424
489
  console.log(`foreman tickets setup: ${source.type} access check skipped/failed: ${err instanceof Error ? err.message : String(err)}`);
425
490
  }
426
491
  }
492
+ for (const source of urlSources(setup)) {
493
+ const fetched = await fetchAndSnapshotUrl(projectDir, source.url).catch((err) => {
494
+ throw new Error(`URL access validation failed for ${source.url}: ${err instanceof Error ? err.message : String(err)}`);
495
+ });
496
+ console.log(`foreman tickets setup: validated URL access (${fetched.contentType}, ${fetched.bytes} bytes)`);
497
+ }
427
498
  }
428
499
  function shouldPrompt(opts) {
429
500
  return !opts.defaults && !opts.yes && process.stdin.isTTY && process.stdout.isTTY;
@@ -587,12 +658,21 @@ export async function cmdPopulateCli(opts) {
587
658
  }
588
659
  const ticketsConfig = loadTicketsConfig(dir);
589
660
  const setup = loadTicketSetupConfig(dir);
590
- const explicitSources = opts.sources;
661
+ const explicitSources = opts.sources ? await prepareDocumentSources(dir, opts.sources) : undefined;
591
662
  const configuredExternalSources = explicitSources?.length ? [] : externalSources(setup);
663
+ const configuredUrlSources = explicitSources?.length ? [] : urlSources(setup);
592
664
  const savedLocalSources = explicitSources?.length ? [] : localSourcePaths(setup);
593
- const sourceHints = configuredExternalSources.length > 0 && savedLocalSources.length === 0
665
+ const selectedLocalHints = configuredExternalSources.length > 0 && savedLocalSources.length === 0 && configuredUrlSources.length === 0
594
666
  ? undefined
595
667
  : await resolvePopulateSourceSelection(dir, explicitSources, ticketsConfig, Boolean(opts.yes));
668
+ const localHints = selectedLocalHints ? await prepareDocumentSources(dir, selectedLocalHints) : undefined;
669
+ const urlHints = [];
670
+ for (const source of configuredUrlSources) {
671
+ const fetched = await fetchAndSnapshotUrl(dir, source.url);
672
+ urlHints.push(fetched.snapshotPath);
673
+ console.log(`foreman tickets: fetched ${source.url} -> ${fetched.snapshotPath}`);
674
+ }
675
+ const sourceHints = unique([...(localHints ?? []), ...urlHints]);
596
676
  const populateAgent = opts.agent
597
677
  ?? (setup?.populate.agent_preference && setup.populate.agent_preference !== "configured"
598
678
  ? setup.populate.agent_preference
@@ -628,7 +708,7 @@ export async function cmdPopulateCli(opts) {
628
708
  if (!validation.clean)
629
709
  process.exit(1);
630
710
  }
631
- if (!sourceHints?.length) {
711
+ if (!sourceHints.length) {
632
712
  console.log(`foreman tickets: imported external tickets and rendered ${ticketsConfig.paths.progressDoc}`);
633
713
  return;
634
714
  }
@@ -668,7 +748,7 @@ export async function cmdPopulateCli(opts) {
668
748
  console.log(`foreman tickets: project ${dir}`);
669
749
  console.log(`foreman tickets: role ${TICKET_POPULATE_ROLE}`);
670
750
  console.log(`foreman tickets: log ${logPath}\n`);
671
- if (sourceHints?.length) {
751
+ if (sourceHints.length) {
672
752
  console.log(`foreman tickets: source hints ${sourceHints.join(" ")}`);
673
753
  }
674
754
  const turn = await foreman.runInstruction(buildPopulateInstruction(sourceHints, ticketsConfig.paths.progressDoc));
@@ -711,6 +791,21 @@ export async function cmdPopulateCli(opts) {
711
791
  fail(String(err instanceof Error ? err.message : err));
712
792
  }
713
793
  }
794
+ async function prepareDocumentSources(projectDir, sources) {
795
+ const prepared = [];
796
+ for (const source of sources) {
797
+ if (/^https?:\/\//i.test(source)) {
798
+ prepared.push((await fetchAndSnapshotUrl(projectDir, source)).snapshotPath);
799
+ continue;
800
+ }
801
+ const absolute = resolve(source);
802
+ if (isAbsolute(source) && existsSync(absolute))
803
+ prepared.push(snapshotExternalLocalFile(projectDir, absolute));
804
+ else
805
+ prepared.push(source);
806
+ }
807
+ return unique(prepared);
808
+ }
714
809
  export function buildTicketsCommand() {
715
810
  const tickets = new Command("tickets").description("Manage the structured ticket tracker for a project.");
716
811
  // ── setup:init / setup:update ─────────────────────────────────────────────
@@ -729,12 +824,13 @@ export function buildTicketsCommand() {
729
824
  .option("--linear-filter <filter>", "Linear IssueFilter JSON or title search text")
730
825
  .option("--jira-site <url>", "Jira Cloud site URL")
731
826
  .option("--jira-jql <jql>", "Jira JQL query")
827
+ .option("--url-source <urls...>", "add public HTTP(S) source URLs")
732
828
  .option("--agent-preference <agent>", "populate runtime preference (configured | claude | codex)")
733
829
  .option("--completion <mode>", "build completion default (pr | auto-merge | direct-merge | none)")
734
830
  .option("--provider <provider>", "PR/MR provider default (auto | github | gitlab | local)")
735
831
  .option("--auto-merge-wait", "wait for dependency PR/MRs to merge before starting dependent tickets")
736
832
  .option("--auto-merge-timeout-minutes <n>", "auto-merge dependency wait timeout in minutes (blank means no timeout)")
737
- .option("--skip-access-check", "do not validate Linear/Jira access during setup")
833
+ .option("--skip-access-check", "do not validate configured source access during setup")
738
834
  .action(async (opts) => {
739
835
  try {
740
836
  await cmdSetupInitCli(opts);
@@ -758,12 +854,13 @@ export function buildTicketsCommand() {
758
854
  .option("--linear-filter <filter>", "Linear IssueFilter JSON or title search text")
759
855
  .option("--jira-site <url>", "Jira Cloud site URL")
760
856
  .option("--jira-jql <jql>", "Jira JQL query")
857
+ .option("--url-source <urls...>", "replace saved sources with public HTTP(S) URLs")
761
858
  .option("--agent-preference <agent>", "populate runtime preference (configured | claude | codex)")
762
859
  .option("--completion <mode>", "build completion default (pr | auto-merge | direct-merge | none)")
763
860
  .option("--provider <provider>", "PR/MR provider default (auto | github | gitlab | local)")
764
861
  .option("--auto-merge-wait", "wait for dependency PR/MRs to merge before starting dependent tickets")
765
862
  .option("--auto-merge-timeout-minutes <n>", "auto-merge dependency wait timeout in minutes (blank means no timeout)")
766
- .option("--skip-access-check", "do not validate Linear/Jira access during setup")
863
+ .option("--skip-access-check", "do not validate configured source access during setup")
767
864
  .action(async (opts) => {
768
865
  try {
769
866
  await cmdSetupUpdateCli(opts);
package/dist/foreman.d.ts CHANGED
@@ -49,13 +49,15 @@ export declare class Foreman {
49
49
  private readonly qaEnabled;
50
50
  private readonly qaMaxCycles;
51
51
  private readonly projectDir?;
52
+ private readonly qaReviewer?;
52
53
  private readonly ticketsEnabled;
53
- constructor(builder: BuilderAdapter, log: Log, notificationsEnabled?: boolean, qaEnabled?: boolean, qaMaxCycles?: number, projectDir?: string | undefined);
54
+ constructor(builder: BuilderAdapter, log: Log, notificationsEnabled?: boolean, qaEnabled?: boolean, qaMaxCycles?: number, projectDir?: string | undefined, qaReviewer?: BuilderAdapter | undefined);
54
55
  /**
55
56
  * Send one instruction and resolve any needs_input exchanges before returning.
56
57
  * The returned result and status always reflect the final (non-needs_input) turn.
57
58
  */
58
59
  private doTurn;
60
+ private doTurnWith;
59
61
  /** Send a planning turn and return the builder's response text. Does not count toward steps. */
60
62
  runPreflight(n: number, ticketsContent?: string): Promise<string>;
61
63
  /** Send user feedback on the plan; builder responds with a revised list. Does not count toward steps. */
package/dist/foreman.js CHANGED
@@ -3,6 +3,9 @@ import { MARKER_SPEC, QA_MARKER_SPEC } from "./markers.js";
3
3
  import { fireNotification } from "./notify.js";
4
4
  import { isTicketsInitialized } from "./tickets/config.js";
5
5
  import { cmdUpdate, cmdComplete, cmdBlock, cmdImplementationQueue } from "./tickets/commands.js";
6
+ import { createHash } from "node:crypto";
7
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
8
+ import { join, relative } from "node:path";
6
9
  /** Phrases that suggest the builder ended its turn by asking the human. */
7
10
  const QUESTION_HINTS = [
8
11
  "should i",
@@ -192,14 +195,16 @@ export class Foreman {
192
195
  qaEnabled;
193
196
  qaMaxCycles;
194
197
  projectDir;
198
+ qaReviewer;
195
199
  ticketsEnabled;
196
- constructor(builder, log, notificationsEnabled = false, qaEnabled = true, qaMaxCycles = 3, projectDir) {
200
+ constructor(builder, log, notificationsEnabled = false, qaEnabled = true, qaMaxCycles = 3, projectDir, qaReviewer) {
197
201
  this.builder = builder;
198
202
  this.log = log;
199
203
  this.notificationsEnabled = notificationsEnabled;
200
204
  this.qaEnabled = qaEnabled;
201
205
  this.qaMaxCycles = qaMaxCycles;
202
206
  this.projectDir = projectDir;
207
+ this.qaReviewer = qaReviewer;
203
208
  this.ticketsEnabled = !!(projectDir && isTicketsInitialized(projectDir));
204
209
  }
205
210
  /**
@@ -207,7 +212,10 @@ export class Foreman {
207
212
  * The returned result and status always reflect the final (non-needs_input) turn.
208
213
  */
209
214
  async doTurn(instruction) {
210
- let result = await this.builder.sendTurn(instruction);
215
+ return this.doTurnWith(this.builder, instruction);
216
+ }
217
+ async doTurnWith(adapter, instruction) {
218
+ let result = await adapter.sendTurn(instruction);
211
219
  let status = parseStepStatus(result.text);
212
220
  while (status.kind === "needs_input") {
213
221
  const question = status.question ?? "The builder has a question";
@@ -229,7 +237,7 @@ export class Foreman {
229
237
  status = { kind: "blocked", reason: "user cancelled at input prompt" };
230
238
  break;
231
239
  }
232
- result = await this.builder.sendTurn(String(answer));
240
+ result = await adapter.sendTurn(String(answer));
233
241
  status = parseStepStatus(result.text);
234
242
  }
235
243
  return { result, status };
@@ -260,7 +268,15 @@ export class Foreman {
260
268
  */
261
269
  async runQa(stepIndex) {
262
270
  for (let cycle = 1; cycle <= this.qaMaxCycles; cycle++) {
263
- const qa = await this.doTurn(buildQaInstruction());
271
+ const before = this.qaReviewer && this.projectDir ? fingerprintProtectedTree(this.projectDir) : undefined;
272
+ const qa = await this.doTurnWith(this.qaReviewer ?? this.builder, buildQaInstruction());
273
+ if (before && this.projectDir) {
274
+ const changes = changedProtectedFiles(before, fingerprintProtectedTree(this.projectDir));
275
+ if (changes.length > 0) {
276
+ this.log.write("qa-protected-files-changed", { stepIndex, cycle, paths: changes });
277
+ return { outcome: "needs-human", detail: `independent QA changed protected files: ${changes.join(", ")}` };
278
+ }
279
+ }
264
280
  this.log.write("qa", {
265
281
  stepIndex,
266
282
  cycle,
@@ -448,3 +464,29 @@ function lastLine(text) {
448
464
  const lines = text.trim().split("\n");
449
465
  return (lines[lines.length - 1] ?? "").slice(0, 200);
450
466
  }
467
+ function fingerprintProtectedTree(root) {
468
+ const out = new Map();
469
+ const ignored = new Set([".git", "node_modules", "dist", "coverage"]);
470
+ const visit = (directory) => {
471
+ for (const entry of readdirSync(directory, { withFileTypes: true })) {
472
+ const path = join(directory, entry.name);
473
+ const rel = relative(root, path).replace(/\\/g, "/");
474
+ if (entry.isDirectory()) {
475
+ if (ignored.has(entry.name) || rel.startsWith(".foreman/") || rel.startsWith(".rafi/cache/"))
476
+ continue;
477
+ visit(path);
478
+ }
479
+ else if (entry.isFile() && statSync(path).size <= 10 * 1024 * 1024) {
480
+ out.set(rel, createHash("sha256").update(readFileSync(path)).digest("hex"));
481
+ }
482
+ }
483
+ };
484
+ if (existsSync(root))
485
+ visit(root);
486
+ return out;
487
+ }
488
+ function changedProtectedFiles(before, after) {
489
+ return [...new Set([...before.keys(), ...after.keys()])]
490
+ .filter((path) => before.get(path) !== after.get(path))
491
+ .sort();
492
+ }
package/dist/index.js CHANGED
File without changes