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/buildRuns.js CHANGED
@@ -2,16 +2,22 @@ import { createHash, randomUUID } from "node:crypto";
2
2
  import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, writeFileSync } from "node:fs";
3
3
  import { hostname } from "node:os";
4
4
  import { join, resolve } from "node:path";
5
+ import { execFileSync } from "node:child_process";
6
+ import Database from "better-sqlite3";
5
7
  import { WorkflowDb } from "./workflowDb.js";
6
- export const BUILD_RUN_VERSION = 1;
8
+ import { isTicketsInitialized, loadTicketsConfig, resolveTicketPaths } from "./tickets/config.js";
9
+ import { loadTickets } from "./tickets/ticketLoader.js";
10
+ export const BUILD_RUN_VERSION = 2;
7
11
  export const BUILD_RUN_DIRECTORY = ".foreman/runs";
8
12
  export const BUILD_HEARTBEAT_MS = 10_000;
9
13
  export const BUILD_LEASE_STALE_MS = 45_000;
10
14
  export function createBuildRun(input) {
11
15
  const now = input.now ?? new Date();
12
16
  const stamp = now.toISOString();
17
+ const worktree = resolve(input.worktree ?? input.repositoryRoot);
18
+ const snapshot = captureGitSnapshot(worktree, input);
13
19
  const run = {
14
- version: 1,
20
+ version: 2,
15
21
  runId: randomUUID(),
16
22
  status: "running",
17
23
  tickets: [...input.tickets],
@@ -23,12 +29,14 @@ export function createBuildRun(input) {
23
29
  qa: input.qa ? { settings: normalizeCapturedSettings(input.qa) } : undefined,
24
30
  repository: {
25
31
  root: resolve(input.repositoryRoot),
26
- worktree: resolve(input.worktree ?? input.repositoryRoot),
27
- branch: input.branch,
28
- baseHead: input.baseHead,
29
- startHead: input.startHead,
30
- partialFingerprint: fingerprintWorkingFiles(input.worktree ?? input.repositoryRoot),
32
+ worktree,
33
+ branch: input.branch ?? snapshot.branch,
34
+ baseHead: input.baseHead ?? snapshot.baselineHead,
35
+ startHead: input.startHead ?? snapshot.startHead,
36
+ git: snapshot,
37
+ baselineComplete: Boolean(snapshot.baselineHead && snapshot.startHead && snapshot.branch),
31
38
  },
39
+ progress: { completedTickets: [], completedOperations: [], remainingTickets: [...input.tickets], nextAction: input.tickets[0] ? `Start ${input.tickets[0]}` : "Plan next work" },
32
40
  receipts: {},
33
41
  lease: currentLease(now),
34
42
  createdAt: stamp,
@@ -71,7 +79,12 @@ export function saveBuildRun(projectDir, run, now = new Date()) {
71
79
  validateBuildRun(run);
72
80
  const directory = join(resolve(projectDir), BUILD_RUN_DIRECTORY);
73
81
  mkdirSync(directory, { recursive: true });
74
- const next = { ...run, updatedAt: now.toISOString() };
82
+ const completedOperations = Object.keys(run.receipts).sort();
83
+ const next = {
84
+ ...run,
85
+ progress: { ...run.progress, completedOperations, remainingTickets: remainingTickets(run) },
86
+ updatedAt: now.toISOString(),
87
+ };
75
88
  const target = join(directory, `${run.runId}.json`);
76
89
  const temp = `${target}.${process.pid}.${randomUUID()}.tmp`;
77
90
  writeFileSync(temp, `${JSON.stringify(next, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
@@ -145,7 +158,7 @@ export function releaseBuildLease(projectDir, run, status = "interrupted") {
145
158
  return saved;
146
159
  }
147
160
  export function completeBuildRun(projectDir, run, now = new Date()) {
148
- const saved = saveBuildRun(projectDir, { ...run, status: "completed", checkpoint: "complete", lease: undefined, completedAt: now.toISOString() }, now);
161
+ const saved = saveBuildRun(projectDir, { ...run, status: "completed", checkpoint: "complete", lease: undefined, completedAt: now.toISOString(), progress: { ...run.progress, completedTickets: [...run.tickets], remainingTickets: [], nextAction: "None; build complete" } }, now);
149
162
  releaseWorkflowLease(projectDir, run.runId, now);
150
163
  return saved;
151
164
  }
@@ -157,7 +170,7 @@ export function readBuildRuns(projectDir) {
157
170
  try {
158
171
  const run = JSON.parse(readFileSync(join(directory, name), "utf8"));
159
172
  validateBuildRun(run);
160
- return [run];
173
+ return [upgradeBuildRun(run)];
161
174
  }
162
175
  catch {
163
176
  return [];
@@ -167,7 +180,42 @@ export function readBuildRuns(projectDir) {
167
180
  export function recoverableBuildRuns(projectDir, now = new Date()) {
168
181
  return readBuildRuns(projectDir)
169
182
  .filter((run) => run.status !== "completed")
170
- .map((run) => ({ ...run, active: isLeaseActive(run, now) }));
183
+ .map((run) => ({ ...inferLegacyRunTickets(projectDir, run), active: isLeaseActive(run, now) }));
184
+ }
185
+ /**
186
+ * Current-branch runs created before ticket checkpointing did not retain their
187
+ * selected ticket. Recover it from Foreman's tracker events so --ticket can
188
+ * still address those durable runs without rewriting the legacy record.
189
+ */
190
+ function inferLegacyRunTickets(projectDir, run) {
191
+ if (run.tickets.length > 0 || !isTicketsInitialized(projectDir))
192
+ return run;
193
+ let db;
194
+ try {
195
+ const paths = resolveTicketPaths(loadTicketsConfig(projectDir), projectDir);
196
+ if (!existsSync(paths.stateDb))
197
+ return run;
198
+ db = new Database(paths.stateDb, { readonly: true, fileMustExist: true });
199
+ const rows = db.prepare(`
200
+ SELECT ticket_id
201
+ FROM ticket_events
202
+ WHERE actor = 'foreman'
203
+ AND ticket_id IS NOT NULL
204
+ AND julianday(timestamp) >= julianday(?)
205
+ AND julianday(timestamp) <= julianday(?)
206
+ ORDER BY timestamp, id
207
+ `).all(run.createdAt, run.updatedAt);
208
+ const tickets = [...new Set(rows.map((row) => row.ticket_id).filter(Boolean))];
209
+ if (tickets.length === 0)
210
+ return run;
211
+ return { ...run, tickets, currentTicket: tickets.at(-1) };
212
+ }
213
+ catch {
214
+ return run;
215
+ }
216
+ finally {
217
+ db?.close();
218
+ }
171
219
  }
172
220
  export function isLeaseActive(run, now = new Date()) {
173
221
  if (!run.lease)
@@ -193,12 +241,106 @@ export function buildRecoveryPreview(run) {
193
241
  `checkpoint ${run.checkpoint}`,
194
242
  `worktree ${run.repository.worktree}`,
195
243
  `branch ${run.repository.branch ?? "current"}`,
196
- `partial files fingerprint ${run.repository.partialFingerprint ?? "unavailable"}`,
244
+ `baseline ${run.repository.baselineComplete ? `${run.repository.git.baseRef ?? "recorded"} @ ${run.repository.git.baselineHead}` : "incomplete legacy baseline"}`,
245
+ `preserved paths ${run.repository.git.runOwnedPaths.join(", ") || run.repository.git.statusPaths.join(", ") || "none recorded"}`,
197
246
  `completed operations ${completed.length ? completed.join(", ") : "none"}`,
198
247
  `session ${run.builder?.sessionId ? "exact Builder session available" : "fresh Builder session required"}`,
199
248
  `QA session ${run.qa?.sessionId ? "exact QA session available" : "fresh QA session required"}`,
200
249
  ];
201
250
  }
251
+ export function projectBuildRecovery(projectDir, run, now = new Date(), ticketOverride) {
252
+ let operationNames = [];
253
+ let issues = [];
254
+ let workflowState = {};
255
+ if (existsSync(join(resolve(projectDir), ".rafi", "recovery.sqlite3"))) {
256
+ const workflow = new WorkflowDb(projectDir);
257
+ try {
258
+ operationNames = workflow.operations(run.runId).filter((operation) => operation.status === "confirmed").map((operation) => operation.kind);
259
+ issues = workflow.issues(run.runId).map((issue) => issue.detail);
260
+ workflowState = workflow.getRun(run.runId)?.state ?? {};
261
+ }
262
+ finally {
263
+ workflow.close();
264
+ }
265
+ }
266
+ const stateTicket = typeof workflowState.currentTicket === "string" ? workflowState.currentTicket : undefined;
267
+ const ticketId = ticketOverride ?? stateTicket ?? run.currentTicket ?? run.progress.remainingTickets[0] ?? run.tickets[0];
268
+ let ticketTitle;
269
+ if (ticketId && isTicketsInitialized(projectDir)) {
270
+ try {
271
+ const paths = resolveTicketPaths(loadTicketsConfig(projectDir), projectDir);
272
+ ticketTitle = loadTickets(paths.tickets).find((ticket) => ticket.id === ticketId)?.title;
273
+ }
274
+ catch { /* durable run data remains enough for a legacy preview */ }
275
+ }
276
+ const stateWorktree = typeof workflowState.worktree === "string" ? workflowState.worktree : undefined;
277
+ const recoveryWorktree = stateWorktree && existsSync(stateWorktree) ? stateWorktree : run.repository.worktree;
278
+ const stateBranch = typeof workflowState.branch === "string" ? workflowState.branch : undefined;
279
+ const expectedChanges = gitStatusPaths(recoveryWorktree);
280
+ const baseChanges = resolve(run.repository.root) === resolve(recoveryWorktree) ? [] : gitStatusPaths(run.repository.root);
281
+ const identityChanged = recoveryWorktree === run.repository.worktree && run.repository.git.worktreeIdentity && worktreeIdentity(recoveryWorktree) !== run.repository.git.worktreeIdentity;
282
+ const branchNow = gitValue(recoveryWorktree, ["branch", "--show-current"]);
283
+ const expectedBranch = stateBranch ?? run.repository.git.branch;
284
+ const branchChanged = Boolean(expectedBranch && branchNow && branchNow !== expectedBranch);
285
+ const unexpectedChanges = baseChanges.map((path) => ({ path, risk: "base-worktree changes may conflict with recovery" }));
286
+ if (identityChanged)
287
+ unexpectedChanges.push({ path: recoveryWorktree, risk: "preserved worktree identity changed" });
288
+ if (branchChanged)
289
+ unexpectedChanges.push({ path: recoveryWorktree, risk: `branch changed from ${expectedBranch} to ${branchNow}` });
290
+ const completed = [...new Set([...run.progress.completedTickets, ...run.progress.completedOperations, ...operationNames])];
291
+ const remaining = run.progress.remainingTickets.length ? run.progress.remainingTickets : remainingTickets(run);
292
+ const interruption = run.interruption?.lastError ?? run.interruption?.summary ?? run.failure?.summary ?? issues.at(-1) ?? run.status;
293
+ const validation = run.progress.validation
294
+ ? `${run.progress.validation.status}${run.progress.validation.qa ? `; QA ${run.progress.validation.qa}` : ""}`
295
+ : run.qa?.sessionId ? "QA session checkpointed" : "no completed QA/validation checkpoint recorded";
296
+ const nextAction = ticketOverride
297
+ ? `Resume ${ticketOverride} from ${run.checkpoint}`
298
+ : run.progress.nextAction ?? (ticketId ? `Resume ${ticketId} from ${run.checkpoint}` : `Resume from ${run.checkpoint}`);
299
+ const primary = ticketId ? `${ticketId}${ticketTitle ? `: ${ticketTitle}` : ""}` : "legacy run (ticket unavailable)";
300
+ return {
301
+ run, ticketId, ticketTitle,
302
+ compactLabel: `${primary} — ${run.status}`,
303
+ compactHint: `${run.builder?.settings.make ?? "runtime unavailable"}; ${stateBranch ?? branchNow ?? run.repository.branch ?? "current branch"}; updated ${relativeTime(run.updatedAt, now)}`,
304
+ completed, remaining,
305
+ lastSuccess: run.progress.lastSuccessfulAction ?? run.checkpoint,
306
+ interruption,
307
+ validation,
308
+ expectedChanges,
309
+ unexpectedChanges,
310
+ nextAction,
311
+ worktree: recoveryWorktree,
312
+ branch: stateBranch ?? branchNow ?? run.repository.git.branch ?? run.repository.branch,
313
+ exactSessionId: typeof workflowState.sessionId === "string" ? workflowState.sessionId : run.builder?.sessionId,
314
+ };
315
+ }
316
+ export function formatBuildRecoveryProjection(projection) {
317
+ const { run } = projection;
318
+ const lines = [
319
+ `run ${run.runId}`,
320
+ `ticket ${projection.ticketId ?? "unavailable"}${projection.ticketTitle ? `: ${projection.ticketTitle}` : ""}`,
321
+ `status ${run.status}; checkpoint ${run.checkpoint}`,
322
+ `completed ${projection.completed.join(", ") || "none recorded"}`,
323
+ `current ${run.progress.currentStep ?? projection.ticketId ?? "not recorded"}`,
324
+ `remaining ${projection.remaining.join(", ") || "none"}`,
325
+ `last successful action ${projection.lastSuccess}`,
326
+ `interruption/failure ${projection.interruption}`,
327
+ `validation/QA ${projection.validation}`,
328
+ `branch ${projection.branch ?? "current"}; worktree ${projection.worktree}`,
329
+ `baseline ${run.repository.baselineComplete ? `${run.repository.git.baseRef ?? "recorded"} @ ${run.repository.git.baselineHead}` : "incomplete; automatic rollback is unavailable"}`,
330
+ `session ${projection.exactSessionId ? "exact Builder session available" : "fresh Builder session required"}`,
331
+ `runtime ${run.builder?.settings.make ?? "unavailable"}; model ${run.builder?.settings.model ?? "unavailable"}`,
332
+ `updated ${run.updatedAt}`,
333
+ `preserved expected in-progress paths ${projection.expectedChanges.join(", ") || "none currently dirty"}`,
334
+ ];
335
+ if (projection.unexpectedChanges.length) {
336
+ lines.push("WARNING: unexpected recovery state (recovery remains available):");
337
+ for (const item of projection.unexpectedChanges)
338
+ lines.push(` ${item.path}: ${item.risk}`);
339
+ lines.push("recommended action: preserve or move these changes before recovery if they overlap the interrupted work");
340
+ }
341
+ lines.push(`next action ${projection.nextAction}`);
342
+ return lines;
343
+ }
202
344
  function currentLease(now) {
203
345
  return { hostname: hostname(), pid: process.pid, processStart: processStartIdentity(process.pid), heartbeatAt: now.toISOString() };
204
346
  }
@@ -210,17 +352,8 @@ function processStartIdentity(pid) {
210
352
  return pid === process.pid ? `${Date.now() - Math.round(process.uptime() * 1000)}` : "unknown";
211
353
  }
212
354
  }
213
- function fingerprintWorkingFiles(worktree) {
214
- try {
215
- const stat = statSync(resolve(worktree));
216
- return createHash("sha256").update(`${stat.dev}:${stat.ino}:${stat.mtimeMs}`).digest("hex");
217
- }
218
- catch {
219
- return undefined;
220
- }
221
- }
222
355
  function validateBuildRun(run) {
223
- if (run.version !== BUILD_RUN_VERSION || !run.runId || !run.repository?.root || !run.repository.worktree || !run.createdAt || !run.updatedAt) {
356
+ if (![1, 2].includes(run.version) || !run.runId || !run.repository?.root || !run.repository.worktree || !run.createdAt || !run.updatedAt) {
224
357
  throw new Error("invalid build run record");
225
358
  }
226
359
  }
@@ -229,6 +362,8 @@ function workflowStatus(status) {
229
362
  return "completed";
230
363
  if (status === "failed")
231
364
  return "failed";
365
+ if (status === "superseded")
366
+ return "superseded";
232
367
  if (status === "blocked")
233
368
  return "blocked";
234
369
  if (status === "interrupted" || status === "recoverable")
@@ -239,6 +374,92 @@ function remainingTickets(run) {
239
374
  const current = run.currentTicket ? run.tickets.indexOf(run.currentTicket) : 0;
240
375
  return run.status === "completed" ? [] : run.tickets.slice(Math.max(0, current));
241
376
  }
377
+ function captureGitSnapshot(worktree, input) {
378
+ const git = (args) => {
379
+ try {
380
+ return execFileSync("git", args, { cwd: worktree, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim() || undefined;
381
+ }
382
+ catch {
383
+ return undefined;
384
+ }
385
+ };
386
+ const statusPaths = (git(["status", "--porcelain=v1", "-z"]) ?? "").split("\0").filter(Boolean).map((line) => line.slice(3)).sort();
387
+ const head = input.startHead ?? git(["rev-parse", "HEAD"]);
388
+ const branch = input.branch ?? git(["branch", "--show-current"]);
389
+ return {
390
+ baselineHead: input.baseHead ?? head,
391
+ baseRef: input.baseRef ?? branch,
392
+ branch,
393
+ startHead: head,
394
+ worktree,
395
+ worktreeIdentity: worktreeIdentity(worktree),
396
+ statusPaths,
397
+ initialStatusPaths: [...statusPaths],
398
+ runOwnedPaths: [],
399
+ createdBranch: false,
400
+ createdWorktree: worktree !== resolve(input.repositoryRoot),
401
+ upstream: git(["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"]),
402
+ };
403
+ }
404
+ function worktreeIdentity(worktree) {
405
+ try {
406
+ const stat = statSync(resolve(worktree));
407
+ return createHash("sha256").update(`${stat.dev}:${stat.ino}`).digest("hex");
408
+ }
409
+ catch {
410
+ return undefined;
411
+ }
412
+ }
413
+ function gitValue(cwd, args) {
414
+ try {
415
+ return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim() || undefined;
416
+ }
417
+ catch {
418
+ return undefined;
419
+ }
420
+ }
421
+ function gitStatusPaths(cwd) {
422
+ return (gitValue(cwd, ["status", "--porcelain=v1", "-z"]) ?? "").split("\0").filter(Boolean).map((line) => line.slice(3)).sort();
423
+ }
424
+ function relativeTime(value, now) {
425
+ const seconds = Math.max(0, Math.floor((now.getTime() - new Date(value).getTime()) / 1000));
426
+ if (seconds < 60)
427
+ return `${seconds}s ago`;
428
+ if (seconds < 3600)
429
+ return `${Math.floor(seconds / 60)}m ago`;
430
+ if (seconds < 86_400)
431
+ return `${Math.floor(seconds / 3600)}h ago`;
432
+ return `${Math.floor(seconds / 86_400)}d ago`;
433
+ }
434
+ function upgradeBuildRun(run) {
435
+ if (run.version === 2)
436
+ return run;
437
+ const legacy = run;
438
+ const remaining = legacy.status === "completed" ? [] : legacy.tickets.slice(Math.max(0, legacy.currentTicket ? legacy.tickets.indexOf(legacy.currentTicket) : 0));
439
+ return {
440
+ ...legacy,
441
+ version: 2,
442
+ legacy: true,
443
+ repository: {
444
+ ...legacy.repository,
445
+ baselineComplete: false,
446
+ git: {
447
+ baselineHead: legacy.repository.baseHead,
448
+ branch: legacy.repository.branch,
449
+ startHead: legacy.repository.startHead,
450
+ worktree: legacy.repository.worktree,
451
+ statusPaths: [], initialStatusPaths: [], runOwnedPaths: [], createdBranch: false, createdWorktree: false,
452
+ },
453
+ },
454
+ progress: {
455
+ completedTickets: legacy.status === "completed" ? [...legacy.tickets] : [],
456
+ completedOperations: Object.keys(legacy.receipts).sort(),
457
+ remainingTickets: remaining,
458
+ lastSuccessfulAction: legacy.checkpoint,
459
+ nextAction: remaining[0] ? `Resume ${remaining[0]}` : "Inspect legacy run",
460
+ },
461
+ };
462
+ }
242
463
  function releaseWorkflowLease(projectDir, runId, now = new Date()) {
243
464
  const workflow = new WorkflowDb(projectDir);
244
465
  try {
@@ -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") {