ai-foreman 1.7.3 → 1.7.5

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.
@@ -8,13 +8,30 @@ export interface PermissionRequest {
8
8
  input: Record<string, unknown>;
9
9
  /** Human-readable prompt from the agent, when available. */
10
10
  title?: string;
11
+ /** Signaled if the provider-side operation should be aborted. */
12
+ signal?: AbortSignal;
13
+ /** Provider-native identifier for this specific tool call, when available. */
14
+ toolUseID?: string;
15
+ /** Short provider-rendered name for the requested action, when available. */
16
+ displayName?: string;
17
+ /** Provider-rendered detail for the requested action, when available. */
18
+ description?: string;
19
+ /** Provider explanation for why permission was requested, when available. */
20
+ decisionReason?: string;
21
+ /** Provider path that triggered the permission request, when available. */
22
+ blockedPath?: string;
11
23
  }
12
24
  /** The foreman's verdict on a permission request. */
13
25
  export type PermissionDecision = {
14
26
  behavior: "allow";
27
+ /** Provider-native tool input to continue with after host interaction. */
28
+ updatedInput?: Record<string, unknown>;
29
+ /** Provider-native permission updates to apply after approval. */
30
+ updatedPermissions?: Array<Record<string, unknown>>;
15
31
  } | {
16
32
  behavior: "deny";
17
33
  message: string;
34
+ interrupt?: boolean;
18
35
  };
19
36
  /** Decides each permission request. Supplied by the foreman, called by the adapter. */
20
37
  export type PermissionHandler = (req: PermissionRequest) => Promise<PermissionDecision>;
