ai-foreman 1.5.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 (51) hide show
  1. package/dist/agentRun.d.ts +3 -2
  2. package/dist/agentRun.js +27 -8
  3. package/dist/branch/git.d.ts +3 -0
  4. package/dist/branch/git.js +31 -5
  5. package/dist/branch/github.d.ts +2 -2
  6. package/dist/branch/github.js +2 -2
  7. package/dist/branch/gitlab.d.ts +3 -2
  8. package/dist/branch/gitlab.js +10 -4
  9. package/dist/branch/planner.d.ts +3 -1
  10. package/dist/branch/planner.js +24 -2
  11. package/dist/branch/runner.d.ts +10 -1
  12. package/dist/branch/runner.js +41 -13
  13. package/dist/branch/types.d.ts +4 -0
  14. package/dist/buildRuns.d.ts +35 -0
  15. package/dist/buildRuns.js +149 -0
  16. package/dist/cli/runtimeAuthPrompt.d.ts +1 -1
  17. package/dist/cli/runtimeAuthPrompt.js +2 -2
  18. package/dist/cli/start.js +153 -16
  19. package/dist/cli/status.d.ts +2 -0
  20. package/dist/cli/status.js +71 -55
  21. package/dist/cli/tickets.js +57 -10
  22. package/dist/foreman.d.ts +3 -1
  23. package/dist/foreman.js +46 -4
  24. package/dist/index.js +0 -0
  25. package/dist/interviews.d.ts +26 -4
  26. package/dist/interviews.js +71 -4
  27. package/dist/log.d.ts +1 -1
  28. package/dist/roles.d.ts +11 -0
  29. package/dist/roles.js +8 -0
  30. package/dist/runtimeAuth.d.ts +1 -1
  31. package/dist/runtimeAuth.js +6 -25
  32. package/dist/runtimeReadiness.d.ts +14 -0
  33. package/dist/runtimeReadiness.js +122 -0
  34. package/dist/ticketPlanning.d.ts +83 -0
  35. package/dist/ticketPlanning.js +287 -0
  36. package/dist/tickets/commands.js +16 -0
  37. package/dist/tickets/delivery.d.ts +38 -0
  38. package/dist/tickets/delivery.js +152 -0
  39. package/dist/tickets/importer.js +4 -0
  40. package/dist/tickets/renderMarkdown.js +9 -0
  41. package/dist/tickets/setupConfig.d.ts +8 -0
  42. package/dist/tickets/setupConfig.js +47 -2
  43. package/dist/tickets/sourceFetch.d.ts +24 -0
  44. package/dist/tickets/sourceFetch.js +246 -0
  45. package/dist/tickets/stateDb.d.ts +10 -0
  46. package/dist/tickets/stateDb.js +24 -0
  47. package/dist/tickets/ticketLoader.js +18 -0
  48. package/dist/tickets/ticketSchema.d.ts +93 -0
  49. package/dist/tickets/ticketSchema.js +17 -0
  50. package/dist/tickets/validate.js +12 -0
  51. package/package.json +29 -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,8 @@ 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, configuredPlanningSources, 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";
17
18
  import { checkpointInterview, completeInterview, createInterviewRecord, failInterview, } from "../interviews.js";
