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.
- package/README.md +6 -1
- package/dist/activity.d.ts +63 -0
- package/dist/activity.js +244 -0
- package/dist/adapters/claude.d.ts +7 -0
- package/dist/adapters/claude.js +41 -1
- package/dist/adapters/codex.d.ts +1 -0
- package/dist/adapters/codex.js +65 -5
- package/dist/adapters/recovering.js +7 -1
- package/dist/adapters/types.d.ts +15 -0
- package/dist/agentRun.d.ts +5 -0
- package/dist/agentRun.js +5 -1
- package/dist/branch/github.js +5 -3
- package/dist/branch/gitlab.js +5 -3
- package/dist/branch/planner.d.ts +5 -0
- package/dist/branch/planner.js +42 -3
- package/dist/branch/presentation.d.ts +17 -0
- package/dist/branch/presentation.js +38 -0
- package/dist/branch/resume.d.ts +2 -0
- package/dist/branch/resume.js +2 -0
- package/dist/branch/runner.js +44 -25
- package/dist/buildRuns.d.ts +41 -17
- package/dist/buildRuns.js +243 -22
- package/dist/cli/events.js +24 -5
- package/dist/cli/runtimeAuthPrompt.js +5 -1
- package/dist/cli/start.js +138 -45
- package/dist/cli/tickets.d.ts +10 -1
- package/dist/cli/tickets.js +418 -79
- package/dist/foreman.d.ts +8 -5
- package/dist/foreman.js +38 -14
- package/dist/index.js +3 -1
- package/dist/providerQuestions.d.ts +23 -0
- package/dist/providerQuestions.js +24 -0
- package/dist/qaReview.d.ts +1 -0
- package/dist/qaReview.js +23 -15
- package/dist/qaSnapshot.d.ts +14 -0
- package/dist/qaSnapshot.js +117 -1
- package/dist/runtimeReadiness.js +5 -0
- package/dist/sources/sourceRegistry.js +13 -6
- package/dist/tickets/details.d.ts +17 -0
- package/dist/tickets/details.js +94 -0
- package/dist/tickets/eligibility.d.ts +28 -0
- package/dist/tickets/eligibility.js +71 -0
- package/dist/tickets/importer.js +13 -8
- package/dist/tickets/reset.d.ts +27 -0
- package/dist/tickets/reset.js +213 -0
- package/dist/tickets/reviewStandards.d.ts +7 -0
- package/dist/tickets/reviewStandards.js +53 -0
- package/dist/tickets/setupConfig.d.ts +22 -0
- package/dist/tickets/setupConfig.js +105 -1
- package/dist/tickets/sourceFetch.js +19 -0
- package/dist/tickets/stateDb.d.ts +8 -1
- package/dist/tickets/stateDb.js +54 -23
- package/dist/workflowDb.d.ts +3 -0
- package/dist/workflowDb.js +26 -0
- package/package.json +15 -3
package/README.md
CHANGED
|
@@ -15,6 +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.
|
|
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.
|
|
18
21
|
|
|
19
22
|
## Install
|
|
20
23
|
|
|
@@ -109,7 +112,7 @@ What each part does:
|
|
|
109
112
|
| `--keep-worktrees` | flag | remove successful worktrees | Keeps successful ticket worktrees for inspection. Blocked worktrees are kept automatically. |
|
|
110
113
|
| `--ticket <id>` | `T001` | none | Selects a branch-mode ticket to continue. Repeatable with `--continue`. |
|
|
111
114
|
| `--base <ref>` | `main` / `origin/main` / `HEAD` | current branch or `HEAD` | Base ref for root ticket branches. |
|
|
112
|
-
| `--branch-prefix <prefix>` | `
|
|
115
|
+
| `--branch-prefix <prefix>` | `feature` / `rafi` / another valid Git prefix | `feature` | Prefix for generated ticket branches. Explicit prefixes are preserved verbatim. |
|
|
113
116
|
| `--max-branch-depth <n>` | positive integer | `5` | Maximum selected branch/PR stack depth (root PR is depth 1). |
|
|
114
117
|
|
|
115
118
|
Auto-detected tracker files:
|
|
@@ -523,6 +526,8 @@ STEP_STATUS: plan_complete | ticket="T001" summary="all requested work is comple
|
|
|
523
526
|
STEP_STATUS: needs_input | question="Which storage backend?" choices="SQLite|Postgres"
|
|
524
527
|
```
|
|
525
528
|
|
|
529
|
+
Provider-native `AskUserQuestion` prompts preserve their existing input/output behavior. Hosts may also observe a question only after the user successfully supplies a non-empty answer; cancelled, denied, malformed, and empty prompts do not emit answered-question telemetry. Rafi uses this boundary to recognize machine-shaped exhaustive-planning exchanges without treating generic questions as grill-me coverage.
|
|
530
|
+
|
|
526
531
|
When QA is enabled, Foreman asks the builder to review its own work:
|
|
527
532
|
|
|
528
533
|
```txt
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { BuilderEvent } from "./adapters/types.js";
|
|
2
|
+
import { AsyncQueue } from "./util/asyncQueue.js";
|
|
3
|
+
export interface ActivityOutput {
|
|
4
|
+
write(text: string): void;
|
|
5
|
+
readonly isTTY: boolean;
|
|
6
|
+
}
|
|
7
|
+
export interface ActivityReporterOptions {
|
|
8
|
+
output?: ActivityOutput;
|
|
9
|
+
now?: () => number;
|
|
10
|
+
displayDelayMs?: number;
|
|
11
|
+
tickMs?: number;
|
|
12
|
+
heartbeatMs?: number;
|
|
13
|
+
quietWarningMs?: number;
|
|
14
|
+
}
|
|
15
|
+
/** One process-local live view shared by nested RAFI operations. */
|
|
16
|
+
export declare class ActivityReporter {
|
|
17
|
+
readonly command: string;
|
|
18
|
+
private readonly output;
|
|
19
|
+
private readonly now;
|
|
20
|
+
private readonly displayDelayMs;
|
|
21
|
+
private readonly tickMs;
|
|
22
|
+
private readonly heartbeatMs;
|
|
23
|
+
private readonly quietWarningMs;
|
|
24
|
+
private readonly commandStartedAt;
|
|
25
|
+
private readonly phases;
|
|
26
|
+
private nextPhaseId;
|
|
27
|
+
private frame;
|
|
28
|
+
private timer?;
|
|
29
|
+
private detail?;
|
|
30
|
+
private provider?;
|
|
31
|
+
private model?;
|
|
32
|
+
private lastSignalAt;
|
|
33
|
+
private quietWarningPrinted;
|
|
34
|
+
private lastHeartbeatAt;
|
|
35
|
+
private lineVisible;
|
|
36
|
+
private paused;
|
|
37
|
+
constructor(command: string, options?: ActivityReporterOptions);
|
|
38
|
+
begin(label: string): () => void;
|
|
39
|
+
update(state: string, detail?: string, metadata?: {
|
|
40
|
+
provider?: string;
|
|
41
|
+
model?: string;
|
|
42
|
+
immediate?: boolean;
|
|
43
|
+
}): void;
|
|
44
|
+
pulse(detail?: string): void;
|
|
45
|
+
note(message: string): void;
|
|
46
|
+
writePersistent(text: string): void;
|
|
47
|
+
pause(): () => void;
|
|
48
|
+
dispose(): void;
|
|
49
|
+
private startTimer;
|
|
50
|
+
private idle;
|
|
51
|
+
private currentPhase;
|
|
52
|
+
private render;
|
|
53
|
+
private clearLine;
|
|
54
|
+
}
|
|
55
|
+
export declare function currentActivity(): ActivityReporter | undefined;
|
|
56
|
+
export declare function withActivityContext<T>(command: string, operation: () => Promise<T>, options?: ActivityReporterOptions): Promise<T>;
|
|
57
|
+
export declare function withActivityPhase<T>(label: string, operation: () => Promise<T>): Promise<T>;
|
|
58
|
+
export declare function pauseActivityForInput<T>(operation: () => Promise<T>): Promise<T>;
|
|
59
|
+
export declare function reportBuilderEvent(event: BuilderEvent): void;
|
|
60
|
+
/** Async event queue that also feeds the active CLI reporter without consuming the stream. */
|
|
61
|
+
export declare class BuilderEventQueue extends AsyncQueue<BuilderEvent> {
|
|
62
|
+
push(event: BuilderEvent): void;
|
|
63
|
+
}
|
package/dist/activity.js
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
import { AsyncQueue } from "./util/asyncQueue.js";
|
|
3
|
+
const FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
4
|
+
/** One process-local live view shared by nested RAFI operations. */
|
|
5
|
+
export class ActivityReporter {
|
|
6
|
+
command;
|
|
7
|
+
output;
|
|
8
|
+
now;
|
|
9
|
+
displayDelayMs;
|
|
10
|
+
tickMs;
|
|
11
|
+
heartbeatMs;
|
|
12
|
+
quietWarningMs;
|
|
13
|
+
commandStartedAt;
|
|
14
|
+
phases = new Map();
|
|
15
|
+
nextPhaseId = 1;
|
|
16
|
+
frame = 0;
|
|
17
|
+
timer;
|
|
18
|
+
detail;
|
|
19
|
+
provider;
|
|
20
|
+
model;
|
|
21
|
+
lastSignalAt = 0;
|
|
22
|
+
quietWarningPrinted = false;
|
|
23
|
+
lastHeartbeatAt = 0;
|
|
24
|
+
lineVisible = false;
|
|
25
|
+
paused = 0;
|
|
26
|
+
constructor(command, options = {}) {
|
|
27
|
+
this.command = command;
|
|
28
|
+
this.output = options.output ?? {
|
|
29
|
+
isTTY: Boolean(process.stdout.isTTY),
|
|
30
|
+
write: (text) => process.stdout.write(text),
|
|
31
|
+
};
|
|
32
|
+
this.now = options.now ?? Date.now;
|
|
33
|
+
this.displayDelayMs = options.displayDelayMs ?? 250;
|
|
34
|
+
this.tickMs = options.tickMs ?? 1_000;
|
|
35
|
+
this.heartbeatMs = options.heartbeatMs ?? 30_000;
|
|
36
|
+
this.quietWarningMs = options.quietWarningMs ?? 60_000;
|
|
37
|
+
this.commandStartedAt = this.now();
|
|
38
|
+
}
|
|
39
|
+
begin(label) {
|
|
40
|
+
const phase = { id: this.nextPhaseId++, label: clean(label, 100), startedAt: this.now() };
|
|
41
|
+
this.phases.set(phase.id, phase);
|
|
42
|
+
if (this.phases.size === 1) {
|
|
43
|
+
this.lastSignalAt = phase.startedAt;
|
|
44
|
+
this.lastHeartbeatAt = phase.startedAt;
|
|
45
|
+
this.quietWarningPrinted = false;
|
|
46
|
+
this.startTimer();
|
|
47
|
+
}
|
|
48
|
+
this.render(!this.output.isTTY);
|
|
49
|
+
let ended = false;
|
|
50
|
+
return () => {
|
|
51
|
+
if (ended)
|
|
52
|
+
return;
|
|
53
|
+
ended = true;
|
|
54
|
+
this.phases.delete(phase.id);
|
|
55
|
+
if (this.phases.size === 0)
|
|
56
|
+
this.idle();
|
|
57
|
+
else
|
|
58
|
+
this.render(true);
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
update(state, detail, metadata = {}) {
|
|
62
|
+
const phase = this.currentPhase();
|
|
63
|
+
if (phase && state)
|
|
64
|
+
phase.label = clean(state, 100);
|
|
65
|
+
this.detail = detail ? clean(detail, 120) : undefined;
|
|
66
|
+
this.provider = metadata.provider ?? this.provider;
|
|
67
|
+
this.model = metadata.model ?? this.model;
|
|
68
|
+
this.lastSignalAt = this.now();
|
|
69
|
+
this.quietWarningPrinted = false;
|
|
70
|
+
this.render(metadata.immediate !== false || this.output.isTTY);
|
|
71
|
+
}
|
|
72
|
+
pulse(detail) {
|
|
73
|
+
if (detail)
|
|
74
|
+
this.detail = clean(detail, 120);
|
|
75
|
+
this.lastSignalAt = this.now();
|
|
76
|
+
this.quietWarningPrinted = false;
|
|
77
|
+
this.render(this.output.isTTY);
|
|
78
|
+
}
|
|
79
|
+
note(message) {
|
|
80
|
+
this.clearLine();
|
|
81
|
+
this.output.write(`${clean(message, 500)}\n`);
|
|
82
|
+
this.render(true);
|
|
83
|
+
}
|
|
84
|
+
writePersistent(text) {
|
|
85
|
+
if (!text)
|
|
86
|
+
return;
|
|
87
|
+
this.clearLine();
|
|
88
|
+
this.output.write(text);
|
|
89
|
+
if (!text.endsWith("\n"))
|
|
90
|
+
this.output.write("\n");
|
|
91
|
+
this.render(true);
|
|
92
|
+
}
|
|
93
|
+
pause() {
|
|
94
|
+
this.paused++;
|
|
95
|
+
this.clearLine();
|
|
96
|
+
let resumed = false;
|
|
97
|
+
return () => {
|
|
98
|
+
if (resumed)
|
|
99
|
+
return;
|
|
100
|
+
resumed = true;
|
|
101
|
+
this.paused = Math.max(0, this.paused - 1);
|
|
102
|
+
this.render(true);
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
dispose() {
|
|
106
|
+
this.phases.clear();
|
|
107
|
+
this.idle();
|
|
108
|
+
}
|
|
109
|
+
startTimer() {
|
|
110
|
+
if (this.timer)
|
|
111
|
+
return;
|
|
112
|
+
this.timer = setInterval(() => this.render(false), this.tickMs);
|
|
113
|
+
this.timer.unref();
|
|
114
|
+
}
|
|
115
|
+
idle() {
|
|
116
|
+
if (this.timer)
|
|
117
|
+
clearInterval(this.timer);
|
|
118
|
+
this.timer = undefined;
|
|
119
|
+
this.clearLine();
|
|
120
|
+
this.detail = undefined;
|
|
121
|
+
this.provider = undefined;
|
|
122
|
+
this.model = undefined;
|
|
123
|
+
}
|
|
124
|
+
currentPhase() {
|
|
125
|
+
return [...this.phases.values()].at(-1);
|
|
126
|
+
}
|
|
127
|
+
render(force) {
|
|
128
|
+
const phase = this.currentPhase();
|
|
129
|
+
if (!phase || this.paused > 0)
|
|
130
|
+
return;
|
|
131
|
+
const now = this.now();
|
|
132
|
+
const elapsed = now - phase.startedAt;
|
|
133
|
+
if (!force && elapsed < this.displayDelayMs)
|
|
134
|
+
return;
|
|
135
|
+
const quietFor = now - this.lastSignalAt;
|
|
136
|
+
if (quietFor >= this.quietWarningMs && !this.quietWarningPrinted) {
|
|
137
|
+
this.quietWarningPrinted = true;
|
|
138
|
+
this.note(`rafi: ${this.provider ?? "provider"} has been quiet for ${formatDuration(quietFor)}; RAFI is still responsive and will keep waiting`);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
const provider = [this.provider, this.model].filter(Boolean).join("/");
|
|
142
|
+
const quiet = quietFor >= this.quietWarningMs ? `provider quiet ${formatDuration(quietFor)}; RAFI is responsive` : this.detail;
|
|
143
|
+
const body = [phase.label, provider || undefined, quiet].filter(Boolean).join(" — ");
|
|
144
|
+
if (this.output.isTTY) {
|
|
145
|
+
const line = `${FRAMES[this.frame++ % FRAMES.length]} RAFI working: ${body} (${formatDuration(now - this.commandStartedAt)})`;
|
|
146
|
+
this.output.write(`\r\x1b[2K${line}`);
|
|
147
|
+
this.lineVisible = true;
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
if (force || now - this.lastHeartbeatAt >= this.heartbeatMs) {
|
|
151
|
+
this.lastHeartbeatAt = now;
|
|
152
|
+
this.output.write(`[${new Date(now).toISOString()}] rafi working: ${body} (${formatDuration(now - this.commandStartedAt)})\n`);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
clearLine() {
|
|
156
|
+
if (!this.lineVisible || !this.output.isTTY)
|
|
157
|
+
return;
|
|
158
|
+
this.output.write("\r\x1b[2K");
|
|
159
|
+
this.lineVisible = false;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
const activityContext = new AsyncLocalStorage();
|
|
163
|
+
export function currentActivity() { return activityContext.getStore(); }
|
|
164
|
+
export async function withActivityContext(command, operation, options = {}) {
|
|
165
|
+
const existing = currentActivity();
|
|
166
|
+
if (existing)
|
|
167
|
+
return operation();
|
|
168
|
+
const reporter = new ActivityReporter(command, options);
|
|
169
|
+
return activityContext.run(reporter, async () => {
|
|
170
|
+
try {
|
|
171
|
+
return await operation();
|
|
172
|
+
}
|
|
173
|
+
finally {
|
|
174
|
+
reporter.dispose();
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
export async function withActivityPhase(label, operation) {
|
|
179
|
+
const end = currentActivity()?.begin(label);
|
|
180
|
+
try {
|
|
181
|
+
return await operation();
|
|
182
|
+
}
|
|
183
|
+
finally {
|
|
184
|
+
end?.();
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
export async function pauseActivityForInput(operation) {
|
|
188
|
+
const resume = currentActivity()?.pause();
|
|
189
|
+
try {
|
|
190
|
+
return await operation();
|
|
191
|
+
}
|
|
192
|
+
finally {
|
|
193
|
+
resume?.();
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
export function reportBuilderEvent(event) {
|
|
197
|
+
const reporter = currentActivity();
|
|
198
|
+
if (!reporter)
|
|
199
|
+
return;
|
|
200
|
+
if (event.kind === "activity")
|
|
201
|
+
reporter.update(event.state, event.detail, { provider: event.provider, model: event.model, immediate: !event.transient });
|
|
202
|
+
else if (event.kind === "retry") {
|
|
203
|
+
const attempt = event.attempt ? ` (${event.attempt}${event.maximum ? `/${event.maximum}` : ""})` : "";
|
|
204
|
+
const delay = event.delayMs ? ` in ${formatDuration(event.delayMs)}` : "";
|
|
205
|
+
reporter.note(`rafi: ${event.provider} ${clean(event.reason, 300)}; retrying${attempt}${delay}`);
|
|
206
|
+
reporter.update(`retrying ${event.provider}`, event.reason, { provider: event.provider });
|
|
207
|
+
}
|
|
208
|
+
else if (event.kind === "tool")
|
|
209
|
+
reporter.update(`running ${event.name}`, briefInput(event.input));
|
|
210
|
+
else if (event.kind === "text")
|
|
211
|
+
reporter.update("processing agent response");
|
|
212
|
+
else if (event.kind === "session-transition")
|
|
213
|
+
reporter.update(`session ${event.transition}`, event.detail);
|
|
214
|
+
else if (event.kind === "context-usage")
|
|
215
|
+
reporter.pulse(event.percentage === undefined ? undefined : `context ${event.percentage.toFixed(0)}%`);
|
|
216
|
+
else if (event.kind === "turn-complete")
|
|
217
|
+
reporter.update(event.result.isError ? "agent turn failed" : "agent turn complete");
|
|
218
|
+
else if (event.kind === "error")
|
|
219
|
+
reporter.update("provider error", event.message);
|
|
220
|
+
}
|
|
221
|
+
/** Async event queue that also feeds the active CLI reporter without consuming the stream. */
|
|
222
|
+
export class BuilderEventQueue extends AsyncQueue {
|
|
223
|
+
push(event) {
|
|
224
|
+
reportBuilderEvent(event);
|
|
225
|
+
super.push(event);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
function briefInput(input) {
|
|
229
|
+
if (!input || typeof input !== "object")
|
|
230
|
+
return undefined;
|
|
231
|
+
const value = input;
|
|
232
|
+
const detail = value.command ?? value.file_path ?? value.path ?? value.pattern ?? value.query;
|
|
233
|
+
return detail === undefined ? undefined : clean(String(detail), 100);
|
|
234
|
+
}
|
|
235
|
+
function clean(value, maximum) {
|
|
236
|
+
return value.replace(/[\r\n\t]+/g, " ").replace(/\s+/g, " ").trim().slice(0, maximum);
|
|
237
|
+
}
|
|
238
|
+
function formatDuration(milliseconds) {
|
|
239
|
+
const seconds = Math.max(0, Math.floor(milliseconds / 1_000));
|
|
240
|
+
if (seconds < 60)
|
|
241
|
+
return `${seconds}s`;
|
|
242
|
+
const minutes = Math.floor(seconds / 60);
|
|
243
|
+
return `${minutes}m${String(seconds % 60).padStart(2, "0")}s`;
|
|
244
|
+
}
|
|
@@ -9,6 +9,12 @@ import type { BuilderAdapter, BuilderAdapterOptions, BuilderEvent, CompactResult
|
|
|
9
9
|
*/
|
|
10
10
|
export declare function buildClaudeQueryOptions(opts: Omit<BuilderAdapterOptions, "permission">): Record<string, unknown>;
|
|
11
11
|
export declare function permissionDecisionToClaudeResult(decision: PermissionDecision, toolUseID?: string): PermissionResult;
|
|
12
|
+
export declare function claudeApiRetryEvent(message: {
|
|
13
|
+
error: string;
|
|
14
|
+
attempt: number;
|
|
15
|
+
max_retries: number;
|
|
16
|
+
retry_delay_ms: number;
|
|
17
|
+
}): BuilderEvent;
|
|
12
18
|
/**
|
|
13
19
|
* Drives Claude Code through the Claude Agent SDK in streaming-input mode:
|
|
14
20
|
* one persistent session, follow-up turns pushed as user messages, permission
|
|
@@ -36,6 +42,7 @@ export declare class ClaudeAdapter implements BuilderAdapter {
|
|
|
36
42
|
private pump;
|
|
37
43
|
private handle;
|
|
38
44
|
sendTurn(text: string): Promise<TurnResult>;
|
|
45
|
+
private sendTurnInternal;
|
|
39
46
|
private captureStderr;
|
|
40
47
|
sessionId(): string | undefined;
|
|
41
48
|
compact(): Promise<CompactResult>;
|
package/dist/adapters/claude.js
CHANGED
|
@@ -10,6 +10,7 @@ export async function requireClaudeSDK() {
|
|
|
10
10
|
}
|
|
11
11
|
}
|
|
12
12
|
import { AsyncQueue } from "../util/asyncQueue.js";
|
|
13
|
+
import { BuilderEventQueue, withActivityPhase } from "../activity.js";
|
|
13
14
|
import { normalizeRuntimeErrorText } from "../runtimeAuth.js";
|
|
14
15
|
import { classifyClaudeSdkFailure, resolveExecutablePath, sanitizeDiagnostics, } from "../runtimeReadiness.js";
|
|
15
16
|
/**
|
|
@@ -53,6 +54,17 @@ export function permissionDecisionToClaudeResult(decision, toolUseID) {
|
|
|
53
54
|
toolUseID,
|
|
54
55
|
};
|
|
55
56
|
}
|
|
57
|
+
export function claudeApiRetryEvent(message) {
|
|
58
|
+
return {
|
|
59
|
+
kind: "retry",
|
|
60
|
+
provider: "claude",
|
|
61
|
+
reason: message.error,
|
|
62
|
+
attempt: message.attempt,
|
|
63
|
+
maximum: message.max_retries,
|
|
64
|
+
delayMs: message.retry_delay_ms,
|
|
65
|
+
managedBy: "provider",
|
|
66
|
+
};
|
|
67
|
+
}
|
|
56
68
|
/**
|
|
57
69
|
* Drives Claude Code through the Claude Agent SDK in streaming-input mode:
|
|
58
70
|
* one persistent session, follow-up turns pushed as user messages, permission
|
|
@@ -62,7 +74,7 @@ export class ClaudeAdapter {
|
|
|
62
74
|
opts;
|
|
63
75
|
agent = "claude";
|
|
64
76
|
inbox = new AsyncQueue();
|
|
65
|
-
eventQueue = new
|
|
77
|
+
eventQueue = new BuilderEventQueue();
|
|
66
78
|
query;
|
|
67
79
|
abort = new AbortController();
|
|
68
80
|
pumpDone;
|
|
@@ -182,6 +194,19 @@ export class ClaudeAdapter {
|
|
|
182
194
|
this.structuredError = msg.error;
|
|
183
195
|
this.apiErrorStatus = msg.error_status;
|
|
184
196
|
this.turnSignals.push(`API retry ${msg.attempt}/${msg.max_retries}: ${msg.error}${msg.error_status === null ? "" : ` (HTTP ${msg.error_status})`}`);
|
|
197
|
+
this.eventQueue.push(claudeApiRetryEvent(msg));
|
|
198
|
+
}
|
|
199
|
+
else if (msg.type === "tool_progress") {
|
|
200
|
+
this.eventQueue.push({ kind: "activity", provider: "claude", state: "running tool", detail: `${msg.tool_name} (${Math.floor(msg.elapsed_time_seconds)}s)`, transient: true });
|
|
201
|
+
}
|
|
202
|
+
else if (msg.type === "system") {
|
|
203
|
+
const system = msg;
|
|
204
|
+
if (system.subtype === "task_started" || system.subtype === "task_progress" || system.subtype === "task_updated") {
|
|
205
|
+
this.eventQueue.push({ kind: "activity", provider: "claude", state: "working on task", detail: typeof system.summary === "string" ? system.summary : undefined, transient: system.subtype === "task_progress" });
|
|
206
|
+
}
|
|
207
|
+
else if (system.subtype === "session_state_changed") {
|
|
208
|
+
this.eventQueue.push({ kind: "activity", provider: "claude", state: "provider working", detail: typeof system.state === "string" ? system.state : undefined });
|
|
209
|
+
}
|
|
185
210
|
}
|
|
186
211
|
else if (msg.type === "result") {
|
|
187
212
|
if ("api_error_status" in msg)
|
|
@@ -221,12 +246,16 @@ export class ClaudeAdapter {
|
|
|
221
246
|
}
|
|
222
247
|
}
|
|
223
248
|
sendTurn(text) {
|
|
249
|
+
return withActivityPhase(`Claude ${activityPhase(this.opts.runtimePhase)}`, () => this.sendTurnInternal(text));
|
|
250
|
+
}
|
|
251
|
+
sendTurnInternal(text) {
|
|
224
252
|
if (this.closed)
|
|
225
253
|
return Promise.reject(new Error("builder is closed"));
|
|
226
254
|
if (this.pending) {
|
|
227
255
|
return Promise.reject(new Error("a turn is already in progress"));
|
|
228
256
|
}
|
|
229
257
|
return new Promise((resolve, reject) => {
|
|
258
|
+
this.eventQueue.push({ kind: "activity", state: "starting Claude turn", provider: "claude", model: this.opts.model });
|
|
230
259
|
this.turnSignals = [];
|
|
231
260
|
this.structuredError = undefined;
|
|
232
261
|
this.apiErrorStatus = undefined;
|
|
@@ -293,6 +322,17 @@ export class ClaudeAdapter {
|
|
|
293
322
|
await this.pumpDone.catch(() => { });
|
|
294
323
|
}
|
|
295
324
|
}
|
|
325
|
+
function activityPhase(phase) {
|
|
326
|
+
if (phase === "planning")
|
|
327
|
+
return "planning";
|
|
328
|
+
if (phase === "ticket-population")
|
|
329
|
+
return "populating tickets";
|
|
330
|
+
if (phase === "qa")
|
|
331
|
+
return "reviewing with QA";
|
|
332
|
+
if (phase === "uninstaller")
|
|
333
|
+
return "planning uninstall";
|
|
334
|
+
return "building";
|
|
335
|
+
}
|
|
296
336
|
function formatClaudeFailure(failure) {
|
|
297
337
|
const environmentNames = Object.keys(process.env)
|
|
298
338
|
.filter((name) => /^(ANTHROPIC|CLAUDE|HTTP_PROXY|HTTPS_PROXY|NO_PROXY|SSL_CERT_FILE|NODE_EXTRA_CA_CERTS)(_|$)/i.test(name))
|
package/dist/adapters/codex.d.ts
CHANGED
|
@@ -27,6 +27,7 @@ export declare class CodexAdapter implements BuilderAdapter {
|
|
|
27
27
|
/** Deprecated fixture helper. Runtime execution uses only app-server. */
|
|
28
28
|
buildArgs(instruction: string): string[];
|
|
29
29
|
sendTurn(instruction: string): Promise<TurnResult>;
|
|
30
|
+
private sendTurnInternal;
|
|
30
31
|
compact(): Promise<CompactResult>;
|
|
31
32
|
contextUsage(): Promise<ContextUsage | undefined>;
|
|
32
33
|
switchSettings(settings: ProviderSettingSwitch): Promise<CompactResult>;
|
package/dist/adapters/codex.js
CHANGED
|
@@ -3,7 +3,7 @@ import { existsSync, readFileSync } from "node:fs";
|
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { createInterface } from "node:readline";
|
|
5
5
|
import { loadSkill } from "special-agents";
|
|
6
|
-
import {
|
|
6
|
+
import { BuilderEventQueue, withActivityPhase } from "../activity.js";
|
|
7
7
|
import { normalizeRuntimeErrorText } from "../runtimeAuth.js";
|
|
8
8
|
/** Compatibility parser for recorded pre-app-server JSONL fixtures. */
|
|
9
9
|
export function parseCodexLine(raw) {
|
|
@@ -25,7 +25,7 @@ export function parseCodexLine(raw) {
|
|
|
25
25
|
export class CodexAdapter {
|
|
26
26
|
opts;
|
|
27
27
|
agent = "codex";
|
|
28
|
-
eventQueue = new
|
|
28
|
+
eventQueue = new BuilderEventQueue();
|
|
29
29
|
process;
|
|
30
30
|
nextId = 1;
|
|
31
31
|
pending = new Map();
|
|
@@ -60,8 +60,12 @@ export class CodexAdapter {
|
|
|
60
60
|
return args;
|
|
61
61
|
}
|
|
62
62
|
async sendTurn(instruction) {
|
|
63
|
+
return withActivityPhase(`Codex ${activityPhase(this.opts.runtimePhase)}`, () => this.sendTurnInternal(instruction));
|
|
64
|
+
}
|
|
65
|
+
async sendTurnInternal(instruction) {
|
|
63
66
|
if (this.closed)
|
|
64
67
|
throw new Error("builder is closed");
|
|
68
|
+
this.eventQueue.push({ kind: "activity", state: "starting Codex turn", provider: "codex", model: this.opts.model });
|
|
65
69
|
try {
|
|
66
70
|
await this.ensureThread();
|
|
67
71
|
this.activeText = [];
|
|
@@ -198,7 +202,13 @@ export class CodexAdapter {
|
|
|
198
202
|
if (!message.method)
|
|
199
203
|
return;
|
|
200
204
|
const params = message.params ?? {};
|
|
201
|
-
if (message.method === "item/
|
|
205
|
+
if (message.method === "item/started") {
|
|
206
|
+
const item = params.item;
|
|
207
|
+
const activity = codexItemActivity(item);
|
|
208
|
+
if (activity)
|
|
209
|
+
this.eventQueue.push({ kind: "activity", provider: "codex", ...activity });
|
|
210
|
+
}
|
|
211
|
+
else if (message.method === "item/completed") {
|
|
202
212
|
const item = params.item;
|
|
203
213
|
if (item?.type === "agentMessage" && typeof item.text === "string") {
|
|
204
214
|
this.activeText.push(item.text);
|
|
@@ -206,6 +216,19 @@ export class CodexAdapter {
|
|
|
206
216
|
}
|
|
207
217
|
else if (item?.type === "commandExecution")
|
|
208
218
|
this.eventQueue.push({ kind: "tool", name: "command_execution", input: { command: item.command } });
|
|
219
|
+
else {
|
|
220
|
+
const activity = codexItemActivity(item, true);
|
|
221
|
+
if (activity)
|
|
222
|
+
this.eventQueue.push({ kind: "activity", provider: "codex", ...activity });
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
else if (message.method === "turn/plan/updated") {
|
|
226
|
+
const plan = params.plan;
|
|
227
|
+
const active = plan?.find((step) => step.status === "inProgress") ?? plan?.at(-1);
|
|
228
|
+
this.eventQueue.push({ kind: "activity", provider: "codex", state: "following plan", detail: typeof active?.step === "string" ? active.step : undefined });
|
|
229
|
+
}
|
|
230
|
+
else if (message.method.includes("reasoning") || message.method === "thread/status/changed") {
|
|
231
|
+
this.eventQueue.push({ kind: "activity", provider: "codex", state: message.method.includes("reasoning") ? "reasoning" : "provider working", transient: message.method.includes("Delta") });
|
|
209
232
|
}
|
|
210
233
|
else if (message.method === "thread/tokenUsage/updated") {
|
|
211
234
|
const tokenUsage = params.tokenUsage;
|
|
@@ -215,8 +238,14 @@ export class CodexAdapter {
|
|
|
215
238
|
this.usage = { used, maximum, ...(maximum ? { percentage: used / maximum * 100 } : {}) };
|
|
216
239
|
this.eventQueue.push({ kind: "context-usage", ...this.usage });
|
|
217
240
|
}
|
|
218
|
-
else if (message.method === "error")
|
|
219
|
-
|
|
241
|
+
else if (message.method === "error") {
|
|
242
|
+
const error = params.error;
|
|
243
|
+
const reason = String(error?.message ?? params.message ?? "Codex app-server error");
|
|
244
|
+
if (params.willRetry === true)
|
|
245
|
+
this.eventQueue.push({ kind: "retry", provider: "codex", reason, managedBy: "provider" });
|
|
246
|
+
else
|
|
247
|
+
this.eventQueue.push({ kind: "error", message: reason });
|
|
248
|
+
}
|
|
220
249
|
const waiters = this.notificationWaiters.get(message.method) ?? [];
|
|
221
250
|
const remaining = [];
|
|
222
251
|
for (const waiter of waiters)
|
|
@@ -248,6 +277,37 @@ export class CodexAdapter {
|
|
|
248
277
|
return blocks.length ? ["# Preloaded Skills", "Use the following skills for this run.", ...blocks].join("\n\n") : undefined;
|
|
249
278
|
}
|
|
250
279
|
}
|
|
280
|
+
function activityPhase(phase) {
|
|
281
|
+
if (phase === "planning")
|
|
282
|
+
return "planning";
|
|
283
|
+
if (phase === "ticket-population")
|
|
284
|
+
return "populating tickets";
|
|
285
|
+
if (phase === "qa")
|
|
286
|
+
return "reviewing with QA";
|
|
287
|
+
if (phase === "uninstaller")
|
|
288
|
+
return "planning uninstall";
|
|
289
|
+
return "building";
|
|
290
|
+
}
|
|
291
|
+
function codexItemActivity(item, completed = false) {
|
|
292
|
+
if (!item)
|
|
293
|
+
return undefined;
|
|
294
|
+
const state = completed ? "completed" : "running";
|
|
295
|
+
if (item.type === "commandExecution")
|
|
296
|
+
return { state: `${state} command`, detail: typeof item.command === "string" ? item.command : undefined };
|
|
297
|
+
if (item.type === "fileChange")
|
|
298
|
+
return { state: `${state} file changes` };
|
|
299
|
+
if (item.type === "mcpToolCall")
|
|
300
|
+
return { state: `${state} MCP tool`, detail: String(item.tool ?? item.name ?? "") || undefined };
|
|
301
|
+
if (item.type === "dynamicToolCall")
|
|
302
|
+
return { state: `${state} tool`, detail: String(item.tool ?? item.name ?? "") || undefined };
|
|
303
|
+
if (item.type === "webSearch")
|
|
304
|
+
return { state: `${state} web search`, detail: typeof item.query === "string" ? item.query : undefined };
|
|
305
|
+
if (item.type === "contextCompaction")
|
|
306
|
+
return { state: completed ? "context compacted" : "compacting context" };
|
|
307
|
+
if (item.type === "agentMessage")
|
|
308
|
+
return { state: completed ? "received response" : "writing response" };
|
|
309
|
+
return undefined;
|
|
310
|
+
}
|
|
251
311
|
function loadSkillMarkdown(cwd, skill) {
|
|
252
312
|
const projectPath = join(cwd, ".agents", "skills", skill, "SKILL.md");
|
|
253
313
|
if (existsSync(projectPath))
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { isCancel, log, select } from "@clack/prompts";
|
|
2
2
|
import { AsyncQueue } from "../util/asyncQueue.js";
|
|
3
|
+
import { pauseActivityForInput, reportBuilderEvent } from "../activity.js";
|
|
3
4
|
/** Adds interactive recovery around structured runtime turn failures. */
|
|
4
5
|
export class RecoveringAdapter {
|
|
5
6
|
opts;
|
|
@@ -25,11 +26,16 @@ export class RecoveringAdapter {
|
|
|
25
26
|
const otherRuntime = this.runtime === "claude" ? "codex" : "claude";
|
|
26
27
|
const choice = this.opts.choose
|
|
27
28
|
? await this.opts.choose(result, this.runtime, otherRuntime)
|
|
28
|
-
: await promptTurnRecovery(result, this.opts.label, this.runtime, otherRuntime, this.opts.allowSwitch);
|
|
29
|
+
: await pauseActivityForInput(() => promptTurnRecovery(result, this.opts.label, this.runtime, otherRuntime, this.opts.allowSwitch));
|
|
29
30
|
if (choice === "cancel")
|
|
30
31
|
return result;
|
|
31
32
|
if (choice === "switch" && !this.opts.allowSwitch)
|
|
32
33
|
return result;
|
|
34
|
+
const recoveryEvent = choice === "retry"
|
|
35
|
+
? { kind: "retry", provider: this.runtime, reason: `${this.opts.label} failed`, managedBy: "rafi" }
|
|
36
|
+
: { kind: "activity", provider: otherRuntime, state: `switching to ${otherRuntime}`, detail: "starting a fresh provider session" };
|
|
37
|
+
this.eventQueue.push(recoveryEvent);
|
|
38
|
+
reportBuilderEvent(recoveryEvent);
|
|
33
39
|
const nextRuntime = choice === "switch" ? otherRuntime : this.runtime;
|
|
34
40
|
const resumeSessionId = choice === "retry" ? this.adapter.sessionId() : undefined;
|
|
35
41
|
await this.replaceAdapter(nextRuntime, resumeSessionId);
|
package/dist/adapters/types.d.ts
CHANGED
|
@@ -59,6 +59,21 @@ export type BuilderEvent = {
|
|
|
59
59
|
kind: "tool";
|
|
60
60
|
name: string;
|
|
61
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";
|
|
62
77
|
} | {
|
|
63
78
|
kind: "turn-complete";
|
|
64
79
|
result: TurnResult;
|
package/dist/agentRun.d.ts
CHANGED
|
@@ -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
|
@@ -115,7 +115,11 @@ export async function createRoleBuilder(opts) {
|
|
|
115
115
|
runtimePhase: phaseForRole(opts.role),
|
|
116
116
|
model,
|
|
117
117
|
resumeSessionId,
|
|
118
|
-
permission: createPermissionHandler(policy, log, {
|
|
118
|
+
permission: createPermissionHandler(policy, log, {
|
|
119
|
+
interactive,
|
|
120
|
+
onAnsweredQuestion: opts.onAnsweredQuestion,
|
|
121
|
+
onProviderQuestion: opts.onProviderQuestion,
|
|
122
|
+
}),
|
|
119
123
|
effort,
|
|
120
124
|
fast: opts.fast ?? saved?.fast,
|
|
121
125
|
sandboxMode: opts.sandboxMode,
|