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 +3 -3
- package/dist/activity.d.ts +6 -1
- package/dist/activity.js +39 -2
- package/dist/agentRun.js +1 -1
- package/dist/branch/runner.d.ts +1 -0
- package/dist/branch/runner.js +18 -8
- package/dist/cli/start.js +170 -102
- package/dist/cli/tickets.js +1 -1
- package/dist/config.d.ts +2 -0
- package/dist/config.js +5 -1
- package/dist/foreman.d.ts +12 -2
- package/dist/foreman.js +82 -27
- package/dist/handoffs.d.ts +30 -2
- package/dist/handoffs.js +151 -16
- package/dist/log.d.ts +1 -1
- package/dist/notify.d.ts +4 -0
- package/dist/notify.js +15 -0
- package/dist/qaReview.d.ts +5 -0
- package/dist/qaReview.js +10 -2
- package/foreman.yaml +4 -0
- package/package.json +2 -2
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** —
|
|
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
|
|
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
|
|
package/dist/activity.d.ts
CHANGED
|
@@ -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
|
|
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
|
|
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();
|
package/dist/branch/runner.d.ts
CHANGED
package/dist/branch/runner.js
CHANGED
|
@@ -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" }, () =>
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
|
|
271
|
-
|
|
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)
|