ai-foreman 1.6.2 → 1.7.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/README.md +1 -1
  2. package/dist/adapters/claude.d.ts +5 -1
  3. package/dist/adapters/claude.js +41 -0
  4. package/dist/adapters/codex.d.ts +26 -28
  5. package/dist/adapters/codex.js +209 -148
  6. package/dist/adapters/recovering.d.ts +3 -1
  7. package/dist/adapters/recovering.js +2 -0
  8. package/dist/adapters/types.d.ts +29 -0
  9. package/dist/branch/planner.js +1 -1
  10. package/dist/branch/runner.d.ts +6 -0
  11. package/dist/branch/runner.js +149 -16
  12. package/dist/buildRuns.d.ts +9 -2
  13. package/dist/buildRuns.js +110 -7
  14. package/dist/cli/start.d.ts +4 -0
  15. package/dist/cli/start.js +283 -28
  16. package/dist/cli/status.js +25 -2
  17. package/dist/cli/tickets.d.ts +1 -0
  18. package/dist/cli/tickets.js +67 -27
  19. package/dist/foreman.d.ts +18 -3
  20. package/dist/foreman.js +112 -5
  21. package/dist/log.d.ts +1 -1
  22. package/dist/operationRecovery.d.ts +25 -0
  23. package/dist/operationRecovery.js +46 -0
  24. package/dist/outcomes.d.ts +16 -0
  25. package/dist/outcomes.js +27 -0
  26. package/dist/qaReview.d.ts +52 -0
  27. package/dist/qaReview.js +103 -0
  28. package/dist/qaSnapshot.d.ts +19 -0
  29. package/dist/qaSnapshot.js +88 -0
  30. package/dist/sessionLifecycle.d.ts +37 -0
  31. package/dist/sessionLifecycle.js +104 -0
  32. package/dist/statusReporter.d.ts +40 -0
  33. package/dist/statusReporter.js +31 -0
  34. package/dist/ticketPopulation.d.ts +47 -0
  35. package/dist/ticketPopulation.js +205 -0
  36. package/dist/tickets/blockers.d.ts +1 -1
  37. package/dist/tickets/blockers.js +2 -0
  38. package/dist/tickets/commands.d.ts +1 -0
  39. package/dist/tickets/commands.js +30 -1
  40. package/dist/tickets/delivery.d.ts +36 -0
  41. package/dist/tickets/delivery.js +204 -4
  42. package/dist/tickets/queue.d.ts +3 -0
  43. package/dist/tickets/queue.js +50 -1
  44. package/dist/tickets/stateDb.d.ts +2 -1
  45. package/dist/tickets/stateDb.js +24 -1
  46. package/dist/tickets/ticketSchema.d.ts +44 -0
  47. package/dist/tickets/ticketSchema.js +10 -0
  48. package/dist/tickets/validate.js +1 -1
  49. package/dist/workflowDb.d.ts +113 -0
  50. package/dist/workflowDb.js +261 -0
  51. package/package.json +16 -3
@@ -7,15 +7,19 @@ import { loadConfig } from "../config.js";
7
7
  import { Log } from "../log.js";
8
8
  import { Foreman } from "../foreman.js";
9
9
  import { printEvents } from "./events.js";
10
- import { cmdInit, cmdUpdate, cmdComplete, cmdBlock, cmdUnblock, cmdCancel, cmdDiscover, cmdAcceptFutureWork, cmdReorder, cmdRender, cmdValidate, cmdQueue, cmdArchive, cmdReview, } from "../tickets/commands.js";
10
+ import { cmdInit, cmdUpdate, cmdComplete, cmdBlock, cmdUnblock, cmdCancel, cmdDiscover, cmdAcceptFutureWork, cmdReorder, cmdRender, cmdValidate, cmdQueue, cmdStackQueue, cmdArchive, cmdReview, } from "../tickets/commands.js";
11
+ import { loadDeliveryConfig } from "../tickets/delivery.js";
11
12
  import { DEFAULT_TICKETS_CONFIG, isTicketsInitialized, loadTicketsConfig } from "../tickets/config.js";
12
13
  import { validateDocsRoot } from "../tickets/config.js";
13
14
  import { importExternalSources, importFromMarkdown, validateExternalSourceAccess } from "../tickets/importer.js";
14
15
  import { formatValidationIssues } from "../tickets/validate.js";
15
- import { assertEffortLevel, createRoleBuilder, makeLogPath, } from "../agentRun.js";
16
+ import { assertEffortLevel, createRoleBuilder, makeLogPath, readOnlyPermissionConfig, } from "../agentRun.js";
16
17
  import { DEFAULT_TICKET_SETUP, configuredPlanningSources, defaultAppName, ensureRafiConfigForTicketSetup, externalSources, hasTicketSetupConfig, loadTicketSetupConfig, loadTicketSetupConfigWithDefaults, localSourcePaths, mergeTicketSetup, recommendedBuildDefaults, saveTicketSetupConfig, urlSources, } from "../tickets/setupConfig.js";
17
18
  import { fetchAndSnapshotUrl, snapshotExternalLocalFile } from "../tickets/sourceFetch.js";
18
19
  import { checkpointInterview, completeInterview, createInterviewRecord, failInterview, } from "../interviews.js";