@@ -42,6 +59,21 @@ export type BuilderEvent = {
42
59
  kind: "tool";
43
60
  name: string;
44
61
  input: unknown;
62
+ } | {
63
+ kind: "activity";
64
+ state: string;
65
+ detail?: string;
66
+ provider?: "claude" | "codex";
67
+ model?: string;
68
+ transient?: boolean;
69
+ } | {
70
+ kind: "retry";
71
+ provider: "claude" | "codex";
72
+ reason: string;
73
+ attempt?: number;
74
+ maximum?: number;
75
+ delayMs?: number;
76
+ managedBy: "provider" | "rafi";
45
77
  } | {
46
78
  kind: "turn-complete";
47
79
  result: TurnResult;
@@ -2,6 +2,7 @@ import { type PermissionConfig } from "./config.js";
2
2
  import { Log } from "./log.js";
3
3
  import { type StepStatus } from "./foreman.js";
4
4
  import type { BuilderAdapter, BuilderAdapterOptions, EffortLevel, TurnResult } from "./adapters/types.js";
5
+ import type { AnsweredProviderQuestion } from "./providerQuestions.js";
5
6
  import { type RoleBundle } from "./roles.js";
6
7
  import type { AgentRuntime } from "./runtimeAuth.js";
7
8
  import type { AgentRoleDefaultsV1 } from "rafi-spec";
@@ -22,6 +23,10 @@ export interface RoleBuilderOptions {
22
23
  extraSkills?: string[];
23
24
  sandboxMode?: BuilderAdapterOptions["sandboxMode"];
24
25
  resumeSessionId?: string;
26
+ /** Observe successfully answered provider-native questions. */
27
+ onAnsweredQuestion?: (event: AnsweredProviderQuestion) => void;
28
+ /** Observe any provider-native question attempt, including denied prompts. */
29
+ onProviderQuestion?: (request: import("./adapters/types.js").PermissionRequest) => void;
25
30
  }
26
31
  export interface RoleBuilder {
27
32
  builder: BuilderAdapter;
package/dist/agentRun.js CHANGED
@@ -107,6 +107,7 @@ export async function createRoleBuilder(opts) {
107
107
  let runtimeExecutable = ready.executable;
108
108
  const policy = new PermissionPolicy(opts.permissionConfig ?? config.permissions, opts.projectDir);
109
109
  const skills = mergeSkills(roleBundle.skills, opts.extraSkills);
110
+ const interactive = !opts.yes && Boolean(process.stdin.isTTY && process.stdout.isTTY);
110
111
  const makeAdapter = async (nextRuntime, resumeSessionId) => {
111
112
  const adapterOpts = {
112
113
  cwd: opts.projectDir,
@@ -114,7 +115,11 @@ export async function createRoleBuilder(opts) {
114
115
  runtimePhase: phaseForRole(opts.role),
115
116
  model,
116
117
  resumeSessionId,
117
- permission: createPermissionHandler(policy, log),
118
+ permission: createPermissionHandler(policy, log, {
119
+ interactive,
120
+ onAnsweredQuestion: opts.onAnsweredQuestion,
121
+ onProviderQuestion: opts.onProviderQuestion,
122
+ }),
118
123
  effort,
119
124
  fast: opts.fast ?? saved?.fast,
120
125
  sandboxMode: opts.sandboxMode,
@@ -130,7 +135,7 @@ export async function createRoleBuilder(opts) {
130
135
  initial,
131
136
  runtime,
132
137
  label: opts.label,
133
- enabled: !opts.yes && Boolean(process.stdin.isTTY && process.stdout.isTTY),
138
+ enabled: interactive,
134
139
  allowSwitch: opts.allowSwitch !== false && !opts.resumeSessionId,
135
140
  recreate: async (nextRuntime, resumeSessionId) => {
136
141
  const nextReady = await ensureRuntimeReadyForCommand(opts.projectDir, nextRuntime, {
@@ -1,6 +1,7 @@
1
1
  import { spawnSync } from "node:child_process";
2
2
  import { mkdirSync, writeFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
+ import { branchNodeFooter } from "./presentation.js";
4
5
  const DEFAULT_COMMAND_TIMEOUT_MS = 15_000;
5
6
  const OUTPUT_LIMIT = 2_000;
6
7
  function runCommand(cwd, bin, args, timeoutMs = DEFAULT_COMMAND_TIMEOUT_MS) {
@@ -440,7 +441,7 @@ function buildPrBody(opts) {
440
441
  `- Dependencies: ${opts.node.dependencies.length ? opts.node.dependencies.join(", ") : "None"}`,
441
442
  `- Commit: ${opts.commit ?? "N/A"}`,
442
443
  "",
443
- "Generated by Rafi ai-foreman branch-per-ticket mode.",
444
+ branchNodeFooter(opts.node),
444
445
  "",
445
446
  ].join("\n");
446
447
  }
@@ -1,6 +1,7 @@
1
1
  import { spawnSync } from "node:child_process";
2
2
  import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
+ import { branchNodeFooter } from "./presentation.js";
4
5
  const DEFAULT_COMMAND_TIMEOUT_MS = 15_000;
5
6
  const OUTPUT_LIMIT = 2_000;
6
7
  export function preflightGlab(cwd) {
@@ -272,7 +273,7 @@ function buildMrBody(opts) {
272
273
  `- Dependencies: ${opts.node.dependencies.length ? opts.node.dependencies.join(", ") : "None"}`,
273
274
  `- Commit: ${opts.commit ?? "N/A"}`,
274
275
  "",
275
- "Generated by Rafi ai-foreman branch-per-ticket mode.",
276
+ branchNodeFooter(opts.node),
276
277
  "",
277
278
  ].join("\n");
278
279
  }
@@ -0,0 +1,17 @@
1
+ import type { BranchPlan, BranchPlanNode } from "./types.js";
2
+ export type BranchAllocationMode = "per-ticket" | "shared" | "mixed";
3
+ export interface BranchPresentation {
4
+ allocationMode: BranchAllocationMode;
5
+ description: "one branch per ticket" | "one shared branch" | "mixed shared and per-ticket branches" | "stacked delivery branches";
6
+ banner: string;
7
+ prompt: string;
8
+ }
9
+ export interface BranchPresentationOptions {
10
+ stacked?: boolean;
11
+ resumed?: boolean;
12
+ }
13
+ export declare function branchPlanLogMetadata(plan: BranchPlan, presentation: BranchPresentation): Record<string, unknown>;
14
+ /** Describe an isolated branch run from its final, transformed plan. */
15
+ export declare function presentBranchPlan(plan: BranchPlan, options?: BranchPresentationOptions): BranchPresentation;
16
+ export declare function branchNodeBehavior(node: BranchPlanNode): "shared delivery branch" | "per-ticket branch";
17
+ export declare function branchNodeFooter(node: BranchPlanNode): string;
@@ -0,0 +1,38 @@
1
+ export function branchPlanLogMetadata(plan, presentation) {
2
+ return {
3
+ baseRef: plan.baseRef,
4
+ tickets: plan.nodes.map((node) => node.ticket.id),
5
+ branches: plan.nodes.map((node) => ({ ticket: node.ticket.id, branch: node.branch, base: node.baseBranch })),
6
+ issues: plan.issues,
7
+ branchMode: presentation.allocationMode,
8
+ executionDescription: presentation.description,
9
+ };
10
+ }
11
+ /** Describe an isolated branch run from its final, transformed plan. */
12
+ export function presentBranchPlan(plan, options = {}) {
13
+ const sharedNodes = plan.nodes.filter((node) => Boolean(node.deliveryUnitId)).length;
14
+ const allocationMode = sharedNodes === 0
15
+ ? "per-ticket"
16
+ : sharedNodes === plan.nodes.length
17
+ ? "shared"
18
+ : "mixed";
19
+ const description = options.stacked
20
+ ? "stacked delivery branches"
21
+ : allocationMode === "shared"
22
+ ? "one shared branch"
23
+ : allocationMode === "mixed"
24
+ ? "mixed shared and per-ticket branches"
25
+ : "one branch per ticket";
26
+ return {
27
+ allocationMode,
28
+ description,
29
+ banner: `foreman: ${options.resumed ? "resuming " : ""}${description} for ${plan.nodes.length} ticket(s)`,
30
+ prompt: `Proceed with ${description}?`,
31
+ };
32
+ }
33
+ export function branchNodeBehavior(node) {
34
+ return node.deliveryUnitId ? "shared delivery branch" : "per-ticket branch";
35
+ }
36
+ export function branchNodeFooter(node) {
37
+ return `Generated by Rafi ai-foreman for a ${branchNodeBehavior(node)}.`;
38
+ }
@@ -16,6 +16,8 @@ export interface BranchResumeSession {
16
16
  reviewProvider?: string;
17
17
  prReady?: boolean;
18
18
  keepWorktrees?: boolean;
19
+ deliveryUnitId?: string;
20
+ deliveryUnitFinal?: boolean;
19
21
  ts?: string;
20
22
  }
21
23
  export declare function findResumableBranchSessions(foremanDir: string): BranchResumeSession[];
@@ -43,6 +43,8 @@ export function findResumableBranchSessions(foremanDir) {
43
43
  reviewProvider: stringField(record.reviewProvider),
44
44
  prReady: booleanField(record.prReady),
45
45
  keepWorktrees: booleanField(record.keepWorktrees),
46
+ deliveryUnitId: stringField(record.deliveryUnitId),
47
+ deliveryUnitFinal: booleanField(record.deliveryUnitFinal),
46
48
  ts: stringField(record.ts),
47
49
  });
48
50
  }
@@ -1,5 +1,5 @@
1
1
  import { buildDurableQaFixHandoff, compactWithRetry, runIsolatedQa } from "../qaReview.js";
2
- import { changeManifest } from "../qaSnapshot.js";
2
+ import { changeManifestAsync } from "../qaSnapshot.js";
3
3
  import { Foreman, MARKER_SPEC, parseStepStatus } from "../foreman.js";
4
4
  import { fireNotification } from "../notify.js";
5
5
  import { cmdBlock, cmdComplete, cmdUpdate } from "../tickets/commands.js";
@@ -9,6 +9,7 @@ import { checkGitLabMrMerged, createOrReuseMr, enableGitLabAutoMerge, pushBranch
9
9
  import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
10
10
  import { join } from "node:path";
11
11
  import { WorkflowDb } from "../workflowDb.js";
12
+ import { currentActivity, withActivityPhase } from "../activity.js";
12
13
  export function readDeliveryUnitSession(projectDir, unitId) {
13
14
  const path = deliverySessionPath(projectDir, unitId);
14
15
  if (!existsSync(path))
@@ -176,6 +177,8 @@ export async function runBranchPlan(opts) {
176
177
  reviewProvider,
177
178
  prReady: opts.prReady,
178
179
  keepWorktrees: opts.keepWorktrees,
180
+ deliveryUnitId: node.deliveryUnitId,
181
+ deliveryUnitFinal: node.deliveryUnitFinal,
179
182
  });
180
183
  if (node.deliveryUnitId) {
181
184
  const path = deliverySessionPath(opts.projectDir, node.deliveryUnitId);
@@ -228,7 +231,7 @@ export async function runBranchPlan(opts) {
228
231
  await builder.close();
229
232
  builder = await opts.createBuilder(worktreePath);
230
233
  }
231
- const digest = changeManifest(worktreePath).diffDigest;
234
+ const digest = (await withActivityPhase("recording QA fix changes", () => changeManifestAsync(worktreePath, (state, detail) => currentActivity()?.update(state, detail), "recording QA fix changes"))).diffDigest;
232
235
  const fixed = await builder.sendTurn(buildDurableQaFixHandoff(node.ticket, issues, worktreePath, result.text, digest));
233
236
  const fixedStatus = parseStepStatus(fixed.text);
234
237
  builderStreamSession = opts.builderSessionStrategy === "compact" ? builder.sessionId() : undefined;
@@ -287,9 +290,12 @@ export async function runBranchPlan(opts) {
287
290
  workflowCheckpoint(opts.projectDir, opts.runId, "push-before", node.ticket.id, { branch: node.branch, sha: commit });
288
291
  const pushOperation = `${opts.runId}:push:${node.branch}`;
289
292
  planJournal(opts.projectDir, opts.runId, pushOperation, "push", { branch: node.branch, sha: commit, remote: "origin" });
290
- const push = reviewProvider === "gitlab"
291
- ? pushBranchForMr(worktreePath, node.branch)
292
- : pushBranchForPr(worktreePath, node.branch);
293
+ const push = await withActivityPhase(`pushing ${node.branch}`, async () => {
294
+ currentActivity()?.update(`pushing ${node.branch}`, `publishing ${node.ticket.id}`);
295
+ return reviewProvider === "gitlab"
296
+ ? pushBranchForMr(worktreePath, node.branch)
297
+ : pushBranchForPr(worktreePath, node.branch);
298
+ });
293
299
  if (push.ok) {
294
300
  confirmJournal(opts.projectDir, pushOperation, node.branch, { sha: commit });
295
301
  opts.log.write("branch-push", { ticket: node.ticket.id, branch: node.branch, status: "pushed" });
@@ -326,24 +332,27 @@ export async function runBranchPlan(opts) {
326
332
  workflowCheckpoint(opts.projectDir, opts.runId, "review-creation-before", node.ticket.id, { provider: reviewProvider, head: node.branch, base: node.baseBranch });
327
333
  const reviewOperation = `${opts.runId}:${reviewProvider === "gitlab" ? "mr" : "pr"}:${node.branch}:${node.baseBranch}`;
328
334
  planJournal(opts.projectDir, opts.runId, reviewOperation, reviewProvider === "gitlab" ? "mr-create" : "pr-create", { head: node.branch, base: node.baseBranch, sha: commit });
329
- const pr = reviewProvider === "gitlab"
330
- ? createOrReuseMr(opts.projectDir, {
331
- node,
332
- ready: opts.prReady || completionMode === "auto-merge",
333
- runId: opts.runId,
334
- qaEvidence: qaSummary,
335
- commit,
336
- autoMerge: false,
337
- cleanup: opts.cleanupBranches ?? true,
338
- mergeMethod: opts.mergeMethod ?? "squash",
339
- })
340
- : createOrReusePr(opts.projectDir, {
341
- node,
342
- ready: opts.prReady || completionMode === "auto-merge",
343
- runId: opts.runId,
344
- qaEvidence: qaSummary,
345
- commit,
346
- });
335
+ const pr = await withActivityPhase(`creating ${reviewProvider === "gitlab" ? "merge request" : "pull request"}`, async () => {
336
+ currentActivity()?.update(`creating ${reviewProvider === "gitlab" ? "merge request" : "pull request"}`, node.ticket.id);
337
+ return reviewProvider === "gitlab"
338
+ ? createOrReuseMr(opts.projectDir, {
339
+ node,
340
+ ready: opts.prReady || completionMode === "auto-merge",
341
+ runId: opts.runId,
342
+ qaEvidence: qaSummary,
343
+ commit,
344
+ autoMerge: false,
345
+ cleanup: opts.cleanupBranches ?? true,
346
+ mergeMethod: opts.mergeMethod ?? "squash",
347
+ })
348
+ : createOrReusePr(opts.projectDir, {
349
+ node,
350
+ ready: opts.prReady || completionMode === "auto-merge",
351
+ runId: opts.runId,
352
+ qaEvidence: qaSummary,
353
+ commit,
354
+ });
355
+ });
347
356
  summary.pr = pr;
348
357
  if (pr.status === "created" || pr.status === "existing") {
349
358
  confirmJournal(opts.projectDir, reviewOperation, pr.url, { head: node.branch, base: node.baseBranch, url: pr.url });
@@ -362,7 +371,10 @@ export async function runBranchPlan(opts) {
362
371
  continue;
363
372
  }
364
373
  if (completionMode === "auto-merge" && reviewProvider === "github") {
365
- const autoMerge = enableGitHubAutoMerge(opts.projectDir, node.branch, opts.cleanupBranches ?? true, opts.mergeMethod ?? "squash");
374
+ const autoMerge = await withActivityPhase("enabling GitHub auto-merge", async () => {
375
+ currentActivity()?.update("enabling GitHub auto-merge", node.branch);
376
+ return enableGitHubAutoMerge(opts.projectDir, node.branch, opts.cleanupBranches ?? true, opts.mergeMethod ?? "squash");
377
+ });
366
378
  summary.pr = autoMerge.status === "failed" ? autoMerge : { ...pr, status: "auto_merge_enabled", url: autoMerge.url ?? pr.url };
367
379
  if (autoMerge.status === "failed") {
368
380
  opts.log.write("pr-auto-merge-failed", failureLogFields(node, autoMerge));
@@ -373,7 +385,10 @@ export async function runBranchPlan(opts) {
373
385
  opts.log.write("pr-auto-merge-enabled", { ticket: node.ticket.id, branch: node.branch, url: summary.pr.url });
374
386
  }
375
387
  else if (completionMode === "auto-merge" && reviewProvider === "gitlab") {
376
- const autoMerge = enableGitLabAutoMerge(opts.projectDir, node.branch, opts.cleanupBranches ?? true, opts.mergeMethod ?? "squash");
388
+ const autoMerge = await withActivityPhase("enabling GitLab auto-merge", async () => {
389
+ currentActivity()?.update("enabling GitLab auto-merge", node.branch);
390
+ return enableGitLabAutoMerge(opts.projectDir, node.branch, opts.cleanupBranches ?? true, opts.mergeMethod ?? "squash");
391
+ });
377
392
  summary.pr = autoMerge.status === "failed" ? autoMerge : { ...pr, status: "auto_merge_enabled", url: autoMerge.url ?? pr.url };
378
393
  if (autoMerge.status === "failed") {
379
394
  opts.log.write("mr-auto-merge-failed", failureLogFields(node, autoMerge));
@@ -501,6 +516,9 @@ function workflowCheckpoint(projectDir, runId, checkpoint, ticket, payload) {
501
516
  }
502
517
  }
503
518
  async function waitForAutoMergeDependencies(opts, node, provider) {
519
+ return withActivityPhase("checking dependency merges", () => waitForAutoMergeDependenciesInternal(opts, node, provider));
520
+ }
521
+ async function waitForAutoMergeDependenciesInternal(opts, node, provider) {
504
522
  const dependencyNodes = node.dependencies
505
523
  .map((dep) => opts.plan.nodes.find((candidate) => candidate.ticket.id === dep))
506
524
  .filter((dep) => Boolean(dep));
@@ -532,6 +550,7 @@ async function waitForAutoMergeDependencies(opts, node, provider) {
532
550
  if (pending.length === 0)
533
551
  return { ok: true };
534
552
  const message = `auto-merge dependency ${pending.join(", ")} has not merged into the root base yet`;
553
+ currentActivity()?.update("waiting for dependency merge", pending.join(", "));
535
554
  if (!opts.autoMergeWait) {
536
555
  return {
537
556
  ok: false,
@@ -1,11 +1,19 @@
1
+ import { currentActivity } from "../activity.js";
1
2
  /** Print a compact live feed of builder activity. */
2
3
  export async function printEvents(events) {
3
4
  let atLineStart = true;
4
5
  for await (const ev of events) {
5
6
  if (ev.kind === "text") {
6
7
  if (ev.text) {
7
- process.stdout.write(ev.text);
8
- atLineStart = ev.text.endsWith("\n");
8
+ const reporter = currentActivity();
9
+ if (reporter) {
10
+ reporter.writePersistent(ev.text);
11
+ atLineStart = true;
12
+ }
13
+ else {
14
+ process.stdout.write(ev.text);
15
+ atLineStart = ev.text.endsWith("\n");
16
+ }
9
17
  }
10
18
  }
11
19
  else if (ev.kind === "tool") {
@@ -13,7 +21,7 @@ export async function printEvents(events) {
13
21
  process.stdout.write("\n");
14
22
  atLineStart = true;
15
23
  }
16
- console.log(` -> ${ev.name} ${briefInput(ev.input)}`);
24
+ writeLine(` -> ${ev.name} ${briefInput(ev.input)}`);
17
25
  }
18
26
  else if (ev.kind === "turn-complete") {
19
27
  if (!atLineStart) {
@@ -22,19 +30,30 @@ export async function printEvents(events) {
22
30
  }
23
31
  const tag = ev.result.isError ? "turn errored" : "turn complete";
24
32
  const cost = ev.result.costUsd > 0 ? ` ($${ev.result.costUsd.toFixed(4)})` : "";
25
- console.log(` - ${tag}${cost}`);
33
+ writeLine(` - ${tag}${cost}`);
26
34
  }
27
35
  else if (ev.kind === "error") {
28
36
  if (!atLineStart) {
29
37
  process.stdout.write("\n");
30
38
  atLineStart = true;
31
39
  }
32
- console.log(` ! error: ${ev.message}`);
40
+ writeLine(` ! error: ${ev.message}`);
41
+ }
42
+ else if (ev.kind === "retry" && !currentActivity()) {
43
+ const attempt = ev.attempt ? ` (${ev.attempt}${ev.maximum ? `/${ev.maximum}` : ""})` : "";
44
+ writeLine(` ! ${ev.provider}: ${ev.reason}; retrying${attempt}`);
33
45
  }
34
46
  }
35
47
  if (!atLineStart)
36
48
  process.stdout.write("\n");
37
49
  }
50
+ function writeLine(line) {
51
+ const reporter = currentActivity();
52
+ if (reporter)
53
+ reporter.writePersistent(line);
54
+ else
55
+ console.log(line);
56
+ }
38
57
  function briefInput(input) {
39
58
  if (input && typeof input === "object") {
40
59
  const o = input;
@@ -3,6 +3,7 @@ import { requireClaudeSDK } from "../adapters/claude.js";
3
3
  import { checkRuntimeReady, RuntimeAuthError, runtimeCommandLabel, } from "../runtimeAuth.js";
4
4
  import { resolveExecutablePath } from "../runtimeReadiness.js";
5
5
  import { otherRuntime, runtimeDisplayName } from "./runtimeSelection.js";
6
+ import { currentActivity } from "../activity.js";
6
7
  export async function ensureRuntimeReadyForCommand(projectDir, runtime, labelOrOptions) {
7
8
  const opts = typeof labelOrOptions === "string"
8
9
  ? { label: labelOrOptions }
@@ -37,9 +38,12 @@ export async function ensureRuntimeReadyForCommand(projectDir, runtime, labelOrO
37
38
  const choice = opts.choose
38
39
  ? await opts.choose(failure, { otherRuntime: fallbackRuntime, allowSwitch })
39
40
  : await promptRuntimeRecovery(failure, opts.label, fallbackRuntime, allowSwitch);
40
- if (choice === "retry")
41
+ if (choice === "retry") {
42
+ currentActivity()?.note(`rafi: retrying ${runtime} readiness check`);
41
43
  continue;
44
+ }
42
45
  if (choice === "switch" && allowSwitch) {
46
+ currentActivity()?.note(`rafi: checking fallback runtime ${fallbackRuntime}`);
43
47
  try {
44
48
  const probe = await check(projectDir, fallbackRuntime);
45
49
  if (fallbackRuntime === "claude") {
package/dist/cli/start.js CHANGED
@@ -22,12 +22,14 @@ import { collectBaseWorktreeDirtyPaths, currentGitRef, ensureCleanBaseWorktree,
22
22
  import { formatGitHubFailure, preflightGh } from "../branch/github.js";
23
23
  import { preflightGlab } from "../branch/gitlab.js";
24
24
  import { readDeliveryUnitSession, runBranchPlan } from "../branch/runner.js";
25
+ import { branchPlanLogMetadata, presentBranchPlan } from "../branch/presentation.js";
25
26
  import { checkpointBuildRun, completeBuildRun, createBuildRun, heartbeatBuildRun, persistBuildSession, readBuildRuns, releaseBuildLease, resumeBuildRun } from "../buildRuns.js";
26
27
  import { parse as parseYaml } from "yaml";
27
28
  import { findResumableBranchSessions, formatBranchContinueCommand, formatBranchSummaryFollowupCommands, } from "../branch/resume.js";
28
29
  import { detectGitProvider, loadTicketSetupConfig } from "../tickets/setupConfig.js";
29
30
  import { loadDeliveryConfig, selectDeliveryUnitForRun, selectStacksForRun, updateStackDeliveryState } from "../tickets/delivery.js";
30
31
  import { AgentStatusReporter } from "../statusReporter.js";
32
+ import { currentActivity, withActivityPhase } from "../activity.js";
31
33
  import { WorkflowDb } from "../workflowDb.js";
32
34
  import { makeLogPath as makeRoleLogPath, readOnlyPermissionConfig, runRoleInstruction } from "../agentRun.js";
33
35
  function fail(message) {
@@ -185,7 +187,10 @@ function branchContinueTicketHelp(cwd, flag = "--continue") {
185
187
  }
186
188
  async function ensureGitHubReadyForCreatePr(cwd, log, yes) {
187
189
  while (true) {
188
- const result = preflightGh(cwd);
190
+ const result = await withActivityPhase("checking GitHub readiness", async () => {
191
+ currentActivity()?.update("checking GitHub readiness", "remote, authentication, and repository access");
192
+ return preflightGh(cwd);
193
+ });
189
194
  if (result.ok)
190
195
  return;
191
196
  log.write("github-readiness-failed", {
@@ -211,6 +216,7 @@ async function ensureGitHubReadyForCreatePr(cwd, log, yes) {
211
216
  console.log("ai-foreman: cancelled");
212
217
  process.exit(0);
213
218
  }
219
+ currentActivity()?.note("rafi: retrying GitHub readiness check");
214
220
  }
215
221
  }
216
222
  async function ensureReviewProviderReady(cwd, provider, log, yes) {
@@ -219,7 +225,10 @@ async function ensureReviewProviderReady(cwd, provider, log, yes) {
219
225
  return;
220
226
  }
221
227
  while (true) {
222
- const result = preflightGlab(cwd);
228
+ const result = await withActivityPhase("checking GitLab readiness", async () => {
229
+ currentActivity()?.update("checking GitLab readiness", "remote, authentication, and repository access");
230
+ return preflightGlab(cwd);
231
+ });
223
232
  if (result.ok)
224
233
  return;
225
234
  log.write("gitlab-readiness-failed", {
@@ -245,6 +254,7 @@ async function ensureReviewProviderReady(cwd, provider, log, yes) {
245
254
  console.log("ai-foreman: cancelled");
246
255
  process.exit(0);
247
256
  }
257
+ currentActivity()?.note("rafi: retrying GitLab readiness check");
248
258
  }
249
259
  }
250
260
  export function buildStartCommand() {
@@ -415,7 +425,7 @@ export function buildStartCommand() {
415
425
  fail("--max-branch-depth must be a positive integer");
416
426
  }
417
427
  if (!branchMode && continueTickets.length > 0) {
418
- fail("--ticket is only supported with --branch-per-ticket --continue or --branch-per-ticket --resume");
428
+ fail("--ticket is only supported when continuing or resuming an isolated branch run");
419
429
  }
420
430
  if (branchMode) {
421
431
  if (opts.resume && continueTickets.length > 1) {
@@ -428,9 +438,9 @@ export function buildStartCommand() {
428
438
  fail("--ticket in branch mode requires --continue or --resume <sessionId>");
429
439
  }
430
440
  if (opts.tickets)
431
- fail("--tickets is not supported with --branch-per-ticket; initialize and use .tickets/tickets.yaml");
441
+ fail("--tickets is not supported with an isolated branch run; initialize and use .tickets/tickets.yaml");
432
442
  if (!isTicketsInitialized(cwd))
433
- fail("--branch-per-ticket requires initialized .tickets/ (run ai-foreman tickets init)");
443
+ fail("isolated branch runs require initialized .tickets/ (run ai-foreman tickets init)");
434
444
  if (branchDefaults.createReview && !branchDefaults.reviewProvider) {
435
445
  fail("PR/MR completion is enabled but no GitHub or GitLab origin remote was detected; pass --provider github|gitlab or --completion none");
436
446
  }
@@ -563,6 +573,7 @@ export function buildStartCommand() {
563
573
  db.close();
564
574
  const ticketById = new Map(tickets.map((ticket) => [ticket.id, ticket]));
565
575
  const resumeSessionByTicket = new Map();
576
+ let auditDependencyCount;
566
577
  let plan;
567
578
  if (continueTickets.length > 0) {
568
579
  const sessions = findResumableBranchSessions(join(cwd, ".foreman"));
@@ -589,6 +600,8 @@ export function buildStartCommand() {
589
600
  dependencies: [],
590
601
  depth: 1,
591
602
  worktreePath: session.worktreePath,
603
+ deliveryUnitId: session.deliveryUnitId,
604
+ deliveryUnitFinal: session.deliveryUnitFinal,
592
605
  });
593
606
  }
594
607
  plan = {
@@ -596,13 +609,6 @@ export function buildStartCommand() {
596
609
  nodes,
597
610
  issues: [],
598
611
  };
599
- log.write("branch-plan", {
600
- baseRef: plan.baseRef,
601
- tickets: plan.nodes.map((node) => node.ticket.id),
602
- branches: plan.nodes.map((node) => ({ ticket: node.ticket.id, branch: node.branch, base: node.baseBranch })),
603
- issues: plan.issues,
604
- resume: true,
605
- });
606
612
  }
607
613
  else {
608
614
  plan = buildBranchPlan(tickets, states, {
@@ -631,13 +637,7 @@ export function buildStartCommand() {
631
637
  rootBaseBranches: branchDefaults.rootBaseBranches,
632
638
  ticketIds: selectedStackTickets ?? deliveryRun?.remaining.slice(0, steps) ?? recoveryRecord?.tickets,
633
639
  });
634
- log.write("branch-plan", {
635
- baseRef: plan.baseRef,
636
- tickets: plan.nodes.map((node) => node.ticket.id),
637
- branches: plan.nodes.map((node) => ({ ticket: node.ticket.id, branch: node.branch, base: node.baseBranch })),
638
- issues: plan.issues,
639
- auditDependencyCount: auditDependencies.length,
640
- });
640
+ auditDependencyCount = auditDependencies.length;
641
641
  }
642
642
  finally {
643
643
  await auditBuilder?.close().catch(() => { });
@@ -660,7 +660,16 @@ export function buildStartCommand() {
660
660
  resumeSessionByTicket.set(plan.nodes[0].ticket.id, { worktreePath: prior.worktreePath, sessionId: opts.resume });
661
661
  }
662
662
  }
663
- console.log(`foreman: ${continueTickets.length > 0 ? "resuming branch-per-ticket mode" : "branch-per-ticket mode"} for ${plan.nodes.length} ticket(s)`);
663
+ const branchPresentation = presentBranchPlan(plan, {
664
+ stacked: selectedStacks.length > 0,
665
+ resumed: continueTickets.length > 0 || Boolean(recoveryRecord),
666
+ });
667
+ log.write("branch-plan", {
668
+ ...branchPlanLogMetadata(plan, branchPresentation),
669
+ ...(auditDependencyCount === undefined ? {} : { auditDependencyCount }),
670
+ ...(continueTickets.length > 0 || recoveryRecord ? { resume: true } : {}),
671
+ });
672
+ console.log(branchPresentation.banner);
664
673
  console.log(`foreman: project ${cwd}`);
665
674
  console.log(`foreman: base ${plan.baseRef}`);
666
675
  console.log(`foreman: log ${logPath}\n`);
@@ -680,7 +689,7 @@ export function buildStartCommand() {
680
689
  console.log();
681
690
  if (!opts.yes && plan.issues.every((issue) => !issue.blocking)) {
682
691
  const action = await select({
683
- message: "Proceed with branch-per-ticket run?",
692
+ message: branchPresentation.prompt,
684
693
  options: [
685
694
  { value: "proceed", label: "Proceed" },
686
695
  { value: "cancel", label: "Cancel" },
@@ -702,7 +711,7 @@ export function buildStartCommand() {
702
711
  };
703
712
  let masterRun = recoveryRecord ? resumeBuildRun(cwd, recoveryRecord.runId, { builder: capturedBranchBuilder, qa: capturedBranchQa, builderSessionId: opts.resume ? String(opts.resume) : null }) : createBuildRun({
704
713
  tickets: plan.nodes.map((node) => node.ticket.id), deliveryUnit: selectedStacks.length ? selectedStacks.map((stack) => stack.id).join(",") : deliveryRun?.unit.id,
705
- repositoryRoot: cwd, branchMode: "per-ticket", builder: capturedBranchBuilder, qa: capturedBranchQa,
714
+ repositoryRoot: cwd, branchMode: branchPresentation.allocationMode, builder: capturedBranchBuilder, qa: capturedBranchQa,
706
715
  });
707
716
  const branchHeartbeat = setInterval(() => { masterRun = heartbeatBuildRun(cwd, masterRun); }, 10_000);
708
717
  branchHeartbeat.unref();
@@ -827,7 +836,11 @@ export function buildStartCommand() {
827
836
  reasoning: capturedBuilder.reasoning, fast: capturedBuilder.fast, step: 1, total: steps,
828
837
  phase: "builder work session", sessionTransition: resumeSessionId ? "resumed exact session" : "initial session", adapter: () => foreman.builderAdapter(),
829
838
  }, (line, snapshot) => {
830
- console.log(line);
839
+ const activity = currentActivity();
840
+ if (activity)
841
+ activity.writePersistent(line);
842
+ else
843
+ console.log(line);
831
844
  log.write("agent-status", { ...snapshot });
832
845
  const db = new WorkflowDb(cwd);
833
846
  try {
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";
@@ -36,7 +37,11 @@ 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
38
  export declare function buildPlanningTurn(n: number, ticketsContent?: string): string;
38
39
  /** Builds the permission callback: classify, log, allow or escalate. */
39
- export declare function createPermissionHandler(policy: PermissionPolicy, log: Log): PermissionHandler;
40
+ export declare function createPermissionHandler(policy: PermissionPolicy, log: Log, opts?: {
41
+ interactive?: boolean;
42
+ onAnsweredQuestion?: (event: AnsweredProviderQuestion) => void;
43
+ onProviderQuestion?: (request: PermissionRequest) => void;
44
+ }): PermissionHandler;
40
45
  export interface BatchResult {
41
46
  completed: number;
42
47
  requested: number;