ai-foreman 1.7.10 → 1.7.12

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.
package/README.md CHANGED
@@ -15,9 +15,9 @@ Loop Claude Code or Codex through tickets.
15
15
  - **Rich ticket system** — tickets carry acceptance criteria, required tests, dependencies, priority, size, and risk level. The agent reads the full context before starting each step.
16
16
  - **Future work tracking** — when the builder discovers out-of-scope work during a run, `ai-foreman tickets discover` captures it without derailing the current ticket. Discovered items live in a separate inbox until you promote them.
17
17
  - **QA that actually gates** — QA runs after every completed ticket and checks code quality, test passing, and regression protection. The ticket only closes after QA passes. QA turns do not count against `--steps`.
18
- - **Visible long-running work** — one elapsed-time activity line identifies provider, phase, tools, retries, and quiet periods; redirected output receives timestamped heartbeats instead of ANSI animation.
18
+ - **Visible long-running work** — semantic activity rows identify provider, phase, tools, retries, and quiet periods; cursor-capable terminals update changing counters in place, while record-oriented terminals coalesce them.
19
19
 
20
- Provider-managed retries are reported immediately as durable lines. A quiet-provider warning appears after 60 seconds without a signal, but Foreman does not add retries, abort the provider, or change provider retry limits. Non-TTY output receives a heartbeat every 30 seconds while work remains pending.
20
+ Provider-managed retries are reported immediately as durable lines. A quiet-provider warning appears after 60 seconds without a signal, but Foreman does not add retries, abort the provider, or change provider retry limits. Non-TTY output receives a heartbeat every 30 seconds while work remains pending. TTY rendering defaults to automatic detection: Codex, CI, and `TERM=dumb` use newline records and suppress spinner- or number-only repeats; other terminals redraw those changes in place and retain a row when the textual status changes. Use `RAFI_ACTIVITY_RENDER_MODE=auto|cursor|records` to override detection.
21
21
 
22
22
  ## Install
23
23
 
@@ -146,7 +146,7 @@ Saved `tickets.build` defaults in `rafi-config.yaml` can enable branch-per-ticke
146
146
 
147
147
  With the saved `current` strategy, Foreman works in the active branch while the user owns Git. It may edit, test, run QA, and update tracker/recovery state, but all branch/worktree, commit, push, merge, rebase, and review lifecycle commands are fenced. An unexpected active ref or worktree change pauses the run. Explicit isolated flags are reported as run overrides.
148
148
 
149
- The active terminal line always includes the current role/provider, activity, truthful context state, successful compaction count, and handoff generation. At the Builder threshold, Foreman settles the in-flight action, verifies native compaction plus a fresh provider usage sample, and resumes the frozen action. The configured maximum defaults to ten successful compactions per location-scoped provider session; the next crossing uses a validated fresh handoff instead of exceeding it. Disposable QA snapshots never reuse a prior QA conversation in a new `/tmp/rafi-qa-*` worktree; they transfer cumulative state through an accepted handoff.
149
+ The current terminal status includes the role/provider, activity, truthful context state, successful compaction count, and handoff generation. At the Builder threshold, Foreman settles the in-flight action, verifies native compaction plus a fresh provider usage sample, and resumes the frozen action. The configured maximum defaults to ten successful compactions per location-scoped provider session; the next crossing uses a validated fresh handoff instead of exceeding it. Disposable QA snapshots never reuse a prior QA conversation in a new `/tmp/rafi-qa-*` worktree; they transfer cumulative state through an accepted handoff.
150
150
 
151
151
  ### GitHub PR Failure Recovery
152
152
 
@@ -11,7 +11,10 @@ export interface ActivityReporterOptions {
11
11
  tickMs?: number;
12
12
  heartbeatMs?: number;
13
13
  quietWarningMs?: number;
14
+ /** Override automatic TTY capability detection for tests or embedding hosts. */
15
+ ttyMode?: ActivityTtyMode;
14
16
  }
17
+ export type ActivityTtyMode = "auto" | "cursor" | "records";
15
18
  /** One process-local live view shared by nested RAFI operations. */