18
19
  function fail(msg) {
19
20
  console.error(`foreman tickets: ${msg}`);
@@ -151,6 +152,7 @@ async function cmdSetupInitCli(opts) {
151
152
  const answers = await collectTicketSetup(dir, opts, undefined);
152
153
  if (interview)
153
154
  interview = checkpointInterview(dir, interview, { checkpoint: "save-setup", answers: { setup: answers } });
155
+ await validateConfiguredSourcesIfRequested(dir, answers, Boolean(opts.skipAccessCheck));
154
156
  ensureRafiConfigForTicketSetup(dir, {
155
157
  appName: opts.appName ?? defaultAppName(dir),
156
158
  docsRoot: opts.docsRoot,
@@ -162,7 +164,6 @@ async function cmdSetupInitCli(opts) {
162
164
  targets: parseRuntimeTargets(opts.runtime),
163
165
  });
164
166
  console.log(`foreman tickets setup: saved ticket setup in ${join(dir, "rafi-config.yaml")}`);
165
- await validateConfiguredSourcesIfRequested(answers, Boolean(opts.skipAccessCheck));
166
167
  if (!isTicketsInitialized(dir)) {
167
168
  if (interview)
168
169
  interview = checkpointInterview(dir, interview, { checkpoint: "initialize-tracker" });
@@ -209,6 +210,7 @@ async function cmdSetupUpdateCli(opts) {
209
210
  const next = mergeTicketSetup(current, patch);
210
211
  if (interview)
211
212
  interview = checkpointInterview(dir, interview, { checkpoint: "save-setup", answers: { setup: next } });
213
+ await validateConfiguredSourcesIfRequested(dir, next, Boolean(opts.skipAccessCheck));
212
214
  ensureRafiConfigForTicketSetup(dir, {
213
215
  appName: opts.appName ?? defaultAppName(dir),
214
216
  docsRoot: opts.docsRoot,
@@ -220,7 +222,6 @@ async function cmdSetupUpdateCli(opts) {
220
222
  targets: parseRuntimeTargets(opts.runtime),
221
223
  });
222
224
  console.log(`foreman tickets setup: updated ticket setup in ${join(dir, "rafi-config.yaml")}`);
223
- await validateConfiguredSourcesIfRequested(next, Boolean(opts.skipAccessCheck));
224
225
  if (isTicketsInitialized(dir)) {
225
226
  const rows = cmdQueue(dir, 1);
226
227
  if (rows.length === 0) {
@@ -383,6 +384,7 @@ async function promptTicketSources(dir, current) {
383
384
  { value: "local", label: "Local docs, files, folders, or globs" },
384
385
  { value: "linear", label: "Linear" },
385
386
  { value: "jira", label: "Jira Cloud" },
387
+ { value: "url", label: "Public URL (HTML, text, Markdown, or PDF)" },
386
388
  { value: "none", label: "No saved source" },
387
389
  ],
388
390
  });
@@ -415,6 +417,17 @@ async function promptTicketSources(dir, current) {
415
417
  filter: String(filter).trim() || null,
416
418
  }];
417
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
+ }
418
431
  const site = await text({
419
432
  message: "Jira Cloud site URL:",
420
433
  placeholder: "https://your-domain.atlassian.net",
@@ -460,9 +473,11 @@ function sourcesFromSetupOptions(opts) {
460
473
  jql: opts.jiraJql,
461
474
  });
462
475
  }
476
+ for (const url of opts.urlSource ?? [])
477
+ sources.push({ type: "url", url });
463
478
  return sources;
464
479
  }
465
- async function validateConfiguredSourcesIfRequested(setup, skip) {
480
+ async function validateConfiguredSourcesIfRequested(projectDir, setup, skip) {
466
481
  if (skip)
467
482
  return;
468
483
  for (const source of externalSources(setup)) {
@@ -474,6 +489,12 @@ async function validateConfiguredSourcesIfRequested(setup, skip) {
474
489
  console.log(`foreman tickets setup: ${source.type} access check skipped/failed: ${err instanceof Error ? err.message : String(err)}`);
475
490
  }
476
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
+ }
477
498
  }
478
499
  function shouldPrompt(opts) {
479
500
  return !opts.defaults && !opts.yes && process.stdin.isTTY && process.stdout.isTTY;
@@ -637,12 +658,21 @@ export async function cmdPopulateCli(opts) {
637
658
  }
638
659
  const ticketsConfig = loadTicketsConfig(dir);
639
660
  const setup = loadTicketSetupConfig(dir);
640
- const explicitSources = opts.sources;
661
+ const explicitSources = opts.sources ? await prepareDocumentSources(dir, opts.sources) : undefined;
641
662
  const configuredExternalSources = explicitSources?.length ? [] : externalSources(setup);
663
+ const configuredUrlSources = explicitSources?.length ? [] : urlSources(setup);
642
664
  const savedLocalSources = explicitSources?.length ? [] : localSourcePaths(setup);
643
- const sourceHints = configuredExternalSources.length > 0 && savedLocalSources.length === 0
665
+ const selectedLocalHints = configuredExternalSources.length > 0 && savedLocalSources.length === 0 && configuredUrlSources.length === 0
644
666
  ? undefined
645
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]);
646
676
  const populateAgent = opts.agent
647
677
  ?? (setup?.populate.agent_preference && setup.populate.agent_preference !== "configured"
648
678
  ? setup.populate.agent_preference
@@ -678,7 +708,7 @@ export async function cmdPopulateCli(opts) {
678
708
  if (!validation.clean)
679
709
  process.exit(1);
680
710
  }
681
- if (!sourceHints?.length) {
711
+ if (!sourceHints.length) {
682
712
  console.log(`foreman tickets: imported external tickets and rendered ${ticketsConfig.paths.progressDoc}`);
683
713
  return;
684
714
  }
@@ -718,7 +748,7 @@ export async function cmdPopulateCli(opts) {
718
748
  console.log(`foreman tickets: project ${dir}`);
719
749
  console.log(`foreman tickets: role ${TICKET_POPULATE_ROLE}`);
720
750
  console.log(`foreman tickets: log ${logPath}\n`);
721
- if (sourceHints?.length) {
751
+ if (sourceHints.length) {
722
752
  console.log(`foreman tickets: source hints ${sourceHints.join(" ")}`);
723
753
  }
724
754
  const turn = await foreman.runInstruction(buildPopulateInstruction(sourceHints, ticketsConfig.paths.progressDoc));
@@ -761,6 +791,21 @@ export async function cmdPopulateCli(opts) {
761
791
  fail(String(err instanceof Error ? err.message : err));
762
792
  }
763
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
+ }
764
809
  export function buildTicketsCommand() {
765
810
  const tickets = new Command("tickets").description("Manage the structured ticket tracker for a project.");
766
811
  // ── setup:init / setup:update ─────────────────────────────────────────────
@@ -779,12 +824,13 @@ export function buildTicketsCommand() {
779
824
  .option("--linear-filter <filter>", "Linear IssueFilter JSON or title search text")
780
825
  .option("--jira-site <url>", "Jira Cloud site URL")
781
826
  .option("--jira-jql <jql>", "Jira JQL query")
827
+ .option("--url-source <urls...>", "add public HTTP(S) source URLs")
782
828
  .option("--agent-preference <agent>", "populate runtime preference (configured | claude | codex)")
783
829
  .option("--completion <mode>", "build completion default (pr | auto-merge | direct-merge | none)")
784
830
  .option("--provider <provider>", "PR/MR provider default (auto | github | gitlab | local)")
785
831
  .option("--auto-merge-wait", "wait for dependency PR/MRs to merge before starting dependent tickets")
786
832
  .option("--auto-merge-timeout-minutes <n>", "auto-merge dependency wait timeout in minutes (blank means no timeout)")
787
- .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")
788
834
  .action(async (opts) => {
789
835
  try {
790
836
  await cmdSetupInitCli(opts);
@@ -808,12 +854,13 @@ export function buildTicketsCommand() {
808
854
  .option("--linear-filter <filter>", "Linear IssueFilter JSON or title search text")
809
855
  .option("--jira-site <url>", "Jira Cloud site URL")
810
856
  .option("--jira-jql <jql>", "Jira JQL query")
857
+ .option("--url-source <urls...>", "replace saved sources with public HTTP(S) URLs")
811
858
  .option("--agent-preference <agent>", "populate runtime preference (configured | claude | codex)")
812
859
  .option("--completion <mode>", "build completion default (pr | auto-merge | direct-merge | none)")
813
860
  .option("--provider <provider>", "PR/MR provider default (auto | github | gitlab | local)")
814
861
  .option("--auto-merge-wait", "wait for dependency PR/MRs to merge before starting dependent tickets")
815
862
  .option("--auto-merge-timeout-minutes <n>", "auto-merge dependency wait timeout in minutes (blank means no timeout)")
816
- .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")
817
864
  .action(async (opts) => {
818
865
  try {
819
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
@@ -1,7 +1,8 @@
1
- export declare const INTERVIEW_STATE_VERSION = 1;
1
+ export declare const INTERVIEW_STATE_VERSION = 2;
2
2
  export declare const INTERVIEW_DIRECTORY = ".rafi/interviews";
3
- export type InterviewWorkflow = "create" | "plan" | "tickets-setup-init" | "tickets-setup-update";
4
- export type InterviewStatus = "in_progress" | "needs_review" | "completed" | "incompatible";
3
+ export type InterviewWorkflow = "create" | "plan" | "tickets-plan" | "tickets-setup-init" | "tickets-setup-update";
4
+ export type InterviewStatus = "in_progress" | "paused" | "needs_review" | "completed" | "incompatible";
5
+ export type InterviewStageStatus = "not_offered" | "offered" | "accepted" | "running" | "completed" | "skipped" | "paused" | "failed" | "cancelled";
5
6
  export interface InterviewRuntimeState {
6
7
  runtime?: "claude" | "codex";
7
8
  model?: string;
@@ -21,13 +22,31 @@ export interface InterviewFailure {
21
22
  export interface InterviewRecord {
22
23
  version: number;
23
24
  id: string;
25
+ journeyId: string;
26
+ parentId?: string;
27
+ childIds: string[];
24
28
  workflow: InterviewWorkflow;
25
29
  status: InterviewStatus;
26
30
  /** Arguments/context required to re-enter the runner. No environment secrets. */
27
31
  invocation: Record<string, unknown>;
28
32
  answers: Record<string, unknown>;
33
+ /** V2 name for compact persisted answers; `answers` remains as a compatibility alias. */
34
+ decisions: Record<string, unknown>;
29
35
  checkpoint: string;
36
+ stages: Record<string, {
37
+ status: InterviewStageStatus;
38
+ updatedAt: string;
39
+ }>;
40
+ planningMode?: "standard" | "exhaustive";
30
41
  runtime: InterviewRuntimeState;
42
+ runtimeAttempts: Array<{
43
+ runtime: "claude" | "codex";
44
+ at: string;
45
+ outcome: "ready" | "failed" | "cancelled";
46
+ category?: string;
47
+ }>;
48
+ sessionIds: Record<string, string>;
49
+ continuityLost: boolean;
31
50
  outputs: InterviewOutputFingerprint[];
32
51
  createdAt: string;
33
52
  updatedAt: string;
@@ -50,11 +69,14 @@ export declare function createInterviewRecord(input: {
50
69
  checkpoint: string;
51
70
  outputs?: string[];
52
71
  now?: Date;
72
+ parentId?: string;
73
+ journeyId?: string;
74
+ planningMode?: "standard" | "exhaustive";
53
75
  }): InterviewRecord;
54
76
  /** Ensure ignored local state exists without modifying unrelated gitignore text. */
55
77
  export declare function ensureInterviewGitignore(projectDir: string): void;
56
78
  export declare function saveInterviewRecord(projectDir: string, record: InterviewRecord, now?: Date): InterviewRecord;
57
- export declare function checkpointInterview(projectDir: string, record: InterviewRecord, update: Partial<Pick<InterviewRecord, "answers" | "checkpoint" | "runtime" | "outputs" | "invocation" | "status">>): InterviewRecord;
79
+ export declare function checkpointInterview(projectDir: string, record: InterviewRecord, update: Partial<Pick<InterviewRecord, "answers" | "decisions" | "checkpoint" | "runtime" | "outputs" | "invocation" | "status" | "stages" | "planningMode" | "runtimeAttempts" | "sessionIds" | "continuityLost" | "childIds">>): InterviewRecord;
58
80
  export declare function failInterview(projectDir: string, record: InterviewRecord, checkpoint: string, error: unknown): InterviewRecord;
59
81
  export declare function completeInterview(projectDir: string, record: InterviewRecord, now?: Date): InterviewRecord;
60
82
  export declare function readInterviewRecords(projectDir: string): InterviewReadResult;
@@ -7,7 +7,7 @@
7
7
  import { createHash, randomUUID } from "node:crypto";
8
8
  import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync, } from "node:fs";
9
9
  import { join, relative, resolve } from "node:path";
10
- export const INTERVIEW_STATE_VERSION = 1;
10
+ export const INTERVIEW_STATE_VERSION = 2;
11
11
  export const INTERVIEW_DIRECTORY = ".rafi/interviews";
12
12
  const MAX_FAILURE_LENGTH = 1_000;
13
13
  const COMPLETE_RETENTION_MS = 30 * 24 * 60 * 60 * 1_000;
@@ -19,12 +19,21 @@ export function createInterviewRecord(input) {
19
19
  return {
20
20
  version: INTERVIEW_STATE_VERSION,
21
21
  id: randomUUID(),
22
+ journeyId: input.journeyId ?? randomUUID(),
23
+ parentId: input.parentId,
24
+ childIds: [],
22
25
  workflow: input.workflow,
23
26
  status: "in_progress",
24
27
  invocation: { ...input.invocation },
25
28
  answers: { ...(input.answers ?? {}) },
29
+ decisions: { ...(input.answers ?? {}) },
26
30
  checkpoint: input.checkpoint,
31
+ stages: { [input.checkpoint]: { status: "running", updatedAt: stamp } },
32
+ planningMode: input.planningMode,
27
33
  runtime: {},
34
+ runtimeAttempts: [],
35
+ sessionIds: {},
36
+ continuityLost: false,
28
37
  outputs: fingerprintOutputs(String(input.invocation.projectDir ?? ""), input.outputs ?? []),
29
38
  createdAt: stamp,
30
39
  updatedAt: stamp,
@@ -54,11 +63,26 @@ export function saveInterviewRecord(projectDir, record, now = new Date()) {
54
63
  return next;
55
64
  }
56
65
  export function checkpointInterview(projectDir, record, update) {
66
+ const nextAnswers = update.answers ?? update.decisions ?? record.answers;
67
+ const stamp = new Date().toISOString();
68
+ const stages = update.stages ?? (update.checkpoint && update.checkpoint !== record.checkpoint
69
+ ? {
70
+ ...record.stages,
71
+ [record.checkpoint]: { status: stageCompletionStatus(record.status), updatedAt: stamp },
72
+ [update.checkpoint]: { status: "running", updatedAt: stamp },
73
+ }
74
+ : record.stages);
57
75
  const next = {
58
76
  ...record,
59
77
  ...update,
60
- answers: update.answers ? { ...update.answers } : record.answers,
78
+ answers: { ...nextAnswers },
79
+ decisions: { ...nextAnswers },
80
+ stages,
61
81
  runtime: update.runtime ? { ...update.runtime } : record.runtime,
82
+ sessionIds: update.runtime?.sessionId
83
+ ? { ...record.sessionIds, [update.runtime.runtime ?? "agent"]: update.runtime.sessionId }
84
+ : update.sessionIds ? { ...update.sessionIds } : record.sessionIds,
85
+ continuityLost: update.runtime?.continuityLost ?? update.continuityLost ?? record.continuityLost,
62
86
  invocation: update.invocation ? { ...update.invocation } : record.invocation,
63
87
  outputs: update.outputs ? update.outputs.map((output) => ({ ...output })) : record.outputs,
64
88
  };
@@ -79,6 +103,7 @@ export function completeInterview(projectDir, record, now = new Date()) {
79
103
  ...record,
80
104
  status: "completed",
81
105
  completedAt: stamp,
106
+ stages: { ...record.stages, [record.checkpoint]: { status: "completed", updatedAt: stamp } },
82
107
  failure: undefined,
83
108
  }, now);
84
109
  }
@@ -103,7 +128,7 @@ export function readInterviewRecords(projectDir) {
103
128
  return { records, problems };
104
129
  }
105
130
  export function unfinishedInterviews(projectDir) {
106
- return readInterviewRecords(projectDir).records.filter((record) => record.status === "in_progress" || record.status === "needs_review" || record.status === "incompatible");
131
+ return readInterviewRecords(projectDir).records.filter((record) => record.status === "in_progress" || record.status === "paused" || record.status === "needs_review" || record.status === "incompatible");
107
132
  }
108
133
  export function findInterviewRecord(projectDir, id) {
109
134
  return readInterviewRecords(projectDir).records.find((record) => record.id === id || record.id.startsWith(id));
@@ -165,6 +190,8 @@ function parseInterviewRecord(value) {
165
190
  if (!value || typeof value !== "object" || Array.isArray(value))
166
191
  throw new Error("invalid interview record");
167
192
  const record = value;
193
+ if (record.version === 1)
194
+ return migrateV1Record(value);
168
195
  if (record.version !== INTERVIEW_STATE_VERSION) {
169
196
  // Preserve it on disk. The caller can show it and require an explicit discard.
170
197
  if (typeof record.id === "string" && typeof record.workflow === "string") {
@@ -175,10 +202,50 @@ function parseInterviewRecord(value) {
175
202
  validateRecord(record);
176
203
  return record;
177
204
  }
205
+ function migrateV1Record(value) {
206
+ const legacy = value;
207
+ if (!legacy.id || !legacy.workflow || !legacy.checkpoint || !legacy.createdAt || !legacy.updatedAt) {
208
+ throw new Error("invalid V1 interview record");
209
+ }
210
+ const outputs = Array.isArray(legacy.outputs) ? legacy.outputs : [];
211
+ const evidenceIsComplete = outputs.length > 0 && outputs.every((item) => item && typeof item.path === "string" && typeof item.sha256 === "string");
212
+ const answers = legacy.answers && typeof legacy.answers === "object" ? legacy.answers : {};
213
+ const runtime = legacy.runtime && typeof legacy.runtime === "object" ? legacy.runtime : {};
214
+ const stamp = legacy.updatedAt;
215
+ return {
216
+ version: 2,
217
+ id: legacy.id,
218
+ journeyId: legacy.id,
219
+ childIds: [],
220
+ workflow: legacy.workflow,
221
+ status: legacy.status === "completed" ? "completed" : evidenceIsComplete ? "paused" : "needs_review",
222
+ invocation: legacy.invocation && typeof legacy.invocation === "object" ? legacy.invocation : {},
223
+ answers,
224
+ decisions: { ...answers },
225
+ checkpoint: legacy.checkpoint,
226
+ stages: { [legacy.checkpoint]: { status: evidenceIsComplete ? "paused" : "failed", updatedAt: stamp } },
227
+ runtime,
228
+ runtimeAttempts: [],
229
+ sessionIds: runtime.sessionId ? { [runtime.runtime ?? "agent"]: runtime.sessionId } : {},
230
+ continuityLost: Boolean(runtime.continuityLost || !runtime.sessionId),
231
+ outputs,
232
+ createdAt: legacy.createdAt,
233
+ updatedAt: legacy.updatedAt,
234
+ completedAt: legacy.completedAt,
235
+ failure: legacy.failure,
236
+ };
237
+ }
238
+ function stageCompletionStatus(status) {
239
+ if (status === "paused" || status === "needs_review")
240
+ return "paused";
241
+ if (status === "completed")
242
+ return "completed";
243
+ return "completed";
244
+ }
178
245
  function validateRecord(record) {
179
246
  if (record.version !== INTERVIEW_STATE_VERSION)
180
247
  throw new Error("unsupported interview record version");
181
- if (!record.id || !record.workflow || !record.checkpoint || !record.createdAt || !record.updatedAt) {
248
+ if (!record.id || !record.journeyId || !record.workflow || !record.checkpoint || !record.createdAt || !record.updatedAt) {
182
249
  throw new Error("invalid interview record");
183
250
  }
184
251
  }