ai-foreman 1.7.4 → 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.
@@ -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";
@@ -38,6 +39,8 @@ export declare function buildPlanningTurn(n: number, ticketsContent?: string): s
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;
package/dist/foreman.js CHANGED
@@ -169,8 +169,11 @@ export function buildPlanningTurn(n, ticketsContent) {
169
169
  /** Builds the permission callback: classify, log, allow or escalate. */
170
170
  export function createPermissionHandler(policy, log, opts = {}) {
171
171
  return async (req) => {
172
+ if (req.toolName === "AskUserQuestion")
173
+ opts.onProviderQuestion?.(req);
172
174
  const providerQuestion = await handleProviderQuestionTool(req, {
173
175
  interactive: opts.interactive ?? true,
176
+ onAnsweredQuestion: opts.onAnsweredQuestion,
174
177
  });
175
178
  if (providerQuestion) {
176
179
  log.write("permission", {
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;