20
+ import { applyTicketPopulation, authorizeTicketRetirements, extractTicketPopulationProposal, materializeTicketPopulation, TICKET_POPULATION_PROPOSAL_END, TICKET_POPULATION_PROPOSAL_START } from "../ticketPopulation.js";
21
+ import { loadTickets } from "../tickets/ticketLoader.js";
22
+ import { resolveTicketPaths } from "../tickets/config.js";
19
23
  function fail(msg) {
20
24
  console.error(`foreman tickets: ${msg}`);
21
25
  process.exit(1);
@@ -59,30 +63,28 @@ Goal:
59
63
  - Do not implement product/code changes. Only update ticket-tracker files.
60
64
  ${sourceHintBlock}
61
65
 
62
- Before editing, read these tracker control files:
66
+ Before proposing, read these tracker control files:
63
67
  - .tickets/config.yaml
64
68
  - .tickets/tickets.yaml
65
69
  - .tickets/tracker-rules.md
66
70
  - ${progressDoc} if it exists
67
71
 
68
- Then inspect the repository for existing planning sources. Check root and docs-style Markdown/YAML/TXT files whose names suggest tickets, backlog, roadmap, plan, TODOs, milestones, progress, specs, phases, or implementation steps. Preserve every ticket or task you find. Do not leave out details.
72
+ Then inspect the repository for existing planning sources, beginning with the approved structured Rafi plan. You are proposal-only: do not edit any file, run tracker mutation commands, allocate final ticket IDs, or write delivery configuration. Rafi validates and performs all writes after approval.
69
73
 
70
- Write the canonical ticket definitions to .tickets/tickets.yaml using Foreman's schema:
71
- - id: stable ticket ID. Preserve existing IDs. If no IDs exist, assign T001, T002, ... in implementation order.
74
+ Return ticket content using Foreman's schema:
75
+ - slice_ref: the exact approved plan slice reference. Do not allocate ticket IDs.
72
76
  - order: unique numeric implementation order. Use gaps like 1000, 2000, 3000.
73
77
  - title, area, priority, size, risk, depends_on, summary, acceptance, required_tests, likely_files, rollback, notes.
74
78
  - Keep dependencies, acceptance criteria, testing expectations, file hints, risk notes, and implementation notes from the source material.
75
79
  - Do not store mutable status/progress fields in .tickets/tickets.yaml.
76
- - Do not edit .tickets/ticket-state.sqlite directly.
80
+ - Dependencies must reference slice_ref values. Include the exact IDs of existing populated tickets whose removed slices should become obsolete.
77
81
 
78
82
  If source content does not cleanly map to the new schema, ask for guidance instead of guessing. Use:
79
83
  STEP_STATUS: needs_input | question="..." choices="..."
80
84
 
81
- After editing:
82
- - Run foreman tickets render.
83
- - Run foreman tickets validate.
84
- - Fix validation errors if possible.
85
- - Triple-check that every source ticket/task is represented exactly once and no important detail was dropped.
85
+ Output one JSON object between ${TICKET_POPULATION_PROPOSAL_START} and ${TICKET_POPULATION_PROPOSAL_END} with shape:
86
+ {"version":1,"plan_id":"approved plan ID","revision":1,"tickets":[{"slice_ref":"slc_...","title":"...","area":"...","priority":"P1","size":"S","risk":"Low","summary":"...","acceptance":["..."],"required_tests":["..."],"likely_files":["..."],"depends_on":["slice_ref"],"rollback":null,"notes":null}],"retirements":["exact existing ticket ID"]}
87
+ Triple-check that every approved slice appears exactly once.
86
88
 
87
89
  End with exactly one marker line as the final non-empty line:
88
90
  STEP_STATUS: done | summary="populated Foreman tickets from existing project ticket sources"
@@ -134,6 +136,8 @@ export function buildPopulateAgentRunOptions(opts) {
134
136
  yes: opts.yes,
135
137
  label: "tickets populate",
136
138
  log: opts.log,
139
+ permissionConfig: readOnlyPermissionConfig(),
140
+ sandboxMode: "read-only",
137
141
  };
138
142
  }
139
143
  async function cmdSetupInitCli(opts) {
@@ -713,11 +717,12 @@ export async function cmdPopulateCli(opts) {
713
717
  return;
714
718
  }
715
719
  }