16
19
  export declare class ActivityReporter {
17
20
  readonly command: string;
@@ -21,6 +24,7 @@ export declare class ActivityReporter {
21
24
  private readonly tickMs;
22
25
  private readonly heartbeatMs;
23
26
  private readonly quietWarningMs;
27
+ private readonly ttyMode;
24
28
  private readonly commandStartedAt;
25
29
  private readonly phases;
26
30
  private nextPhaseId;
@@ -33,6 +37,7 @@ export declare class ActivityReporter {
33
37
  private quietWarningPrinted;
34
38
  private lastHeartbeatAt;
35
39
  private lineVisible;
40
+ private lastSemanticKey?;
36
41
  private paused;
37
42
  private agentStatusLine?;
38
43
  constructor(command: string, options?: ActivityReporterOptions);
@@ -45,7 +50,7 @@ export declare class ActivityReporter {
45
50
  pulse(detail?: string): void;
46
51
  note(message: string): void;
47
52
  writePersistent(text: string): void;
48
- /** Keep role/provider/context state in the single bottom-most TTY line. */
53
+ /** Keep role/provider/context state attached to the current activity status. */
49
54
  setAgentStatus(line: string | undefined): void;
50
55
  pause(): () => void;
51
56
  dispose(): void;
package/dist/activity.js CHANGED
@@ -10,6 +10,7 @@ export class ActivityReporter {
10
10
  tickMs;
11
11
  heartbeatMs;
12
12
  quietWarningMs;
13
+ ttyMode;
13
14
  commandStartedAt;
14
15
  phases = new Map();
15
16
  nextPhaseId = 1;
@@ -22,6 +23,7 @@ export class ActivityReporter {
22
23
  quietWarningPrinted = false;
23
24
  lastHeartbeatAt = 0;
24
25
  lineVisible = false;
26
+ lastSemanticKey;
25
27
  paused = 0;
26
28
  agentStatusLine;
27
29
  constructor(command, options = {}) {
@@ -35,6 +37,7 @@ export class ActivityReporter {
35
37
  this.tickMs = options.tickMs ?? 1_000;
36
38
  this.heartbeatMs = options.heartbeatMs ?? 30_000;
37
39
  this.quietWarningMs = options.quietWarningMs ?? 60_000;
40
+ this.ttyMode = resolveTtyMode(options.ttyMode);
38
41
  this.commandStartedAt = this.now();
39
42
  }
40
43
  begin(label) {
@@ -91,7 +94,7 @@ export class ActivityReporter {
91
94
  this.output.write("\n");
92
95
  this.render(true);
93
96
  }
94
- /** Keep role/provider/context state in the single bottom-most TTY line. */
97
+ /** Keep role/provider/context state attached to the current activity status. */
95
98
  setAgentStatus(line) {
96
99
  this.agentStatusLine = line ? clean(line, 500) : undefined;
97
100
  if (this.agentStatusLine && !this.timer) {
@@ -130,7 +133,6 @@ export class ActivityReporter {
130
133
  }
131
134
  idle() {
132
135
  if (this.agentStatusLine) {
133
- this.clearLine();
134
136
  this.detail = undefined;
135
137
  this.provider = undefined;
136
138
  this.model = undefined;
@@ -145,6 +147,7 @@ export class ActivityReporter {
145
147
  this.provider = undefined;
146
148
  this.model = undefined;
147
149
  this.agentStatusLine = undefined;
150
+ this.lastSemanticKey = undefined;
148
151
  }
149
152
  currentPhase() {
150
153
  return [...this.phases.values()].at(-1);
@@ -168,8 +171,21 @@ export class ActivityReporter {
168
171
  const body = [phase?.label, provider || undefined, phase ? quiet : undefined, this.agentStatusLine].filter(Boolean).join(" — ");
169
172
  if (this.output.isTTY) {
170
173
  const line = `${FRAMES[this.frame++ % FRAMES.length]} RAFI working: ${body} (${formatDuration(now - this.commandStartedAt)})`;
174
+ const semanticKey = semanticStatusKey(body);
175
+ if (this.ttyMode === "records") {
176
+ if (semanticKey === this.lastSemanticKey)
177
+ return;
178
+ this.output.write(`${line}\n`);
179
+ this.lastSemanticKey = semanticKey;
180
+ return;
181
+ }
182
+ if (this.lineVisible && semanticKey !== this.lastSemanticKey) {
183
+ this.output.write("\n");
184
+ this.lineVisible = false;
185
+ }
171
186
  this.output.write(`\r\x1b[2K${line}`);
172
187
  this.lineVisible = true;
188
+ this.lastSemanticKey = semanticKey;
173
189
  return;
174
190
  }
175
191
  if (force || now - this.lastHeartbeatAt >= this.heartbeatMs) {
@@ -260,6 +276,27 @@ function briefInput(input) {
260
276
  function clean(value, maximum) {
261
277
  return value.replace(/[\r\n\t]+/g, " ").replace(/\s+/g, " ").trim().slice(0, maximum);
262
278
  }
279
+ function semanticStatusKey(body) {
280
+ return body.replace(/\p{Number}+(?:[.,]\p{Number}+)*/gu, "#");
281
+ }
282
+ function resolveTtyMode(requested) {
283
+ const configured = requested ?? configuredTtyMode(process.env.RAFI_ACTIVITY_RENDER_MODE);
284
+ if (configured === "cursor" || configured === "records")
285
+ return configured;
286
+ return truthyEnvironmentValue(process.env.CODEX_CI)
287
+ || truthyEnvironmentValue(process.env.CI)
288
+ || process.env.TERM?.toLowerCase() === "dumb"
289
+ ? "records"
290
+ : "cursor";
291
+ }
292
+ function configuredTtyMode(value) {
293
+ return value === "cursor" || value === "records" || value === "auto" ? value : "auto";
294
+ }
295
+ function truthyEnvironmentValue(value) {
296
+ if (value === undefined)
297
+ return false;
298
+ return !["", "0", "false"].includes(value.trim().toLowerCase());
299
+ }
263
300
  function formatDuration(milliseconds) {
264
301
  const seconds = Math.max(0, Math.floor(milliseconds / 1_000));
265
302
  if (seconds < 60)
package/dist/agentRun.js CHANGED
@@ -293,7 +293,7 @@ export async function runRoleInstruction(opts) {
293
293
  const { builder, effort, roleBundle, skills } = roleBuilder;
294
294
  const viewer = printEvents(builder.events());
295
295
  const config = loadConfig(join(opts.projectDir, "foreman.yaml"));
296
- const foreman = new Foreman(builder, log, config.notifications.enabled, false, 3, opts.projectDir);
296
+ const foreman = new Foreman(builder, log, { desktop: config.notifications.enabled, terminalBell: config.notifications.terminal_bell }, false, 3, opts.projectDir);
297
297
  try {
298
298
  const turn = await foreman.runInstruction(opts.instruction);
299
299
  await builder.close();
@@ -25,6 +25,7 @@ export interface BranchRunnerOptions {
25
25
  effort?: EffortLevel;
26
26
  fast?: boolean;
27
27
  notificationsEnabled: boolean;
28
+ terminalBellEnabled?: boolean;
28
29
  qaEnabled: boolean;
29
30
  createPr: boolean;
30
31
  completionMode?: CompletionMode;
@@ -2,7 +2,7 @@ import { buildDurableQaFixHandoff, compactWithRetry, runIsolatedQa } from "../qa
2
2
  import { changeManifestAsync } from "../qaSnapshot.js";
3
3
  import { Foreman, MARKER_SPEC, parseStepStatus } from "../foreman.js";
4
4
  import { fireNotification } from "../notify.js";
5
- import { cmdBlock, cmdComplete, cmdUpdate } from "../tickets/commands.js";
5
+ import { cmdBlock, cmdComplete, cmdUnblock, cmdUpdate } from "../tickets/commands.js";
6
6
  import { commitAll, createTicketWorktree, findWorktreeForBranch, currentWorktreeBranch, deleteLocalBranch, ensureCleanBaseWorktree, ensureForemanExcluded, hasTrackerChanges, hasWorktreeChanges, headCommitIfAhead, removeTicketWorktree, mergeBranchToLocalBase, } from "./git.js";
7
7
  import { checkGitHubPrMerged, createOrReusePr, enableGitHubAutoMerge, pushBranchForPr } from "./github.js";
8
8
  import { checkGitLabMrMerged, createOrReuseMr, enableGitLabAutoMerge, pushBranchForMr } from "./gitlab.js";
@@ -143,10 +143,14 @@ export async function runBranchPlan(opts) {
143
143
  sessionRef: resumeSession.sessionRef,
144
144
  });
145
145
  }
146
- journalTracker(opts.projectDir, opts.runId, `${opts.runId}:tracker-update:${node.ticket.id}:in-progress`, { ticket: node.ticket.id, status: "in_progress" }, () => cmdUpdate(opts.projectDir, node.ticket.id, {
147
- status: "in_progress", actor: "foreman",
148
- summary: resumeSession ? `Resuming branch ${node.branch}` : `Starting branch ${node.branch}`,
149
- }));
146
+ journalTracker(opts.projectDir, opts.runId, `${opts.runId}:tracker-update:${node.ticket.id}:in-progress`, { ticket: node.ticket.id, status: "in_progress" }, () => {
147
+ if (resumeSession)
148
+ cmdUnblock(opts.projectDir, node.ticket.id, { actor: "foreman", summary: `Reopened by explicit branch recovery for ${node.branch}` });
149
+ cmdUpdate(opts.projectDir, node.ticket.id, {
150
+ status: "in_progress", actor: "foreman",
151
+ summary: resumeSession ? `Resuming branch ${node.branch}` : `Starting branch ${node.branch}`,
152
+ });
153
+ });
150
154
  const ticketInstruction = resumeSession ? buildBranchTicketResumeInstruction(node, opts.trackerPaths) : buildBranchTicketInstruction(node, opts.trackerPaths);
151
155
  // Reattach the predecessor even for a `fresh` strategy so the host can
152
156
  // publish and validate a cumulative handoff before creating its successor.
@@ -173,7 +177,7 @@ export async function runBranchPlan(opts) {
173
177
  }
174
178
  builderWorkSessions += 1;
175
179
  viewer = opts.observeBuilder?.(builder);
176
- const foreman = new Foreman(builder, opts.log, opts.notificationsEnabled, opts.qaEnabled, 3, undefined, undefined, undefined, opts.qaSessionStrategy ?? "compact", undefined, opts.builderSessionStrategy ?? "compact", undefined, opts.beforeBuilderTurn ? (adapter, action) => opts.beforeBuilderTurn(adapter, action, worktreePath) : undefined, undefined, undefined, async (adapter) => opts.observeBuilderNativeCompactions?.(adapter, worktreePath));
180
+ const foreman = new Foreman(builder, opts.log, { desktop: opts.notificationsEnabled, terminalBell: opts.terminalBellEnabled ?? true }, opts.qaEnabled, 3, undefined, undefined, undefined, opts.qaSessionStrategy ?? "compact", undefined, opts.builderSessionStrategy ?? "compact", undefined, opts.beforeBuilderTurn ? (adapter, action) => opts.beforeBuilderTurn(adapter, action, worktreePath) : undefined, undefined, undefined, undefined, async (adapter) => opts.observeBuilderNativeCompactions?.(adapter, worktreePath));
177
181
  const { result, status } = await foreman.runInstruction(ticketInstruction);
178
182
  builder = foreman.builderAdapter();
179
183
  workflowCheckpoint(opts.projectDir, opts.runId, "builder-after", node.ticket.id, { status: status.kind, sessionId: builder.sessionId(), worktree: worktreePath });
@@ -243,6 +247,7 @@ export async function runBranchPlan(opts) {
243
247
  qaStrategy: opts.qaSessionStrategy ?? "compact", state: qaStream, createQa: opts.createQa, maxCycles: 3,
244
248
  sessionBoundary: opts.qaSessionBoundary,
245
249
  observeNativeCompactions: opts.observeQaNativeCompactions,
250
+ resolveBlocked: (adapter, reason) => foreman.resolveBlocker(adapter, reason),
246
251
  evidence: (entry) => opts.log.write("qa-evidence", { ticket: node.ticket.id, ...entry }),
247
252
  fix: async (issues) => {
248
253
  if (!builder)
@@ -267,8 +272,13 @@ export async function runBranchPlan(opts) {
267
272
  }
268
273
  if (opts.beforeBuilderTurn)
269
274
  builder = await opts.beforeBuilderTurn(builder, fixInstruction, worktreePath);
270
- const fixed = await builder.sendTurn(fixInstruction);
271
- const fixedStatus = parseStepStatus(fixed.text);
275
+ let fixed = await builder.sendTurn(fixInstruction);
276
+ let fixedStatus = parseStepStatus(fixed.text);
277
+ if (!fixed.isError && fixedStatus.kind === "blocked") {
278
+ const resolved = await foreman.resolveBlocker(builder, fixedStatus.reason ?? "Builder QA fix reported an unspecified blocker");
279
+ fixed = resolved.result;
280
+ fixedStatus = resolved.status;
281
+ }
272
282
  const fixedSessionId = builder.sessionId();
273
283
  const fixedSessionRef = builder.sessionRef?.();
274
284
  if (fixedSessionId)