ai-foreman 1.7.4 → 1.7.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/README.md +6 -1
  2. package/dist/activity.d.ts +63 -0
  3. package/dist/activity.js +244 -0
  4. package/dist/adapters/claude.d.ts +7 -0
  5. package/dist/adapters/claude.js +41 -1
  6. package/dist/adapters/codex.d.ts +1 -0
  7. package/dist/adapters/codex.js +65 -5
  8. package/dist/adapters/recovering.js +7 -1
  9. package/dist/adapters/types.d.ts +15 -0
  10. package/dist/agentRun.d.ts +5 -0
  11. package/dist/agentRun.js +5 -1
  12. package/dist/branch/github.js +5 -3
  13. package/dist/branch/gitlab.js +5 -3
  14. package/dist/branch/planner.d.ts +5 -0
  15. package/dist/branch/planner.js +42 -3
  16. package/dist/branch/presentation.d.ts +17 -0
  17. package/dist/branch/presentation.js +38 -0
  18. package/dist/branch/resume.d.ts +2 -0
  19. package/dist/branch/resume.js +2 -0
  20. package/dist/branch/runner.js +44 -25
  21. package/dist/buildRuns.d.ts +41 -17
  22. package/dist/buildRuns.js +243 -22
  23. package/dist/cli/events.js +24 -5
  24. package/dist/cli/runtimeAuthPrompt.js +5 -1
  25. package/dist/cli/start.js +138 -45
  26. package/dist/cli/tickets.d.ts +10 -1
  27. package/dist/cli/tickets.js +418 -79
  28. package/dist/foreman.d.ts +8 -5
  29. package/dist/foreman.js +38 -14
  30. package/dist/index.js +3 -1
  31. package/dist/providerQuestions.d.ts +23 -0
  32. package/dist/providerQuestions.js +24 -0
  33. package/dist/qaReview.d.ts +1 -0
  34. package/dist/qaReview.js +23 -15
  35. package/dist/qaSnapshot.d.ts +14 -0
  36. package/dist/qaSnapshot.js +117 -1
  37. package/dist/runtimeReadiness.js +5 -0
  38. package/dist/sources/sourceRegistry.js +13 -6
  39. package/dist/tickets/details.d.ts +17 -0
  40. package/dist/tickets/details.js +94 -0
  41. package/dist/tickets/eligibility.d.ts +28 -0
  42. package/dist/tickets/eligibility.js +71 -0
  43. package/dist/tickets/importer.js +13 -8
  44. package/dist/tickets/reset.d.ts +27 -0
  45. package/dist/tickets/reset.js +213 -0
  46. package/dist/tickets/reviewStandards.d.ts +7 -0
  47. package/dist/tickets/reviewStandards.js +53 -0
  48. package/dist/tickets/setupConfig.d.ts +22 -0
  49. package/dist/tickets/setupConfig.js +105 -1
  50. package/dist/tickets/sourceFetch.js +19 -0
  51. package/dist/tickets/stateDb.d.ts +8 -1
  52. package/dist/tickets/stateDb.js +54 -23
  53. package/dist/workflowDb.d.ts +3 -0
  54. package/dist/workflowDb.js +26 -0
  55. package/package.json +15 -3
package/dist/foreman.d.ts CHANGED
@@ -1,5 +1,6 @@
1
- import type { BuilderAdapter, PermissionHandler, TurnResult } from "./adapters/types.js";
1
+ import type { BuilderAdapter, PermissionHandler, PermissionRequest, TurnResult } from "./adapters/types.js";
2
2
  import type { PermissionPolicy } from "./permissions/policy.js";
3
+ import { type AnsweredProviderQuestion } from "./providerQuestions.js";
3
4
  import type { Log } from "./log.js";
4
5
  import { type QaNonconvergenceContext, type QaNonconvergenceDecision } from "./qaReview.js";
5
6
  import type { SessionStrategy } from "rafi-spec";
@@ -26,7 +27,7 @@ export declare function looksLikeQuestion(text: string): boolean;
26
27
  export { MARKER_SPEC } from "./markers.js";
27
28
  export { QA_MARKER_SPEC } from "./markers.js";
28
29
  /** Instruction sent on the first turn of a batch. */
29
- export declare function buildPrimer(n: number, trackerPath?: string, ticketsEnabled?: boolean): string;
30
+ export declare function buildPrimer(n: number, trackerPath?: string, ticketsEnabled?: boolean, preferredTicketId?: string): string;
30
31
  /** Instruction sent on turns 2–N. */
31
32
  export declare function buildNextStepInstruction(i: number, n: number): string;
32
33
  /** QA review turn: ask the builder to triple-check the ticket or step it just completed. */
@@ -34,10 +35,12 @@ export declare function buildQaInstruction(): string;
34
35
  /** Follow-up turn after qa_fail: have the builder implement the listed fixes. */
35
36
  export declare function buildQaFixInstruction(issues: string): string;
36
37
  /** Pre-flight planning turn: ask the builder to list its next N steps without implementing anything. */
37
- export declare function buildPlanningTurn(n: number, ticketsContent?: string): string;
38
+ export declare function buildPlanningTurn(n: number, ticketsContent?: string, preferredTicketId?: string): string;
38
39
  /** Builds the permission callback: classify, log, allow or escalate. */
39
40
  export declare function createPermissionHandler(policy: PermissionPolicy, log: Log, opts?: {
40
41
  interactive?: boolean;
42
+ onAnsweredQuestion?: (event: AnsweredProviderQuestion) => void;
43
+ onProviderQuestion?: (request: PermissionRequest) => void;
41
44
  }): PermissionHandler;