720
+ const structuredPlan = loadPopulationPlan(dir, sourceHints);
716
721
  if (!opts.yes) {
717
722
  const action = await select({
718
- message: "Populate .tickets/tickets.yaml by letting the builder edit this project?",
723
+ message: "Ask the read-only Ticket Maker for a structured population proposal?",
719
724
  options: [
720
- { value: "proceed", label: "Proceed - builder may edit ticket files" },
725
+ { value: "proceed", label: "Proceed - generate proposal only" },
721
726
  { value: "cancel", label: "Cancel" },
722
727
  ],
723
728
  });
@@ -772,18 +777,29 @@ export async function cmdPopulateCli(opts) {
772
777
  console.error(`foreman tickets: needs human — ${turn.status.error ?? "builder did not emit done"}`);
773
778
  process.exit(2);
774
779
  }
775
- cmdRender(dir);
776
- const validation = cmdValidate(dir);
777
- if (validation.issues.length > 0) {
778
- console.log(`foreman tickets: ${validation.issues.length} validation issue(s) found:`);
779
- console.log(formatValidationIssues(validation.issues));
780
- if (!validation.clean)
781
- process.exit(1);
782
- }
783
- else {
784
- console.log("foreman tickets: validation passed — all 4 passes clean");
780
+ const existing = loadTickets(resolveTicketPaths(ticketsConfig, dir).tickets);
781
+ const proposal = extractTicketPopulationProposal(turn.result.text);
782
+ const materialized = materializeTicketPopulation(proposal, structuredPlan, existing);
783
+ let retirementsConfirmed = false;
784
+ if (!opts.yes) {
785
+ console.log(`foreman tickets: proposal maps ${materialized.sliceToTicket.size} slice(s) and retires ${materialized.retirements.join(", ") || "none"}`);
786
+ const approved = await confirm({ message: "Apply this exact ticket and delivery proposal?", initialValue: false });
787
+ if (isCancel(approved) || !approved) {
788
+ console.log("foreman tickets: cancelled; no files or tracker state changed");
789
+ return;
790
+ }
791
+ if (materialized.retirements.length) {
792
+ const retirement = await confirm({ message: `Mark these removed-slice tickets obsolete: ${materialized.retirements.join(", ")}?`, initialValue: false });
793
+ if (isCancel(retirement) || !retirement) {
794
+ console.log("foreman tickets: cancelled; no files or tracker state changed");
795
+ return;
796
+ }
797
+ retirementsConfirmed = true;
798
+ }
785
799
  }
786
- console.log(`foreman tickets: populated .tickets/tickets.yaml and rendered ${ticketsConfig.paths.progressDoc}`);
800
+ authorizeTicketRetirements(materialized.retirements, { interactiveConfirmed: retirementsConfirmed, authorizedIds: opts.authorizeRetire, computerRun: Boolean(opts.yes) });
801
+ const applied = applyTicketPopulation(dir, materialized);
802
+ console.log(`foreman tickets: populated atomically (run ${applied.runId}, transaction ${applied.transactionId})`);
787
803
  }
788
804
  catch (err) {
789
805
  await builder?.close().catch(() => { });
@@ -806,6 +822,17 @@ async function prepareDocumentSources(projectDir, sources) {
806
822
  }
807
823
  return unique(prepared);
808
824
  }
825
+ function loadPopulationPlan(projectDir, sources) {
826
+ for (const source of sources) {
827
+ const candidate = source.endsWith(".json") ? resolve(projectDir, source) : source.endsWith(".md") ? resolve(projectDir, source.replace(/\.md$/, ".json")) : undefined;
828
+ if (!candidate || !existsSync(candidate))
829
+ continue;
830
+ const plan = JSON.parse(readFileSync(candidate, "utf8"));
831
+ if (plan.version === 1 && plan.plan_id && Number.isInteger(plan.revision) && plan.content_digest)
832
+ return plan;
833
+ }
834
+ throw new Error("ticket population requires the approved paired structured plan (normally docs/rafi-plan.json); run `rafi plan --validate` first");
835
+ }
809
836
  export function buildTicketsCommand() {
810
837
  const tickets = new Command("tickets").description("Manage the structured ticket tracker for a project.");
811
838
  // ── setup:init / setup:update ─────────────────────────────────────────────
@@ -915,7 +942,8 @@ export function buildTicketsCommand() {
915
942
  .option("--effort <level>", "reasoning effort level (low|medium|high|xhigh)")
916
943
  .option("--sources <paths...>", "source hint files, folders, or globs to check first")
917
944
  .option("--fast", "fast mode - lower latency")
918
- .option("-y, --yes", "skip confirmation prompt before letting the builder edit tickets")
945
+ .option("--authorize-retire <ids...>", "exact ticket IDs authorized to become obsolete in computer-run mode")
946
+ .option("-y, --yes", "computer-run approval; retirements still require --authorize-retire exact IDs")
919
947
  .action(async (opts) => {
920
948
  try {
921
949
  await cmdPopulateCli(opts);
@@ -1233,9 +1261,21 @@ export function buildTicketsCommand() {
1233
1261
  .description("Print the ticket queue to stdout.")
1234
1262
  .option("-p, --project <dir>", "project directory (default: cwd)")
1235
1263
  .option("--limit <n>", "override view limit")
1264
+ .option("--refresh", "query GitHub/GitLab and refresh cached stack review state")
1236
1265
  .action((opts) => {
1237
1266
  try {
1238
- const rows = cmdQueue(cwd(opts), opts.limit !== undefined ? Number(opts.limit) : undefined);
1267
+ const dir = cwd(opts);
1268
+ const delivery = loadDeliveryConfig(dir);
1269
+ if (delivery?.stacks?.length) {
1270
+ const lines = cmdStackQueue(dir, Boolean(opts.refresh));
1271
+ if (!lines.length)
1272
+ console.log("No remaining tickets.");
1273
+ else
1274
+ for (const line of lines)
1275
+ console.log(line);
1276
+ return;
1277
+ }
1278
+ const rows = cmdQueue(dir, opts.limit !== undefined ? Number(opts.limit) : undefined);
1239
1279
  if (rows.length === 0) {
1240
1280
  console.log("No remaining tickets.");
1241
1281
  }
package/dist/foreman.d.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  import type { BuilderAdapter, PermissionHandler, TurnResult } from "./adapters/types.js";
2
2
  import type { PermissionPolicy } from "./permissions/policy.js";
3
3
  import type { Log } from "./log.js";
4
+ import { type QaNonconvergenceContext, type QaNonconvergenceDecision } from "./qaReview.js";
5
+ import type { SessionStrategy } from "rafi-spec";
4
6
  /** Parsed STEP_STATUS marker from a builder's turn. */
5
7
  export interface StepStatus {
6
8
  kind: "done" | "blocked" | "plan_complete" | "needs_input" | "qa_pass" | "qa_fail" | "unknown";
@@ -43,20 +45,28 @@ export interface BatchResult {
43
45
  }
44
46
  /** Drives one builder through a batch of N steps via the STEP_STATUS protocol. */
45
47
  export declare class Foreman {
46
- private readonly builder;
48
+ private builder;
47
49
  private readonly log;
48
50
  private readonly notificationsEnabled;
49
51
  private readonly qaEnabled;
50
52
  private readonly qaMaxCycles;
51
53
  private readonly projectDir?;
52
54
  private readonly qaReviewer?;
55
+ private readonly qaFactory?;
56
+ private readonly qaSessionStrategy;
57
+ private readonly builderFactory?;
58
+ private readonly builderSessionStrategy;
59
+ private readonly qaNonconvergence?;
53
60
  private readonly ticketsEnabled;
54
- constructor(builder: BuilderAdapter, log: Log, notificationsEnabled?: boolean, qaEnabled?: boolean, qaMaxCycles?: number, projectDir?: string | undefined, qaReviewer?: BuilderAdapter | undefined);
61
+ private readonly qaStream;
62
+ private builderWorkSessions;
63
+ constructor(builder: BuilderAdapter, log: Log, notificationsEnabled?: boolean, qaEnabled?: boolean, qaMaxCycles?: number, projectDir?: string | undefined, qaReviewer?: BuilderAdapter | undefined, qaFactory?: ((cwd: string, sessionId?: string) => Promise<BuilderAdapter>) | undefined, qaSessionStrategy?: SessionStrategy, builderFactory?: ((cwd: string, sessionId?: string) => Promise<BuilderAdapter>) | undefined, builderSessionStrategy?: SessionStrategy, qaNonconvergence?: ((context: QaNonconvergenceContext) => Promise<QaNonconvergenceDecision>) | undefined);
55
64
  /**
56
65
  * Send one instruction and resolve any needs_input exchanges before returning.
57
66
  * The returned result and status always reflect the final (non-needs_input) turn.
58
67
  */
59
68
  private doTurn;
69
+ private prepareBuilderBoundary;
60
70
  private doTurnWith;
61
71
  /** Send a planning turn and return the builder's response text. Does not count toward steps. */
62
72
  runPreflight(n: number, ticketsContent?: string): Promise<string>;
@@ -74,9 +84,14 @@ export declare class Foreman {
74
84
  */
75
85
  private runQa;
76
86
  runQaReview(stepIndex: number): Promise<{
77
- outcome: "passed" | "blocked" | "needs-human";
87
+ outcome: "passed" | "blocked" | "needs-human" | "waived";
78
88
  detail?: string;
79
89
  summary?: string;
80
90
  }>;
91
+ qaSessionId(): string | undefined;
92
+ builderSessionId(): string | undefined;
93
+ builderAdapter(): BuilderAdapter;
94
+ close(): Promise<void>;
95
+ private ticketForQa;
81
96
  runBatch(n: number, trackerPath?: string): Promise<BatchResult>;
82
97
  }
package/dist/foreman.js CHANGED
@@ -1,8 +1,10 @@
1
1
  import { select, isCancel } from "@clack/prompts";
2
2
  import { MARKER_SPEC, QA_MARKER_SPEC } from "./markers.js";
3
3
  import { fireNotification } from "./notify.js";
4
- import { isTicketsInitialized } from "./tickets/config.js";
4
+ import { isTicketsInitialized, loadTicketsConfig } from "./tickets/config.js";
5
5
  import { cmdUpdate, cmdComplete, cmdBlock, cmdImplementationQueue } from "./tickets/commands.js";
6
+ import { loadTickets } from "./tickets/ticketLoader.js";
7
+ import { runIsolatedQa } from "./qaReview.js";
6
8
  import { createHash } from "node:crypto";
7
9
  import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
8
10
  import { join, relative } from "node:path";
@@ -196,8 +198,15 @@ export class Foreman {
196
198
  qaMaxCycles;
197
199
  projectDir;
198
200
  qaReviewer;
201
+ qaFactory;
202
+ qaSessionStrategy;
203
+ builderFactory;
204
+ builderSessionStrategy;
205
+ qaNonconvergence;
199
206
  ticketsEnabled;
200
- constructor(builder, log, notificationsEnabled = false, qaEnabled = true, qaMaxCycles = 3, projectDir, qaReviewer) {
207
+ qaStream = { reviews: 0, modificationViolations: 0 };
208
+ builderWorkSessions = 0;
209
+ constructor(builder, log, notificationsEnabled = false, qaEnabled = true, qaMaxCycles = 3, projectDir, qaReviewer, qaFactory, qaSessionStrategy = "compact", builderFactory, builderSessionStrategy = "compact", qaNonconvergence) {
201
210
  this.builder = builder;
202
211
  this.log = log;
203
212
  this.notificationsEnabled = notificationsEnabled;
@@ -205,6 +214,11 @@ export class Foreman {
205
214
  this.qaMaxCycles = qaMaxCycles;
206
215
  this.projectDir = projectDir;
207
216
  this.qaReviewer = qaReviewer;
217
+ this.qaFactory = qaFactory;
218
+ this.qaSessionStrategy = qaSessionStrategy;
219
+ this.builderFactory = builderFactory;
220
+ this.builderSessionStrategy = builderSessionStrategy;
221
+ this.qaNonconvergence = qaNonconvergence;
208
222
  this.ticketsEnabled = !!(projectDir && isTicketsInitialized(projectDir));
209
223
  }
210
224
  /**
@@ -214,9 +228,54 @@ export class Foreman {
214
228
  async doTurn(instruction) {
215
229
  return this.doTurnWith(this.builder, instruction);
216
230
  }
231
+ async prepareBuilderBoundary() {
232
+ if (!this.builderFactory || !this.projectDir)
233
+ return;
234
+ if (this.builderSessionStrategy === "fresh") {
235
+ await this.builder.close();
236
+ this.builder = await this.builderFactory(this.projectDir);
237
+ this.log.write("branch-session", { role: "builder", transition: "fresh", workSession: this.builderWorkSessions + 1 });
238
+ return;
239
+ }
240
+ const priorSession = this.builder.sessionId();
241
+ if (priorSession && this.builder.compact) {
242
+ let error = "";
243
+ for (let attempt = 1; attempt <= 2; attempt++) {
244
+ const result = await this.builder.compact().catch((cause) => ({ ok: false, error: cause instanceof Error ? cause.message : String(cause) }));
245
+ if (result.ok) {
246
+ this.log.write("branch-session", { role: "builder", transition: attempt === 1 ? "compacted" : "compaction-retry-succeeded", workSession: this.builderWorkSessions + 1, sessionId: priorSession });
247
+ return;
248
+ }
249
+ error = result.error ?? "native compaction failed";
250
+ if (attempt === 1)
251
+ this.log.write("branch-session", { role: "builder", transition: "compaction-retry", detail: error });
252
+ }
253
+ await this.builder.close();
254
+ this.builder = await this.builderFactory(this.projectDir);
255
+ this.log.write("branch-session", { role: "builder", transition: "compaction-fallback-fresh", continuityLost: true, detail: error });
256
+ return;
257
+ }
258
+ await this.builder.close();
259
+ this.builder = await this.builderFactory(this.projectDir);
260
+ this.log.write("branch-session", { role: "builder", transition: "missing-session-fresh", continuityLost: true });
261
+ }
217
262
  async doTurnWith(adapter, instruction) {
218
263
  let result = await adapter.sendTurn(instruction);
219
264
  let status = parseStepStatus(result.text);
265
+ if (status.kind === "unknown") {
266
+ if (!status.error && looksLikeQuestion(result.text)) {
267
+ status = { kind: "needs_input", question: lastLine(result.text), choices: ["Continue", "Cancel"] };
268
+ }
269
+ else {
270
+ const qa = /\bQA\b|qa_pass|qa_fail/i.test(instruction);
271
+ result = await adapter.sendTurn(qa
272
+ ? "Protocol correction only: based on the review already completed, return exactly one final STEP_STATUS: qa_pass or STEP_STATUS: qa_fail marker. Do not repeat QA, tests, tools, or compaction."
273
+ : "Protocol correction only: based on the work already completed, return exactly one final STEP_STATUS: done, plan_complete, blocked, or needs_input marker. Do not repeat implementation, tools, or compaction.");
274
+ status = parseStepStatus(result.text);
275
+ if (status.kind === "unknown")
276
+ status = { ...status, error: `protocol correction exhausted: ${status.error ?? "missing final marker"}` };
277
+ }
278
+ }
220
279
  while (status.kind === "needs_input") {
221
280
  const question = status.question ?? "The builder has a question";
222
281
  const choices = status.choices?.length
@@ -272,6 +331,31 @@ export class Foreman {
272
331
  * QA turns are free — they do not advance the step counter.
273
332
  */
274
333
  async runQa(stepIndex) {
334
+ if (this.projectDir && this.qaFactory) {
335
+ const review = await runIsolatedQa({
336
+ ticket: this.ticketForQa(stepIndex),
337
+ builderWorktree: this.projectDir,
338
+ builderSummary: `Builder completed step ${stepIndex}`,
339
+ qaStrategy: this.qaSessionStrategy,
340
+ state: this.qaStream,
341
+ createQa: this.qaFactory,
342
+ maxCycles: this.qaMaxCycles,
343
+ fix: async (issues) => {
344
+ await this.prepareBuilderBoundary();
345
+ this.builderWorkSessions += 1;
346
+ const fix = await this.doTurn(buildQaFixInstruction(issues));
347
+ this.log.write("qa-fix", { stepIndex, statusKind: fix.status.kind, costUsd: fix.result.costUsd, isError: fix.result.isError });
348
+ return fix.result.isError || fix.status.kind !== "done"
349
+ ? { ok: false, detail: fix.status.reason ?? fix.status.error ?? fix.result.text.slice(0, 200) }
350
+ : { ok: true };
351
+ },
352
+ evidence: ({ cycle, outcome, detail, qaDiff }) => this.log.write("qa", { stepIndex, cycle, outcome, detail, qaDiff, disposable: true }),
353
+ onNonconvergence: this.qaNonconvergence,
354
+ });
355
+ if (review.outcome === "nonconverged")
356
+ return { outcome: "needs-human", detail: review.detail };
357
+ return { outcome: review.outcome, detail: review.detail, summary: review.summary };
358
+ }
275
359
  for (let cycle = 1; cycle <= this.qaMaxCycles; cycle++) {
276
360
  const before = this.qaReviewer && this.projectDir ? fingerprintProtectedTree(this.projectDir) : undefined;
277
361
  const qa = await this.doTurnWith(this.qaReviewer ?? this.builder, buildQaInstruction());
@@ -335,6 +419,24 @@ export class Foreman {
335
419
  async runQaReview(stepIndex) {
336
420
  return this.runQa(stepIndex);
337
421
  }
422
+ qaSessionId() { return this.qaStream.sessionId ?? this.qaReviewer?.sessionId(); }
423
+ builderSessionId() { return this.builder.sessionId(); }
424
+ builderAdapter() { return this.builder; }
425
+ async close() { await this.builder.close(); }
426
+ ticketForQa(stepIndex) {
427
+ if (this.projectDir && this.ticketsEnabled) {
428
+ const config = loadTicketsConfig(this.projectDir);
429
+ const active = cmdImplementationQueue(this.projectDir).find((row) => row.status === "next" || row.status === "in_progress");
430
+ const ticket = loadTickets(join(this.projectDir, config.paths.tickets)).find((candidate) => candidate.id === active?.ticket);
431
+ if (ticket)
432
+ return ticket;
433
+ }
434
+ return {
435
+ id: `STEP-${stepIndex}`, order: stepIndex, title: `Implementation step ${stepIndex}`, area: "project",
436
+ priority: "P2", size: "M", risk: "Low", depends_on: [], summary: `Review implementation step ${stepIndex}`,
437
+ acceptance: ["The requested implementation step is complete"], required_tests: ["Run the relevant project validation"], likely_files: [],
438
+ };
439
+ }
338
440
  async runBatch(n, trackerPath) {
339
441
  this.log.write("batch-start", { requested: n, agent: this.builder.agent });
340
442
  let completed = 0;
@@ -365,6 +467,9 @@ export class Foreman {
365
467
  const instruction = i === 1
366
468
  ? buildPrimer(n, trackerPath, this.ticketsEnabled)
367
469
  : buildNextStepInstruction(i, n);
470
+ if (i > 1)
471
+ await this.prepareBuilderBoundary();
472
+ this.builderWorkSessions += 1;
368
473
  const { result, status } = await this.doTurn(instruction);
369
474
  this.log.write("step", {
370
475
  index: i,
@@ -383,6 +488,7 @@ export class Foreman {
383
488
  }
384
489
  if (status.kind === "done" || status.kind === "plan_complete") {
385
490
  let qaSummary;
491
+ let qaWaived = false;
386
492
  if (this.qaEnabled) {
387
493
  const qa = await this.runQa(i);
388
494
  if (qa.outcome === "blocked") {
@@ -395,6 +501,7 @@ export class Foreman {
395
501
  detail = qa.detail;
396
502
  break;
397
503
  }
504
+ qaWaived = qa.outcome === "waived";
398
505
  qaSummary = qa.summary;
399
506
  }
400
507
  // Update ticket state only after QA has passed, so generated tracker
@@ -406,12 +513,12 @@ export class Foreman {
406
513
  cmdComplete(this.projectDir, ticketId, {
407
514
  actor: "foreman",
408
515
  summary: status.summary ?? `Step ${i} complete`,
409
- validationResult: this.qaEnabled ? "passed" : "not_applicable",
516
+ validationResult: qaWaived ? "failed" : this.qaEnabled ? "passed" : "not_applicable",
410
517
  validationNotes: this.qaEnabled
411
- ? "Foreman QA emitted qa_pass"
518
+ ? qaWaived ? "User explicitly waived unresolved QA failures" : "Foreman QA emitted qa_pass"
412
519
  : "Foreman QA disabled for this run",
413
520
  evidence: this.qaEnabled
414
- ? (qaSummary ?? "Foreman QA emitted qa_pass")
521
+ ? (qaSummary ?? (qaWaived ? "Unresolved QA issues preserved in run evidence" : "Foreman QA emitted qa_pass"))
415
522
  : undefined,
416
523
  });
417
524
  }
package/dist/log.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  /** A single structured log record. */
2
2
  export interface LogRecord {
3
3
  ts: string;
4
- event: "batch-start" | "batch-end" | "branch-plan" | "branch-start" | "branch-session" | "branch-resume" | "branch-issue" | "branch-complete" | "branch-push" | "github-readiness-failed" | "gitlab-readiness-failed" | "pr-created" | "pr-existing" | "pr-failed" | "pr-auto-merge-enabled" | "pr-auto-merge-failed" | "mr-created" | "mr-existing" | "mr-failed" | "mr-auto-merge-enabled" | "mr-auto-merge-failed" | "branch-auto-merge-wait" | "branch-direct-merge" | "step" | "permission" | "escalation" | "needs_input" | "preflight" | "rafi-plan" | "ticket-populate" | "uninstall-proposal" | "qa" | "qa-fix" | "qa-protected-files-changed" | "error";
4
+ event: "batch-start" | "batch-end" | "branch-plan" | "branch-start" | "branch-session" | "branch-resume" | "branch-issue" | "branch-complete" | "branch-push" | "github-readiness-failed" | "gitlab-readiness-failed" | "pr-created" | "pr-existing" | "pr-failed" | "pr-auto-merge-enabled" | "pr-auto-merge-failed" | "mr-created" | "mr-existing" | "mr-failed" | "mr-auto-merge-enabled" | "mr-auto-merge-failed" | "branch-auto-merge-wait" | "branch-direct-merge" | "step" | "permission" | "escalation" | "needs_input" | "preflight" | "rafi-plan" | "ticket-populate" | "uninstall-proposal" | "qa" | "qa-fix" | "qa-protected-files-changed" | "qa-evidence" | "agent-status" | "error";
5
5
  [key: string]: unknown;
6
6
  }
7
7
  /** Append-only JSONL logger. One line per record, plus an echo to the console. */
@@ -0,0 +1,25 @@
1
+ import type { OperationRecord } from "./workflowDb.js";
2
+ export type ReconciliationResult = {
3
+ outcome: "confirmed";
4
+ externalId?: string;
5
+ detail: string;
6
+ } | {
7
+ outcome: "absent";
8
+ detail: string;
9
+ } | {
10
+ outcome: "uncertain";
11
+ detail: string;
12
+ inspectionCommands: string[];
13
+ };
14
+ export interface RemoteReviewInspector {
15
+ (intent: Record<string, unknown>): Promise<{
16
+ found: boolean;
17
+ url?: string;
18
+ head?: string;
19
+ base?: string;
20
+ uncertain?: string;
21
+ }>;
22
+ }
23
+ /** Prove a side effect before recovery decides whether the stable operation may retry. */
24
+ export declare function reconcileOperation(projectDir: string, operation: OperationRecord, inspectReview?: RemoteReviewInspector): Promise<ReconciliationResult>;
25
+ export declare function recoveryDecision(result: ReconciliationResult): "continue" | "retry" | "pause";
@@ -0,0 +1,46 @@
1
+ import { spawnSync } from "node:child_process";
2
+ /** Prove a side effect before recovery decides whether the stable operation may retry. */
3
+ export async function reconcileOperation(projectDir, operation, inspectReview) {
4
+ const intent = operation.intent;
5
+ if (operation.status === "confirmed")
6
+ return { outcome: "confirmed", externalId: operation.externalId, detail: "operation already has a confirmed receipt" };
7
+ if (operation.kind === "commit") {
8
+ const sha = String(operation.result?.sha ?? operation.externalId ?? "");
9
+ if (!sha)
10
+ return { outcome: "absent", detail: "no commit receipt or SHA exists" };
11
+ const exists = spawnSync("git", ["-C", projectDir, "cat-file", "-e", `${sha}^{commit}`], { encoding: "utf8" });
12
+ return exists.status === 0 ? { outcome: "confirmed", externalId: sha, detail: `commit ${sha} exists` } : { outcome: "absent", detail: `commit ${sha} is absent` };
13
+ }
14
+ if (operation.kind === "push") {
15
+ const branch = String(intent.branch ?? "");
16
+ const expected = String(intent.sha ?? "");
17
+ const check = spawnSync("git", ["-C", projectDir, "ls-remote", "--heads", "origin", `refs/heads/${branch}`], { encoding: "utf8" });
18
+ if (check.status !== 0)
19
+ return { outcome: "uncertain", detail: check.stderr.trim() || "remote branch lookup failed", inspectionCommands: [`git -C ${quote(projectDir)} ls-remote --heads origin refs/heads/${branch}`] };
20
+ const actual = check.stdout.trim().split(/\s+/)[0] ?? "";
21
+ if (!actual)
22
+ return { outcome: "absent", detail: `remote branch ${branch} is absent` };
23
+ if (expected && actual !== expected)
24
+ return { outcome: "uncertain", detail: `remote branch ${branch} is ${actual}, expected ${expected}`, inspectionCommands: [`git -C ${quote(projectDir)} fetch origin ${quote(branch)}`, `git -C ${quote(projectDir)} log --oneline --decorate -5 origin/${quote(branch)}`] };
25
+ return { outcome: "confirmed", externalId: branch, detail: `remote branch ${branch} points at ${actual}` };
26
+ }
27
+ if (operation.kind === "pr-create" || operation.kind === "mr-create") {
28
+ if (!inspectReview)
29
+ return { outcome: "uncertain", detail: "no authenticated review inspector is available", inspectionCommands: [operation.kind === "pr-create" ? "gh pr list --state all --json url,headRefName,baseRefName" : "glab mr list --all"] };
30
+ const result = await inspectReview(intent);
31
+ if (result.uncertain)
32
+ return { outcome: "uncertain", detail: result.uncertain, inspectionCommands: [operation.kind === "pr-create" ? "gh pr list --state all --json url,headRefName,baseRefName" : "glab mr list --all"] };
33
+ if (!result.found)
34
+ return { outcome: "absent", detail: "matching review is absent" };
35
+ if (result.head !== intent.head || result.base !== intent.base)
36
+ return { outcome: "uncertain", detail: `review head/base does not match intended ${intent.head} -> ${intent.base}`, inspectionCommands: [operation.kind === "pr-create" ? `gh pr view ${quote(result.url ?? "")}` : `glab mr view ${quote(result.url ?? "")}`] };
37
+ return { outcome: "confirmed", externalId: result.url, detail: "matching review exists with the intended head and base" };
38
+ }
39
+ if (operation.kind === "ticket-complete" || operation.kind === "tracker-update")
40
+ return { outcome: "uncertain", detail: "tracker reconciliation requires the tracker state adapter", inspectionCommands: ["rafi tickets validate", "rafi tickets queue"] };
41
+ return { outcome: "uncertain", detail: `no reconciler registered for ${operation.kind}`, inspectionCommands: ["rafi status"] };
42
+ }
43
+ export function recoveryDecision(result) {
44
+ return result.outcome === "confirmed" ? "continue" : result.outcome === "absent" ? "retry" : "pause";
45
+ }
46
+ function quote(value) { return /^[A-Za-z0-9_./:@%+=,-]+$/.test(value) ? value : `'${value.replace(/'/g, `'"'"'`)}'`; }
@@ -0,0 +1,16 @@
1
+ import type { ConfigurableAgentRole, WorkflowIssue, WorkflowIssueCode } from "rafi-spec";
2
+ import type { StepStatus } from "./foreman.js";
3
+ export interface IssueContext {
4
+ role?: ConfigurableAgentRole;
5
+ phase: string;
6
+ step?: number;
7
+ ticket?: string;
8
+ stack?: string;
9
+ qaCycle?: number;
10
+ provider?: "claude" | "codex";
11
+ model?: string;
12
+ now?: Date;
13
+ }
14
+ export declare function protocolIssue(status: StepStatus, context: IssueContext): WorkflowIssue;
15
+ export declare function issue(code: WorkflowIssueCode, context: IssueContext, detail: string, humanRequired: boolean, recoverable: boolean, suggestedAction: string): WorkflowIssue;
16
+ export declare function formatWorkflowIssue(value: WorkflowIssue): string;
@@ -0,0 +1,27 @@
1
+ export function protocolIssue(status, context) {
2
+ const detail = status.error ?? "role did not emit a final marker";
3
+ let code = "role_marker_missing";
4
+ if (/multiple|duplicate/i.test(detail))
5
+ code = "role_marker_duplicated";
6
+ else if (/not the final/i.test(detail))
7
+ code = "role_marker_non_final";
8
+ else if (/malformed|unterminated|field/i.test(detail))
9
+ code = "role_marker_malformed";
10
+ else if (/exhausted/i.test(detail))
11
+ code = "role_protocol_exhausted";
12
+ else if (status.kind !== "unknown")
13
+ code = "role_marker_invalid";
14
+ return issue(code, context, detail, true, true, "Inspect the saved output and resume the exact checkpoint after correcting the protocol response.");
15
+ }
16
+ export function issue(code, context, detail, humanRequired, recoverable, suggestedAction) {
17
+ return {
18
+ code, role: context.role, phase: context.phase, step: context.step, ticket: context.ticket, stack: context.stack,
19
+ qa_cycle: context.qaCycle, provider: context.provider, model: context.model, detail,
20
+ human_required: humanRequired, recoverable, suggested_action: suggestedAction,
21
+ occurred_at: (context.now ?? new Date()).toISOString(),
22
+ };
23
+ }
24
+ export function formatWorkflowIssue(value) {
25
+ const scope = [value.role, value.ticket && `ticket=${value.ticket}`, value.stack && `stack=${value.stack}`, value.step && `step=${value.step}`].filter(Boolean).join(" ");
26
+ return `${value.code}${scope ? ` (${scope})` : ""}: ${value.detail}\nSuggested action: ${value.suggested_action}`;
27
+ }
@@ -0,0 +1,52 @@
1
+ import type { SessionStrategy } from "rafi-spec";
2
+ import type { BuilderAdapter } from "./adapters/types.js";
3
+ import type { TicketDef } from "./tickets/ticketSchema.js";
4
+ export interface QaStreamState {
5
+ sessionId?: string;
6
+ reviews: number;
7
+ modificationViolations: number;
8
+ }
9
+ export interface QaNonconvergenceContext {
10
+ ticket: TicketDef;
11
+ history: Array<{
12
+ cycle: number;
13
+ outcome: string;
14
+ detail: string;
15
+ }>;
16
+ builderWorktree: string;
17
+ }
18
+ export type QaNonconvergenceDecision = {
19
+ action: "retry" | "pause" | "waive" | "remediate";
20
+ remediation?: string;
21
+ };
22
+ export interface IsolatedQaOptions {
23
+ ticket: TicketDef;
24
+ builderWorktree: string;
25
+ builderSummary: string;
26
+ qaStrategy: SessionStrategy;
27
+ state: QaStreamState;
28
+ createQa: (cwd: string, sessionId?: string) => Promise<BuilderAdapter>;
29
+ fix: (issues: string) => Promise<{
30
+ ok: boolean;
31
+ detail?: string;
32
+ }>;
33
+ maxCycles: number;
34
+ evidence?: (entry: {
35
+ cycle: number;
36
+ outcome: string;
37
+ detail: string;
38
+ qaDiff?: string[];
39
+ }) => void;
40
+ onNonconvergence?: (context: QaNonconvergenceContext) => Promise<QaNonconvergenceDecision>;
41
+ }
42
+ export interface IsolatedQaResult {
43
+ outcome: "passed" | "blocked" | "needs-human" | "nonconverged" | "waived";
44
+ detail?: string;
45
+ summary?: string;
46
+ }
47
+ export declare function runIsolatedQa(opts: IsolatedQaOptions): Promise<IsolatedQaResult>;
48
+ export declare function compactWithRetry(adapter: BuilderAdapter): Promise<{
49
+ ok: boolean;
50
+ error?: string;
51
+ }>;
52
+ export declare function buildDurableQaFixHandoff(ticket: TicketDef, issues: string, worktree: string, priorResult: string, diffDigest: string): string;