42
45
  export interface BatchResult {
43
46
  completed: number;
@@ -71,7 +74,7 @@ export declare class Foreman {
71
74
  private prepareBuilderBoundary;
72
75
  private doTurnWith;
73
76
  /** Send a planning turn and return the builder's response text. Does not count toward steps. */
74
- runPreflight(n: number, ticketsContent?: string): Promise<string>;
77
+ runPreflight(n: number, ticketsContent?: string, preferredTicketId?: string): Promise<string>;
75
78
  /** Send user feedback on the plan; builder responds with a revised list. Does not count toward steps. */
76
79
  sendPreflightFeedback(feedback: string): Promise<void>;
77
80
  /** Send one custom instruction through the same needs_input loop as a batch turn. */
@@ -95,5 +98,5 @@ export declare class Foreman {
95
98
  builderAdapter(): BuilderAdapter;
96
99
  close(): Promise<void>;
97
100
  private ticketForQa;
98
- runBatch(n: number, trackerPath?: string): Promise<BatchResult>;
101
+ runBatch(n: number, trackerPath?: string, onTicketStart?: (ticketId: string) => void | Promise<void>, preferredTicketId?: string): Promise<BatchResult>;
99
102
  }
package/dist/foreman.js CHANGED
@@ -107,7 +107,7 @@ export function looksLikeQuestion(text) {
107
107
  export { MARKER_SPEC } from "./markers.js";
108
108
  export { QA_MARKER_SPEC } from "./markers.js";
109
109
  /** Instruction sent on the first turn of a batch. */
110
- export function buildPrimer(n, trackerPath, ticketsEnabled = false) {
110
+ export function buildPrimer(n, trackerPath, ticketsEnabled = false, preferredTicketId) {
111
111
  let trackerRule;
112
112
  if (ticketsEnabled) {
113
113
  const progressDoc = trackerPath ?? "docs/ticket-progress.md";
@@ -122,12 +122,15 @@ export function buildPrimer(n, trackerPath, ticketsEnabled = false) {
122
122
  else {
123
123
  trackerRule = "";
124
124
  }
125
+ const preferredTicketRule = preferredTicketId
126
+ ? `\n- Resume and finish ticket ${preferredTicketId} first. Do not substitute a different queued ticket.`
127
+ : "";
125
128
  return `You are being run by an automated foreman. We will work through your next ${n} tickets or implementation steps, one per turn.
126
129
 
127
130
  Rules:
128
131
  - Do exactly ONE ticket or step this turn, then stop.
129
132
  - ${MARKER_SPEC}
130
- - If a tool action is denied by foreman policy, do not retry it; report it via the blocked marker.${trackerRule}
133
+ - If a tool action is denied by foreman policy, do not retry it; report it via the blocked marker.${trackerRule}${preferredTicketRule}
131
134
 
132
135
  This is step 1 of ${n}. Implement the next ticket or step now.`;
133
136
  }
@@ -160,17 +163,23 @@ ${issues}
160
163
  Fix every one of them now. Then end with STEP_STATUS: done so foreman can re-run QA on the fixes. Triple-check that your fixes actually resolve the issues before emitting done.`;
161
164
  }
162
165
  /** Pre-flight planning turn: ask the builder to list its next N steps without implementing anything. */
163
- export function buildPlanningTurn(n, ticketsContent) {
166
+ export function buildPlanningTurn(n, ticketsContent, preferredTicketId) {
164
167
  const header = ticketsContent
165
168
  ? `Here is the project's ticket list:\n\n${ticketsContent}\n\n`
166
169
  : "";
167
- return `${header}Before we begin, list the next ${n} ticket(s) or step(s) you plan to implement, in order. Be specific — reference ticket IDs or titles where applicable. Do not implement anything yet; output the numbered list only. Do not emit a STEP_STATUS marker on this turn.`;
170
+ const preferred = preferredTicketId
171
+ ? ` The first item must be ticket ${preferredTicketId}; this is an interrupted ticket being resumed.`
172
+ : "";
173
+ return `${header}Before we begin, list the next ${n} ticket(s) or step(s) you plan to implement, in order.${preferred} Be specific — reference ticket IDs or titles where applicable. Do not implement anything yet; output the numbered list only. Do not emit a STEP_STATUS marker on this turn.`;
168
174
  }
169
175
  /** Builds the permission callback: classify, log, allow or escalate. */
170
176
  export function createPermissionHandler(policy, log, opts = {}) {
171
177
  return async (req) => {
178
+ if (req.toolName === "AskUserQuestion")
179
+ opts.onProviderQuestion?.(req);
172
180
  const providerQuestion = await handleProviderQuestionTool(req, {
173
181
  interactive: opts.interactive ?? true,
182
+ onAnsweredQuestion: opts.onAnsweredQuestion,
174
183
  });
175
184
  if (providerQuestion) {
176
185
  log.write("permission", {
@@ -315,8 +324,8 @@ export class Foreman {
315
324
  return { result, status };
316
325
  }
317
326
  /** Send a planning turn and return the builder's response text. Does not count toward steps. */
318
- async runPreflight(n, ticketsContent) {
319
- const instruction = buildPlanningTurn(n, ticketsContent);
327
+ async runPreflight(n, ticketsContent, preferredTicketId) {
328
+ const instruction = buildPlanningTurn(n, ticketsContent, preferredTicketId);
320
329
  const result = await this.builder.sendTurn(instruction);
321
330
  this.log.write("preflight", {
322
331
  ticketsProvided: ticketsContent !== undefined,
@@ -343,10 +352,10 @@ export class Foreman {
343
352
  * Loops on qa_fail → fix → re-QA until qa_pass or the cycle cap is reached.
344
353
  * QA turns are free — they do not advance the step counter.
345
354
  */
346
- async runQa(stepIndex) {
355
+ async runQa(stepIndex, ticketId) {
347
356
  if (this.projectDir && this.qaFactory) {
348
357
  const review = await runIsolatedQa({
349
- ticket: this.ticketForQa(stepIndex),
358
+ ticket: this.ticketForQa(stepIndex, ticketId),
350
359
  builderWorktree: this.projectDir,
351
360
  builderSummary: `Builder completed step ${stepIndex}`,
352
361
  qaStrategy: this.qaSessionStrategy,
@@ -436,10 +445,12 @@ export class Foreman {
436
445
  builderSessionId() { return this.builder.sessionId(); }
437
446
  builderAdapter() { return this.builder; }
438
447
  async close() { await this.builder.close(); }
439
- ticketForQa(stepIndex) {
448
+ ticketForQa(stepIndex, ticketId) {
440
449
  if (this.projectDir && this.ticketsEnabled) {
441
450
  const config = loadTicketsConfig(this.projectDir);
442
- const active = cmdImplementationQueue(this.projectDir).find((row) => row.status === "next" || row.status === "in_progress");
451
+ const active = ticketId
452
+ ? { ticket: ticketId }
453
+ : cmdImplementationQueue(this.projectDir).find((row) => row.status === "next" || row.status === "in_progress");
443
454
  const ticket = loadTickets(join(this.projectDir, config.paths.tickets)).find((candidate) => candidate.id === active?.ticket);
444
455
  if (ticket)
445
456
  return ticket;
@@ -450,7 +461,7 @@ export class Foreman {
450
461
  acceptance: ["The requested implementation step is complete"], required_tests: ["Run the relevant project validation"], likely_files: [],
451
462
  };
452
463
  }
453
- async runBatch(n, trackerPath) {
464
+ async runBatch(n, trackerPath, onTicketStart, preferredTicketId) {
454
465
  this.log.write("batch-start", { requested: n, agent: this.builder.agent });
455
466
  let completed = 0;
456
467
  let outcome = "all-done";
@@ -461,9 +472,22 @@ export class Foreman {
461
472
  if (this.ticketsEnabled && this.projectDir) {
462
473
  try {
463
474
  const queue = cmdImplementationQueue(this.projectDir);
464
- const next = queue.find((r) => r.status === "next");
475
+ const next = i === 1 && preferredTicketId
476
+ ? queue.find((row) => row.ticket === preferredTicketId)
477
+ : queue.find((row) => row.status === "next");
478
+ if (i === 1 && preferredTicketId && !next) {
479
+ outcome = "needs-human";
480
+ detail = `recovery ticket ${preferredTicketId} is no longer available in the implementation queue`;
481
+ break;
482
+ }
483
+ if (i === 1 && preferredTicketId && next && next.status !== "next" && next.status !== "in_progress") {
484
+ outcome = "needs-human";
485
+ detail = `recovery ticket ${preferredTicketId} cannot resume while its status is ${next.status}`;
486
+ break;
487
+ }
465
488
  if (next) {
466
489
  pendingTicketId = next.ticket;
490
+ await onTicketStart?.(next.ticket);
467
491
  cmdUpdate(this.projectDir, next.ticket, {
468
492
  status: "in_progress",
469
493
  actor: "foreman",
@@ -478,7 +502,7 @@ export class Foreman {
478
502
  }
479
503
  }
480
504
  const instruction = i === 1
481
- ? buildPrimer(n, trackerPath, this.ticketsEnabled)
505
+ ? buildPrimer(n, trackerPath, this.ticketsEnabled, preferredTicketId)
482
506
  : buildNextStepInstruction(i, n);
483
507
  if (i > 1)
484
508
  await this.prepareBuilderBoundary();
@@ -503,7 +527,7 @@ export class Foreman {
503
527
  let qaSummary;
504
528
  let qaWaived = false;
505
529
  if (this.qaEnabled) {
506
- const qa = await this.runQa(i);
530
+ const qa = await this.runQa(i, status.ticket ?? pendingTicketId);
507
531
  if (qa.outcome === "blocked") {
508
532
  outcome = "blocked";
509
533
  detail = qa.detail;
package/dist/index.js CHANGED
@@ -5,6 +5,7 @@ import { buildTicketsCommand } from "./cli/tickets.js";
5
5
  import { buildStartCommand } from "./cli/start.js";
6
6
  import { buildStatusCommand } from "./cli/status.js";
7
7
  import { buildDoctorCommand } from "./cli/doctor.js";
8
+ import { withActivityContext } from "./activity.js";
8
9
  const PACKAGE_VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"))?.version;
9
10
  const program = new Command();
10
11
  program
@@ -20,7 +21,8 @@ program.addCommand(buildDoctorCommand());
20
21
  const argv = [...process.argv];
21
22
  if (argv[2] === "--")
22
23
  argv.splice(2, 1);
23
- program.parseAsync(argv).catch((err) => {
24
+ const command = argv.slice(2).filter((arg) => !arg.startsWith("-")).slice(0, 3).join(" ") || "ai-foreman";
25
+ withActivityContext(command, () => program.parseAsync(argv)).catch((err) => {
24
26
  console.error(`foreman: ${String(err)}`);
25
27
  process.exit(1);
26
28
  });
@@ -1,5 +1,16 @@
1
1
  import { isCancel, multiselect, select, text } from "@clack/prompts";
2
2
  import type { PermissionDecision, PermissionRequest } from "./adapters/types.js";
3
+ export interface PromptOption {
4
+ label: string;
5
+ description?: string;
6
+ preview?: string;
7
+ }
8
+ export interface ProviderQuestion {
9
+ question: string;
10
+ header?: string;
11
+ options: PromptOption[];
12
+ multiSelect: boolean;
13
+ }
3
14
  export interface ProviderQuestionPromptDeps {
4
15
  select: typeof select;
5
16
  multiselect: typeof multiselect;
@@ -9,6 +20,18 @@ export interface ProviderQuestionPromptDeps {
9
20
  export interface ProviderQuestionOptions {
10
21
  interactive: boolean;
11
22
  prompts?: ProviderQuestionPromptDeps;
23
+ /** Called only after a non-empty answer has been successfully collected. */
24
+ onAnsweredQuestion?: (event: AnsweredProviderQuestion) => void;
25
+ }
26
+ export interface AnsweredProviderQuestion {
27
+ toolName: "AskUserQuestion";
28
+ question: ProviderQuestion;
29
+ /** Number of readable questions presented by this provider tool call. */
30
+ questionCount: number;
31
+ answer: string;
12
32
  }
33
+ export declare const GRILL_ME_STOP_CHOICE = "Stop questions and make the plan now";
13
34
  export declare function handleProviderQuestionTool(req: PermissionRequest, opts: ProviderQuestionOptions): Promise<PermissionDecision | undefined>;
35
+ /** Machine-recognizable native grill-me question shape. */
36
+ export declare function isGrillMeProviderQuestion(event: AnsweredProviderQuestion): boolean;
14
37
  export declare function countProviderQuestions(input: Record<string, unknown>): number;
@@ -1,6 +1,7 @@
1
1
  import { isCancel, multiselect, select, text } from "@clack/prompts";
2
2
  const ASK_USER_QUESTION_TOOL = "AskUserQuestion";
3
3
  const CUSTOM_VALUE = "__rafi_custom_response__";
4
+ export const GRILL_ME_STOP_CHOICE = "Stop questions and make the plan now";
4
5
  export async function handleProviderQuestionTool(req, opts) {
5
6
  if (req.toolName !== ASK_USER_QUESTION_TOOL)
6
7
  return undefined;
@@ -34,6 +35,14 @@ export async function handleProviderQuestionTool(req, opts) {
34
35
  answers[question.question] = answered.answer;
35
36
  if (answered.annotation)
36
37
  annotations[question.question] = answered.annotation;
38
+ if (answered.answer.trim()) {
39
+ opts.onAnsweredQuestion?.({
40
+ toolName: ASK_USER_QUESTION_TOOL,
41
+ question,
42
+ questionCount: questions.length,
43
+ answer: answered.answer,
44
+ });
45
+ }
37
46
  }
38
47
  const updatedInput = {
39
48
  ...req.input,
@@ -43,6 +52,21 @@ export async function handleProviderQuestionTool(req, opts) {
43
52
  updatedInput.annotations = annotations;
44
53
  return { behavior: "allow", updatedInput };
45
54
  }
55
+ /** Machine-recognizable native grill-me question shape. */
56
+ export function isGrillMeProviderQuestion(event) {
57
+ const { question } = event;
58
+ if (event.questionCount !== 1 || question.multiSelect)
59
+ return false;
60
+ if (!/^grill-me\b/i.test(question.header ?? ""))
61
+ return false;
62
+ const labels = question.options.map((option) => option.label.trim());
63
+ if (labels.length < 3 || !labels[0]?.endsWith("(Recommended)"))
64
+ return false;
65
+ const stopIndex = labels.indexOf(GRILL_ME_STOP_CHOICE);
66
+ if (stopIndex < 0)
67
+ return false;
68
+ return labels.some((label, index) => index > 0 && index !== stopIndex && label.length > 0);
69
+ }
46
70
  export function countProviderQuestions(input) {
47
71
  const questions = input.questions;
48
72
  return Array.isArray(questions) ? questions.length : 0;
@@ -45,6 +45,7 @@ export interface IsolatedQaResult {
45
45
  summary?: string;
46
46
  }
47
47
  export declare function runIsolatedQa(opts: IsolatedQaOptions): Promise<IsolatedQaResult>;
48
+ export declare function buildQaReviewHandoff(ticket: TicketDef, builderSummary: string, diffDigest: string, validationChecklist: string[]): string;
48
49
  export declare function compactWithRetry(adapter: BuilderAdapter): Promise<{
49
50
  ok: boolean;
50
51
  error?: string;
package/dist/qaReview.js CHANGED
@@ -1,9 +1,11 @@
1
+ import { currentActivity, withActivityPhase } from "./activity.js";
1
2
  import { buildQaFixInstruction, buildQaInstruction, parseStepStatus } from "./foreman.js";
2
- import { createDisposableQaSnapshot } from "./qaSnapshot.js";
3
+ import { createDisposableQaSnapshotAsync } from "./qaSnapshot.js";
4
+ import { loadTicketSetupConfigWithDefaults } from "./tickets/setupConfig.js";
3
5
  export async function runIsolatedQa(opts) {
4
6
  const history = [];
5
7
  for (let cycle = 1; cycle <= opts.maxCycles; cycle++) {
6
- const review = await oneReview(opts, cycle);
8
+ const review = await withActivityPhase(`running QA cycle ${cycle}/${opts.maxCycles}`, () => oneReview(opts, cycle));
7
9
  if (review.outcome === "retry-modification") {
8
10
  cycle -= 1;
9
11
  continue;
@@ -15,7 +17,7 @@ export async function runIsolatedQa(opts) {
15
17
  history.push({ cycle, outcome: "qa_fail", detail: review.detail });
16
18
  if (cycle === opts.maxCycles)
17
19
  break;
18
- const fix = await opts.fix(review.detail ?? "QA reported unspecified issues");
20
+ const fix = await withActivityPhase("applying QA fixes", () => opts.fix(review.detail ?? "QA reported unspecified issues"));
19
21
  if (!fix.ok)
20
22
  return { outcome: "blocked", detail: fix.detail ?? "Builder QA fix failed" };
21
23
  }
@@ -30,36 +32,32 @@ export async function runIsolatedQa(opts) {
30
32
  const issues = decision.action === "remediate" ? decision.remediation : history.at(-1)?.detail;
31
33
  if (!issues)
32
34
  return { outcome: "nonconverged", detail: "Planner remediation did not produce approved fix instructions" };
33
- const fix = await opts.fix(issues);
35
+ const fix = await withActivityPhase("applying QA remediation", () => opts.fix(issues));
34
36
  if (!fix.ok)
35
37
  return { outcome: "blocked", detail: fix.detail ?? "Builder QA fix failed" };
36
38
  return runIsolatedQa(opts);
37
39
  }
38
40
  async function oneReview(opts, cycle) {
39
- const snapshot = createDisposableQaSnapshot(opts.builderWorktree);
41
+ const progress = (state, detail) => currentActivity()?.update(state, detail);
42
+ const snapshot = await withActivityPhase("preparing disposable QA snapshot", () => createDisposableQaSnapshotAsync(opts.builderWorktree, progress));
40
43
  let qa;
41
44
  try {
42
45
  const resume = opts.qaStrategy === "compact" && opts.state.modificationViolations === 0 ? opts.state.sessionId : undefined;
43
46
  qa = await opts.createQa(snapshot.path, resume);
44
47
  if (opts.qaStrategy === "compact" && opts.state.reviews > 0 && resume) {
45
- const compacted = await compactWithRetry(qa);
48
+ const compacted = await withActivityPhase("compacting QA session", () => compactWithRetry(qa));
46
49
  if (!compacted.ok) {
47
50
  await qa.close();
48
51
  qa = await opts.createQa(snapshot.path);
49
52
  opts.state.sessionId = undefined;
50
53
  }
51
54
  }
52
- const handoff = [
53
- buildQaInstruction(), "", "QA handoff:", `Ticket: ${opts.ticket.id} — ${opts.ticket.title}`,
54
- `Acceptance: ${opts.ticket.acceptance.join("; ")}`, `Builder summary: ${opts.builderSummary}`,
55
- `Changed files/diff digest: ${snapshot.manifest.diffDigest}`, `Required tests: ${opts.ticket.required_tests.join("; ")}`,
56
- "Do not change source, configuration, documentation, tickets, or control files. Only ignored cache, coverage, and build output is allowed.",
57
- ].join("\n");
55
+ const handoff = buildQaReviewHandoff(opts.ticket, opts.builderSummary, snapshot.manifest.diffDigest, loadTicketSetupConfigWithDefaults(opts.builderWorktree).build.validation_checklist);
58
56
  const turn = await qa.sendTurn(handoff);
59
57
  const status = parseStepStatus(turn.text);
60
58
  opts.state.reviews += 1;
61
59
  opts.state.sessionId = qa.sessionId();
62
- const changes = snapshot.qaChanges();
60
+ const changes = await withActivityPhase("checking QA file changes", () => snapshot.qaChanges());
63
61
  if (changes.length) {
64
62
  opts.state.modificationViolations += 1;
65
63
  opts.evidence?.({ cycle, outcome: "qa_file_modification", detail: "QA modified the disposable review copy", qaDiff: changes });
@@ -82,10 +80,20 @@ async function oneReview(opts, cycle) {
82
80
  return { outcome: "needs-human", detail: status.error ?? `QA returned ${status.kind}` };
83
81
  }
84
82
  finally {
85
- await qa?.close().catch(() => { });
86
- snapshot.remove();
83
+ if (qa)
84
+ await withActivityPhase("closing QA session", () => qa.close().catch(() => { }));
85
+ await withActivityPhase("cleaning up disposable QA snapshot", () => snapshot.remove());
87
86
  }
88
87
  }
88
+ export function buildQaReviewHandoff(ticket, builderSummary, diffDigest, validationChecklist) {
89
+ return [
90
+ buildQaInstruction(), "", "QA handoff:", `Ticket: ${ticket.id} — ${ticket.title}`,
91
+ `Acceptance: ${ticket.acceptance.join("; ")}`, `Builder summary: ${builderSummary}`,
92
+ `Changed files/diff digest: ${diffDigest}`, `Required tests: ${ticket.required_tests.join("; ")}`,
93
+ `Project validation checklist: ${validationChecklist.join("; ")}`,
94
+ "Do not change source, configuration, documentation, tickets, or control files. Only ignored cache, coverage, and build output is allowed.",
95
+ ].join("\n");
96
+ }
89
97
  export async function compactWithRetry(adapter) {
90
98
  if (!adapter.compact)
91
99
  return { ok: false, error: "native compaction unavailable" };
@@ -14,6 +14,20 @@ export interface DisposableQaSnapshot {
14
14
  qaChanges(): string[];
15
15
  remove(): void;
16
16
  }
17
+ export interface AsyncDisposableQaSnapshot {
18
+ path: string;
19
+ manifest: QaChangeManifest;
20
+ verify(): Promise<void>;
21
+ qaChanges(): Promise<string[]>;
22
+ remove(): Promise<void>;
23
+ }
24
+ export type QaSnapshotProgress = (state: string, detail?: string) => void;
17
25
  /** Reproduce the Builder's exact change set in a detached disposable worktree. */
18
26
  export declare function createDisposableQaSnapshot(builderWorktree: string): DisposableQaSnapshot;
27
+ /**
28
+ * Async form used by the live CLI. Git and filesystem work yields to the event
29
+ * loop so the activity reporter can keep animating during large snapshots.
30
+ */
31
+ export declare function createDisposableQaSnapshotAsync(builderWorktree: string, progress?: QaSnapshotProgress): Promise<AsyncDisposableQaSnapshot>;
19
32
  export declare function changeManifest(worktree: string): QaChangeManifest;
33
+ export declare function changeManifestAsync(worktree: string, progress?: QaSnapshotProgress, state?: string): Promise<QaChangeManifest>;
@@ -1,8 +1,9 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { cpSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readlinkSync, rmSync, symlinkSync } from "node:fs";
3
+ import { cp, lstat, mkdir, mkdtemp, readFile, readlink, rm, symlink } from "node:fs/promises";
3
4
  import { tmpdir } from "node:os";
4
5
  import { dirname, join, resolve } from "node:path";
5
- import { execFileSync, spawnSync } from "node:child_process";
6
+ import { execFileSync, spawn, spawnSync } from "node:child_process";
6
7
  /** Reproduce the Builder's exact change set in a detached disposable worktree. */
7
8
  export function createDisposableQaSnapshot(builderWorktree) {
8
9
  const source = resolve(builderWorktree);
@@ -47,6 +48,69 @@ export function createDisposableQaSnapshot(builderWorktree) {
47
48
  throw error;
48
49
  }
49
50
  }
51
+ /**
52
+ * Async form used by the live CLI. Git and filesystem work yields to the event
53
+ * loop so the activity reporter can keep animating during large snapshots.
54
+ */
55
+ export async function createDisposableQaSnapshotAsync(builderWorktree, progress = () => { }) {
56
+ const source = resolve(builderWorktree);
57
+ progress("preparing disposable QA snapshot", "locating Builder worktree");
58
+ const root = (await runGit(source, ["rev-parse", "--show-toplevel"])).toString().trim();
59
+ const tempRoot = await mkdtemp(join(tmpdir(), "rafi-qa-"));
60
+ const review = join(tempRoot, "review");
61
+ const head = (await runGit(source, ["rev-parse", "HEAD"])).toString().trim();
62
+ progress("preparing disposable QA snapshot", "creating detached review worktree");
63
+ try {
64
+ await runGit(root, ["worktree", "add", "--detach", review, head]);
65
+ }
66
+ catch (error) {
67
+ await rm(tempRoot, { recursive: true, force: true });
68
+ throw new Error(`cannot create disposable QA worktree: ${errorMessage(error)}`);
69
+ }
70
+ try {
71
+ progress("preparing disposable QA snapshot", "copying tracked Builder changes");
72
+ const diff = await runGit(source, ["diff", "--binary", "HEAD"]);
73
+ if (diff.length) {
74
+ try {
75
+ await runGit(review, ["apply", "--index", "--binary", "--whitespace=nowarn", "-"], diff);
76
+ }
77
+ catch (error) {
78
+ throw new Error(`cannot apply Builder diff to QA worktree: ${errorMessage(error)}`);
79
+ }
80
+ }
81
+ const paths = await untrackedPathsAsync(source);
82
+ for (let index = 0; index < paths.length; index++) {
83
+ progress("preparing disposable QA snapshot", `copying untracked files ${index + 1}/${paths.length}`);
84
+ await copyUntrackedAsync(source, review, paths[index]);
85
+ }
86
+ progress("preparing disposable QA snapshot", "recording Builder change manifest");
87
+ const manifest = await changeManifestAsync(source, progress, "preparing disposable QA snapshot");
88
+ const snapshot = {
89
+ path: review,
90
+ manifest,
91
+ verify: async () => {
92
+ const actual = await changeManifestAsync(review, progress, "preparing disposable QA snapshot");
93
+ if (JSON.stringify(actual) !== JSON.stringify(manifest)) {
94
+ throw new Error(`disposable QA snapshot does not match Builder change manifest: expected ${JSON.stringify(manifest)}, got ${JSON.stringify(actual)}`);
95
+ }
96
+ },
97
+ qaChanges: async () => manifestDifference(manifest, await changeManifestAsync(review, progress)),
98
+ remove: async () => {
99
+ progress("cleaning up disposable QA snapshot", "removing detached review worktree");
100
+ await runGit(root, ["worktree", "remove", "--force", review]).catch(() => { });
101
+ await rm(tempRoot, { recursive: true, force: true });
102
+ },
103
+ };
104
+ progress("preparing disposable QA snapshot", "verifying review copy");
105
+ await snapshot.verify();
106
+ return snapshot;
107
+ }
108
+ catch (error) {
109
+ await runGit(root, ["worktree", "remove", "--force", review]).catch(() => { });
110
+ await rm(tempRoot, { recursive: true, force: true });
111
+ throw error;
112
+ }
113
+ }
50
114
  export function changeManifest(worktree) {
51
115
  const diff = execFileSync("git", ["-C", worktree, "diff", "--binary", "HEAD"], { encoding: "buffer", maxBuffer: 128 * 1024 * 1024 });
52
116
  const untracked = untrackedPaths(worktree).map((path) => {
@@ -58,10 +122,29 @@ export function changeManifest(worktree) {
58
122
  });
59
123
  return { diffDigest: createHash("sha256").update(diff).digest("hex"), untracked };
60
124
  }
125
+ export async function changeManifestAsync(worktree, progress = () => { }, state = "checking QA file changes") {
126
+ const diff = await runGit(worktree, ["diff", "--binary", "HEAD"]);
127
+ const paths = await untrackedPathsAsync(worktree);
128
+ const untracked = [];
129
+ for (let index = 0; index < paths.length; index++) {
130
+ const path = paths[index];
131
+ progress(state, `hashing untracked files ${index + 1}/${paths.length}`);
132
+ const absolute = join(worktree, path);
133
+ const stat = await lstat(absolute);
134
+ const kind = stat.isSymbolicLink() ? "symlink" : "file";
135
+ const bytes = kind === "symlink" ? Buffer.from(await readlink(absolute)) : await readFile(absolute);
136
+ untracked.push({ path, kind, mode: stat.mode & 0o7777, digest: createHash("sha256").update(bytes).digest("hex") });
137
+ }
138
+ return { diffDigest: createHash("sha256").update(diff).digest("hex"), untracked };
139
+ }
61
140
  function untrackedPaths(worktree) {
62
141
  const raw = execFileSync("git", ["-C", worktree, "ls-files", "--others", "--exclude-standard", "-z"], { encoding: "buffer" });
63
142
  return raw.toString().split("\0").filter(Boolean).sort();
64
143
  }
144
+ async function untrackedPathsAsync(worktree) {
145
+ const raw = await runGit(worktree, ["ls-files", "--others", "--exclude-standard", "-z"]);
146
+ return raw.toString().split("\0").filter(Boolean).sort();
147
+ }
65
148
  function copyUntracked(source, target, path) {
66
149
  const from = join(source, path);
67
150
  const to = join(target, path);
@@ -75,6 +158,39 @@ function copyUntracked(source, target, path) {
75
158
  else
76
159
  cpSync(from, to, { preserveTimestamps: true });
77
160
  }
161
+ async function copyUntrackedAsync(source, target, path) {
162
+ const from = join(source, path);
163
+ const to = join(target, path);
164
+ await mkdir(dirname(to), { recursive: true });
165
+ const stat = await lstat(from);
166
+ if (stat.isSymbolicLink()) {
167
+ await rm(to, { force: true });
168
+ await symlink(await readlink(from), to);
169
+ }
170
+ else {
171
+ await cp(from, to, { preserveTimestamps: true });
172
+ }
173
+ }
174
+ function runGit(cwd, args, input) {
175
+ return new Promise((resolvePromise, reject) => {
176
+ const child = spawn("git", ["-C", cwd, ...args], { stdio: ["pipe", "pipe", "pipe"] });
177
+ const stdout = [];
178
+ const stderr = [];
179
+ child.stdout.on("data", (chunk) => stdout.push(chunk));
180
+ child.stderr.on("data", (chunk) => stderr.push(chunk));
181
+ child.on("error", reject);
182
+ child.on("close", (code) => {
183
+ if (code === 0)
184
+ resolvePromise(Buffer.concat(stdout));
185
+ else
186
+ reject(new Error(Buffer.concat(stderr).toString().trim() || `git exited with status ${code ?? "unknown"}`));
187
+ });
188
+ child.stdin.end(input);
189
+ });
190
+ }
191
+ function errorMessage(error) {
192
+ return error instanceof Error ? error.message : String(error);
193
+ }
78
194
  function manifestDifference(before, after) {
79
195
  const changes = [];
80
196
  if (before.diffDigest !== after.diffDigest)
@@ -1,6 +1,7 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { accessSync, constants, statSync } from "node:fs";
3
3
  import { delimiter, isAbsolute, resolve } from "node:path";
4
+ import { currentActivity, withActivityPhase } from "./activity.js";
4
5
  export const RUNTIME_PROBE_TIMEOUT_MS = 30_000;
5
6
  export const RUNTIME_DIAGNOSTIC_LIMIT = 8 * 1024;
6
7
  const RELEVANT_ENV = /^(ANTHROPIC|CLAUDE|CODEX|OPENAI|HTTP_PROXY|HTTPS_PROXY|NO_PROXY|SSL_CERT_FILE|NODE_EXTRA_CA_CERTS)(_|$)/i;
@@ -52,6 +53,9 @@ function isRunnableFile(path, platform) {
52
53
  }
53
54
  }
54
55
  export async function probeRuntime(cwd, runtime, opts = {}) {
56
+ return withActivityPhase(`checking ${runtime} runtime`, () => probeRuntimeInternal(cwd, runtime, opts));
57
+ }
58
+ async function probeRuntimeInternal(cwd, runtime, opts) {
55
59
  const env = opts.env ?? process.env;
56
60
  const command = runtime === "claude" ? "claude" : "codex";
57
61
  const executable = resolveExecutablePath(command, env) ?? command;
@@ -68,6 +72,7 @@ export async function probeRuntime(cwd, runtime, opts = {}) {
68
72
  const spawnOpts = { cwd, env, stdio: ["ignore", "pipe", "pipe"] };
69
73
  const child = spawn(executable, args, spawnOpts);
70
74
  const append = (chunk) => {
75
+ currentActivity()?.pulse(`${runtime} runtime responded`);
71
76
  if (output.length >= limit)
72
77
  return;
73
78
  const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
@@ -1,10 +1,13 @@
1
- import { execFileSync } from "node:child_process";
1
+ import { execFile, execFileSync } from "node:child_process";
2
+ import { promisify } from "node:util";
2
3
  import { createHash } from "node:crypto";
3
4
  import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
4
5
  import { basename, isAbsolute, join, relative, resolve, sep } from "node:path";
5
6
  import { parse, stringify } from "yaml";
6
7
  import { fetchAndSnapshotUrl, normalizePublicHttpUrl, snapshotExternalLocalFile } from "../tickets/sourceFetch.js";
7
8
  import { fetchJiraIssues, fetchLinearIssues } from "../tickets/importer.js";
9
+ import { withActivityPhase } from "../activity.js";
10
+ const execFileAsync = promisify(execFile);
8
11
  export const SOURCE_REQUEST_START = "RAFI_SOURCE_REQUEST_START";
9
12
  export const SOURCE_REQUEST_END = "RAFI_SOURCE_REQUEST_END";
10
13
  const CONFIG_FILE = "rafi-config.yaml";
@@ -148,9 +151,12 @@ export function sourceRequestFromAnswer(answer, projectDir) {
148
151
  if (/^https?:\/\/\S+$/i.test(value))
149
152
  return requestFromUrl(value);
150
153
  const candidate = resolve(projectDir, value);
151
- if (value && existsSync(candidate))
152
- return { type: "local", label: basename(candidate), locator: { path: normalizeLocalPath(projectDir, candidate) } };
153
- if (hasGlobMagic(value) || /^(?:\.{0,2}\/|\/)/.test(value) || /[\\/][^\\/]+\.[A-Za-z0-9]{1,12}$/.test(value))
154
+ if (value && existsSync(candidate)) {
155
+ const rel = relative(resolve(projectDir), candidate);
156
+ const path = !rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel) ? rel || "." : candidate;
157
+ return { type: "local", label: basename(candidate), locator: { path } };
158
+ }
159
+ if (hasGlobMagic(value) || /^(?:\.{0,2}\/|\/)/.test(value) || /^[^\s]+[\\/][^\\/]+\.[A-Za-z0-9]{1,12}$/.test(value))
154
160
  return { type: "local", label: basename(value), locator: { path: value } };
155
161
  if (/^(?:open\s+)?issues\s+for\s+(?:this|the)\s+repo(?:sitory)?$/i.test(value))
156
162
  return { type: inferOriginProvider(projectDir), label: "Open repository issues", locator: { repository: inferOriginRepository(projectDir), mode: "issues", filters: { state: "open" } } };
@@ -272,7 +278,7 @@ async function captureUrl(projectDir, entry, storage) {
272
278
  rmSync(resolve(projectDir, fetched.metadataPath), { force: true });
273
279
  return version;
274
280
  }
275
- function captureCliProvider(projectDir, entry, storage) {
281
+ async function captureCliProvider(projectDir, entry, storage) {
276
282
  const repository = entry.locator.repository || inferOriginRepository(projectDir);
277
283
  const mode = entry.locator.mode ?? "issues";
278
284
  let command;
@@ -309,7 +315,8 @@ function captureCliProvider(projectDir, entry, storage) {
309
315
  }
310
316
  let text;
311
317
  try {
312
- text = execFileSync(command, args, { cwd: projectDir, encoding: "utf8", maxBuffer: 20 * 1024 * 1024 });
318
+ const result = await withActivityPhase(`fetching ${entry.type} source`, () => execFileAsync(command, args, { cwd: projectDir, encoding: "utf8", maxBuffer: 20 * 1024 * 1024 }));
319
+ text = result.stdout;
313
320
  }
314
321
  catch (error) {
315
322
  throw new Error(`${entry.type} source ${entry.label} could not be retrieved with authenticated ${command}: ${error instanceof Error ? error.message : String(error)}`);
@@ -0,0 +1,17 @@
1
+ import { type DeliveryStack, type DeliveryUnit } from "./delivery.js";
2
+ import { type TicketEvent, type TicketState, type ValidationSnapshot } from "./stateDb.js";
3
+ import type { TicketDef } from "./ticketSchema.js";
4
+ export interface TicketDeliveryDetails {
5
+ units: DeliveryUnit[];
6
+ stacks: DeliveryStack[];
7
+ }
8
+ export interface TicketDetails {
9
+ definition: TicketDef & Record<string, unknown>;
10
+ state: TicketState;
11
+ effective_blockers: string[];
12
+ validation_history: ValidationSnapshot[];
13
+ events: TicketEvent[];
14
+ delivery: TicketDeliveryDetails;
15
+ }
16
+ export declare function getTicketDetails(projectDir: string, ticketId: string, now?: Date): TicketDetails;
17
+ export declare function formatTicketDetails(details: TicketDetails): string[];