ai-foreman 1.5.0 → 1.6.1

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 (59) hide show
  1. package/README.md +8 -0
  2. package/dist/adapters/claude.d.ts +6 -0
  3. package/dist/adapters/claude.js +84 -9
  4. package/dist/adapters/recovering.d.ts +30 -0
  5. package/dist/adapters/recovering.js +83 -0
  6. package/dist/adapters/types.d.ts +13 -0
  7. package/dist/adapters/types.js +0 -3
  8. package/dist/agentRun.d.ts +3 -2
  9. package/dist/agentRun.js +89 -24
  10. package/dist/branch/git.d.ts +3 -0
  11. package/dist/branch/git.js +31 -5
  12. package/dist/branch/github.d.ts +2 -2
  13. package/dist/branch/github.js +2 -2
  14. package/dist/branch/gitlab.d.ts +3 -2
  15. package/dist/branch/gitlab.js +10 -4
  16. package/dist/branch/planner.d.ts +3 -1
  17. package/dist/branch/planner.js +24 -2
  18. package/dist/branch/runner.d.ts +10 -1
  19. package/dist/branch/runner.js +41 -13
  20. package/dist/branch/types.d.ts +4 -0
  21. package/dist/buildRuns.d.ts +35 -0
  22. package/dist/buildRuns.js +149 -0
  23. package/dist/cli/doctor.js +91 -4
  24. package/dist/cli/runtimeAuthPrompt.d.ts +3 -1
  25. package/dist/cli/runtimeAuthPrompt.js +25 -6
  26. package/dist/cli/start.js +208 -17
  27. package/dist/cli/status.d.ts +2 -0
  28. package/dist/cli/status.js +71 -55
  29. package/dist/cli/tickets.js +57 -10
  30. package/dist/foreman.d.ts +3 -1
  31. package/dist/foreman.js +52 -5
  32. package/dist/index.js +0 -0
  33. package/dist/interviews.d.ts +26 -4
  34. package/dist/interviews.js +71 -4
  35. package/dist/log.d.ts +1 -1
  36. package/dist/roles.d.ts +11 -0
  37. package/dist/roles.js +8 -0
  38. package/dist/runtimeAuth.d.ts +1 -1
  39. package/dist/runtimeAuth.js +10 -31
  40. package/dist/runtimeReadiness.d.ts +17 -0
  41. package/dist/runtimeReadiness.js +195 -0
  42. package/dist/ticketPlanning.d.ts +83 -0
  43. package/dist/ticketPlanning.js +287 -0
  44. package/dist/tickets/commands.js +16 -0
  45. package/dist/tickets/delivery.d.ts +38 -0
  46. package/dist/tickets/delivery.js +152 -0
  47. package/dist/tickets/importer.js +4 -0
  48. package/dist/tickets/renderMarkdown.js +9 -0
  49. package/dist/tickets/setupConfig.d.ts +8 -0
  50. package/dist/tickets/setupConfig.js +47 -2
  51. package/dist/tickets/sourceFetch.d.ts +24 -0
  52. package/dist/tickets/sourceFetch.js +246 -0
  53. package/dist/tickets/stateDb.d.ts +10 -0
  54. package/dist/tickets/stateDb.js +24 -0
  55. package/dist/tickets/ticketLoader.js +18 -0
  56. package/dist/tickets/ticketSchema.d.ts +93 -0
  57. package/dist/tickets/ticketSchema.js +17 -0
  58. package/dist/tickets/validate.js +12 -0
  59. package/package.json +33 -2
package/README.md CHANGED
@@ -47,6 +47,14 @@ Requires:
47
47
 
48
48
  - Node.js 20 or newer.
49
49
 
50
+ ### Claude runtime and enterprise authentication
51
+
52
+ Foreman first resolves and probes the system `claude` command, then passes that exact absolute executable path to the Claude Agent SDK. It does not use the SDK's bundled Claude executable. SDK sessions inherit the current environment and load Claude's user, project, and local settings; organization-managed policy remains applied by Claude Code. This preserves enterprise SSO such as `/login-okta`, managed settings, and proxy/certificate configuration.
53
+
54
+ The SDK package is an optional dependency of `ai-foreman`. Do not install `@anthropic-ai/claude-agent-sdk` into the application being built unless that application independently uses it.
55
+
56
+ Run `ai-foreman doctor .` to see the selected executable, SDK-wrapper status, settings sources, and relevant environment-variable names. Add `--live-claude` for a bounded, no-tools request through the real adapter path. That opt-in check uses account quota.
57
+
50
58
  ## Use
51
59
 
52
60
  - Check the target project.
@@ -13,6 +13,7 @@ export declare function buildClaudeQueryOptions(opts: Omit<BuilderAdapterOptions
13
13
  * requests routed to the foreman's handler via `canUseTool`.
14
14
  */
15
15
  export declare class ClaudeAdapter implements BuilderAdapter {
16
+ private readonly opts;
16
17
  readonly agent: "claude";
17
18
  private readonly inbox;
18
19
  private readonly eventQueue;
@@ -20,6 +21,10 @@ export declare class ClaudeAdapter implements BuilderAdapter {
20
21
  private readonly abort;
21
22
  private readonly pumpDone;
22
23
  private _sessionId?;
24
+ private readonly stderrChunks;
25
+ private turnSignals;
26
+ private structuredError?;
27
+ private apiErrorStatus?;
23
28
  private pending?;
24
29
  private closed;
25
30
  static create(opts: BuilderAdapterOptions): Promise<ClaudeAdapter>;
@@ -28,6 +33,7 @@ export declare class ClaudeAdapter implements BuilderAdapter {
28
33
  private pump;
29
34
  private handle;
30
35
  sendTurn(text: string): Promise<TurnResult>;
36
+ private captureStderr;
31
37
  sessionId(): string | undefined;
32
38
  events(): AsyncIterable<BuilderEvent>;
33
39
  close(): Promise<void>;
@@ -4,14 +4,14 @@ export async function requireClaudeSDK() {
4
4
  return await import("@anthropic-ai/claude-agent-sdk");
5
5
  }
6
6
  catch {
7
- throw new Error("Claude Agent SDK not installed.\n" +
8
- "To use Claude Code as your agent runtime, run:\n" +
9
- " npm install @anthropic-ai/claude-agent-sdk\n" +
10
- "Or use Codex only: ai-foreman start --agent codex");
7
+ throw new Error("Rafi's Claude Agent SDK dependency is not installed.\n" +
8
+ "Reinstall Rafi/ai-foreman with optional dependencies enabled; do not install the SDK into the target application.\n" +
9
+ "Or use Codex for this run with --agent codex.");
11
10
  }
12
11
  }
13
12
  import { AsyncQueue } from "../util/asyncQueue.js";
14
13
  import { normalizeRuntimeErrorText } from "../runtimeAuth.js";
14
+ import { classifyClaudeSdkFailure, resolveExecutablePath, sanitizeDiagnostics, } from "../runtimeReadiness.js";
15
15
  /**
16
16
  * Pure function: build the `options` object passed to `query()`.
17
17
  * Extracted so tests can assert on the shape without making a live SDK call.
@@ -20,18 +20,20 @@ import { normalizeRuntimeErrorText } from "../runtimeAuth.js";
20
20
  export function buildClaudeQueryOptions(opts) {
21
21
  const base = {
22
22
  cwd: opts.cwd,
23
+ pathToClaudeCodeExecutable: opts.runtimeExecutable,
24
+ env: { ...process.env },
23
25
  model: opts.model,
24
26
  resume: opts.resumeSessionId,
25
27
  permissionMode: "acceptEdits",
26
28
  effort: opts.effort,
27
29
  extraArgs: opts.fast ? { fast: null } : undefined,
30
+ settingSources: ["user", "project", "local"],
28
31
  };
29
32
  if (opts.systemPromptAppend) {
30
33
  base.systemPrompt = { type: "preset", preset: "claude_code", append: opts.systemPromptAppend };
31
34
  }
32
35
  if (opts.skills !== undefined) {
33
36
  base.skills = opts.skills;
34
- base.settingSources = ["project"];
35
37
  }
36
38
  return base;
37
39
  }
@@ -41,6 +43,7 @@ export function buildClaudeQueryOptions(opts) {
41
43
  * requests routed to the foreman's handler via `canUseTool`.
42
44
  */
43
45
  export class ClaudeAdapter {
46
+ opts;
44
47
  agent = "claude";
45
48
  inbox = new AsyncQueue();
46
49
  eventQueue = new AsyncQueue();
@@ -48,12 +51,20 @@ export class ClaudeAdapter {
48
51
  abort = new AbortController();
49
52
  pumpDone;
50
53
  _sessionId;
54
+ stderrChunks = [];
55
+ turnSignals = [];
56
+ structuredError;
57
+ apiErrorStatus;
51
58
  pending;
52
59
  closed = false;
53
60
  static async create(opts) {
54
61
  try {
62
+ const runtimeExecutable = opts.runtimeExecutable ?? resolveExecutablePath("claude");
63
+ if (!runtimeExecutable) {
64
+ throw new Error("Claude Code executable not found on PATH. Install your organization-approved Claude Code CLI, then retry.");
65
+ }
55
66
  const { query } = await requireClaudeSDK();
56
- return new ClaudeAdapter(opts, query);
67
+ return new ClaudeAdapter({ ...opts, runtimeExecutable }, query);
57
68
  }
58
69
  catch (err) {
59
70
  const message = err instanceof Error ? err.message : String(err);
@@ -62,11 +73,13 @@ export class ClaudeAdapter {
62
73
  }
63
74
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
64
75
  constructor(opts, query) {
76
+ this.opts = opts;
65
77
  this.query = query({
66
78
  prompt: this.inbox,
67
79
  options: {
68
80
  ...buildClaudeQueryOptions(opts),
69
81
  abortController: this.abort,
82
+ stderr: (data) => this.captureStderr(data),
70
83
  canUseTool: async (toolName, input) => {
71
84
  const decision = await opts.permission({ toolName, input });
72
85
  return decision.behavior === "allow"
@@ -106,6 +119,10 @@ export class ClaudeAdapter {
106
119
  this._sessionId = msg.session_id;
107
120
  }
108
121
  if (msg.type === "assistant") {
122
+ if (msg.error) {
123
+ this.structuredError = msg.error;
124
+ this.turnSignals.push(`assistant error: ${msg.error}`);
125
+ }
109
126
  for (const block of msg.message.content) {
110
127
  if (block.type === "text" && block.text) {
111
128
  this.eventQueue.push({ kind: "text", text: block.text });
@@ -119,23 +136,52 @@ export class ClaudeAdapter {
119
136
  }
120
137
  }
121
138
  }
139
+ else if (msg.type === "auth_status") {
140
+ if (msg.error)
141
+ this.turnSignals.push(`auth status: ${msg.error}`);
142
+ if (msg.output.length > 0)
143
+ this.turnSignals.push(...msg.output.map((line) => `auth: ${line}`));
144
+ }
145
+ else if (msg.type === "system" && msg.subtype === "api_retry") {
146
+ this.structuredError = msg.error;
147
+ this.apiErrorStatus = msg.error_status;
148
+ this.turnSignals.push(`API retry ${msg.attempt}/${msg.max_retries}: ${msg.error}${msg.error_status === null ? "" : ` (HTTP ${msg.error_status})`}`);
149
+ }
122
150
  else if (msg.type === "result") {
151
+ if ("api_error_status" in msg)
152
+ this.apiErrorStatus = msg.api_error_status;
123
153
  const text = "result" in msg && typeof msg.result === "string"
124
154
  ? msg.result
125
155
  : "errors" in msg
126
156
  ? msg.errors.join("; ")
127
157
  : "";
158
+ const rawDiagnostics = sanitizeDiagnostics([
159
+ text,
160
+ ...this.turnSignals,
161
+ ...this.stderrChunks,
162
+ ].filter(Boolean).join("\n"));
163
+ const failure = msg.is_error ? {
164
+ runtime: "claude",
165
+ phase: this.opts.runtimePhase ?? "builder",
166
+ category: classifyClaudeSdkFailure(this.structuredError, this.apiErrorStatus, rawDiagnostics),
167
+ executable: this.opts.runtimeExecutable ?? "claude",
168
+ cwd: this.opts.cwd,
169
+ diagnostics: rawDiagnostics,
170
+ } : undefined;
128
171
  const result = {
129
- text: msg.is_error
130
- ? normalizeRuntimeErrorText("claude", text, null, "builder turn")
131
- : text,
172
+ text: failure ? formatClaudeFailure(failure) : text,
132
173
  isError: msg.is_error,
133
174
  numTurns: msg.num_turns,
134
175
  costUsd: msg.total_cost_usd,
176
+ failure,
135
177
  };
136
178
  this.eventQueue.push({ kind: "turn-complete", result });
137
179
  this.pending?.resolve(result);
138
180
  this.pending = undefined;
181
+ this.turnSignals = [];
182
+ this.structuredError = undefined;
183
+ this.apiErrorStatus = undefined;
184
+ this.stderrChunks.length = 0;
139
185
  }
140
186
  }
141
187
  sendTurn(text) {
@@ -145,6 +191,9 @@ export class ClaudeAdapter {
145
191
  return Promise.reject(new Error("a turn is already in progress"));
146
192
  }
147
193
  return new Promise((resolve, reject) => {
194
+ this.turnSignals = [];
195
+ this.structuredError = undefined;
196
+ this.apiErrorStatus = undefined;
148
197
  this.pending = { resolve, reject };
149
198
  this.inbox.push({
150
199
  type: "user",
@@ -153,6 +202,11 @@ export class ClaudeAdapter {
153
202
  });
154
203
  });
155
204
  }
205
+ captureStderr(data) {
206
+ this.stderrChunks.push(data);
207
+ while (this.stderrChunks.join("").length > 8 * 1024)
208
+ this.stderrChunks.shift();
209
+ }
156
210
  sessionId() {
157
211
  return this._sessionId;
158
212
  }
@@ -174,3 +228,24 @@ export class ClaudeAdapter {
174
228
  await this.pumpDone.catch(() => { });
175
229
  }
176
230
  }
231
+ function formatClaudeFailure(failure) {
232
+ const environmentNames = Object.keys(process.env)
233
+ .filter((name) => /^(ANTHROPIC|CLAUDE|HTTP_PROXY|HTTPS_PROXY|NO_PROXY|SSL_CERT_FILE|NODE_EXTRA_CA_CERTS)(_|$)/i.test(name))
234
+ .sort();
235
+ const lines = [
236
+ `Claude failed during ${failure.phase} (${failure.category}).`,
237
+ `Executable: ${failure.executable}`,
238
+ `Working directory: ${failure.cwd}`,
239
+ "Settings: user, project, local, and managed policy",
240
+ `Relevant environment variables set: ${environmentNames.length > 0 ? environmentNames.join(", ") : "none"}`,
241
+ ];
242
+ if (failure.category === "authentication") {
243
+ lines.push("Authenticate using the Claude Code login method approved by your organization, then verify the exact executable with `claude -p \"Return exactly OK\"`.");
244
+ }
245
+ else if (failure.category === "network") {
246
+ lines.push("Check the organization proxy/CA configuration, including HTTPS_PROXY, NODE_EXTRA_CA_CERTS, and CLAUDE_CODE_CERT_STORE where applicable.");
247
+ }
248
+ if (failure.diagnostics)
249
+ lines.push("", "Runtime diagnostics:", failure.diagnostics);
250
+ return lines.join("\n");
251
+ }
@@ -0,0 +1,30 @@
1
+ import type { AgentRuntime } from "../runtimeAuth.js";
2
+ import type { BuilderAdapter, BuilderEvent, TurnResult } from "./types.js";
3
+ export type TurnRecoveryChoice = "retry" | "switch" | "cancel";
4
+ export interface RecoveringAdapterOptions {
5
+ initial: BuilderAdapter;
6
+ runtime: AgentRuntime;
7
+ enabled: boolean;
8
+ allowSwitch: boolean;
9
+ label: string;
10
+ recreate: (runtime: AgentRuntime, resumeSessionId?: string) => Promise<BuilderAdapter>;
11
+ choose?: (result: TurnResult, runtime: AgentRuntime, otherRuntime: AgentRuntime) => Promise<TurnRecoveryChoice>;
12
+ onRuntimeChange?: (runtime: AgentRuntime) => void;
13
+ }
14
+ /** Adds interactive recovery around structured runtime turn failures. */
15
+ export declare class RecoveringAdapter implements BuilderAdapter {
16
+ private readonly opts;
17
+ private adapter;
18
+ private runtime;
19
+ private readonly eventQueue;
20
+ private eventPump;
21
+ private closed;
22
+ constructor(opts: RecoveringAdapterOptions);
23
+ get agent(): "claude" | "codex";
24
+ sendTurn(text: string): Promise<TurnResult>;
25
+ sessionId(): string | undefined;
26
+ events(): AsyncIterable<BuilderEvent>;
27
+ close(): Promise<void>;
28
+ private replaceAdapter;
29
+ private forwardEvents;
30
+ }
@@ -0,0 +1,83 @@
1
+ import { isCancel, log, select } from "@clack/prompts";
2
+ import { AsyncQueue } from "../util/asyncQueue.js";
3
+ /** Adds interactive recovery around structured runtime turn failures. */
4
+ export class RecoveringAdapter {
5
+ opts;
6
+ adapter;
7
+ runtime;
8
+ eventQueue = new AsyncQueue();
9
+ eventPump;
10
+ closed = false;
11
+ constructor(opts) {
12
+ this.opts = opts;
13
+ this.adapter = opts.initial;
14
+ this.runtime = opts.runtime;
15
+ this.eventPump = this.forwardEvents(this.adapter);
16
+ }
17
+ get agent() {
18
+ return this.adapter.agent;
19
+ }
20
+ async sendTurn(text) {
21
+ while (true) {
22
+ const result = await this.adapter.sendTurn(text);
23
+ if (!this.opts.enabled || !result.isError || !result.failure)
24
+ return result;
25
+ const otherRuntime = this.runtime === "claude" ? "codex" : "claude";
26
+ const choice = this.opts.choose
27
+ ? await this.opts.choose(result, this.runtime, otherRuntime)
28
+ : await promptTurnRecovery(result, this.opts.label, this.runtime, otherRuntime, this.opts.allowSwitch);
29
+ if (choice === "cancel")
30
+ return result;
31
+ if (choice === "switch" && !this.opts.allowSwitch)
32
+ return result;
33
+ const nextRuntime = choice === "switch" ? otherRuntime : this.runtime;
34
+ const resumeSessionId = choice === "retry" ? this.adapter.sessionId() : undefined;
35
+ await this.replaceAdapter(nextRuntime, resumeSessionId);
36
+ if (choice === "switch") {
37
+ log.info(`Using ${nextRuntime} for the rest of this run. This is a fresh provider session; conversational continuity was not transferred.`);
38
+ }
39
+ }
40
+ }
41
+ sessionId() {
42
+ return this.adapter.sessionId();
43
+ }
44
+ events() {
45
+ return this.eventQueue;
46
+ }
47
+ async close() {
48
+ if (this.closed)
49
+ return;
50
+ this.closed = true;
51
+ await this.adapter.close().catch(() => { });
52
+ await this.eventPump.catch(() => { });
53
+ this.eventQueue.close();
54
+ }
55
+ async replaceAdapter(runtime, resumeSessionId) {
56
+ await this.adapter.close().catch(() => { });
57
+ await this.eventPump.catch(() => { });
58
+ this.adapter = await this.opts.recreate(runtime, resumeSessionId);
59
+ this.runtime = runtime;
60
+ this.opts.onRuntimeChange?.(runtime);
61
+ this.eventPump = this.forwardEvents(this.adapter);
62
+ }
63
+ async forwardEvents(adapter) {
64
+ for await (const event of adapter.events())
65
+ this.eventQueue.push(event);
66
+ }
67
+ }
68
+ async function promptTurnRecovery(result, label, runtime, otherRuntime, allowSwitch) {
69
+ log.error(result.text);
70
+ const choice = await select({
71
+ message: `${runtime} failed during ${label}. What should Rafi do?`,
72
+ options: [
73
+ { value: "retry", label: "Fix the runtime manually, then retry this turn" },
74
+ ...(allowSwitch
75
+ ? [{ value: "switch", label: `Switch to ${otherRuntime} for this run (fresh session)` }]
76
+ : []),
77
+ { value: "cancel", label: "Cancel this command and keep project state" },
78
+ ],
79
+ });
80
+ if (isCancel(choice))
81
+ return "cancel";
82
+ return choice;
83
+ }
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * Agent-agnostic interface over a coding agent ("builder").
3
3
  */
4
+ import type { RuntimeProbeCategory, RuntimeProbePhase } from "rafi-spec";
4
5
  /** A permission request surfaced by a builder before it runs a tool. */
5
6
  export interface PermissionRequest {
6
7
  toolName: string;
@@ -24,6 +25,14 @@ export interface TurnResult {
24
25
  isError: boolean;
25
26
  numTurns: number;
26
27
  costUsd: number;
28
+ failure?: {
29
+ runtime: "claude" | "codex";
30
+ phase: RuntimeProbePhase;
31
+ category: RuntimeProbeCategory;
32
+ executable: string;
33
+ cwd: string;
34
+ diagnostics: string;
35
+ };
27
36
  }
28
37
  /** Observability events emitted while a builder works. */
29
38
  export type BuilderEvent = {
@@ -44,6 +53,10 @@ export type EffortLevel = "low" | "medium" | "high" | "xhigh";
44
53
  export interface BuilderAdapterOptions {
45
54
  /** Working directory the builder operates in. */
46
55
  cwd: string;
56
+ /** Absolute runtime executable path verified by the readiness probe. */
57
+ runtimeExecutable?: string;
58
+ /** User-facing execution phase for diagnostics. */
59
+ runtimePhase?: RuntimeProbePhase;
47
60
  /** Permission decision callback. */
48
61
  permission: PermissionHandler;
49
62
  /** Resume a prior session instead of starting fresh. */
@@ -1,4 +1 @@
1
- /**
2
- * Agent-agnostic interface over a coding agent ("builder").
3
- */
4
1
  export {};
@@ -16,7 +16,7 @@ export interface RoleBuilderOptions {
16
16
  yes?: boolean;
17
17
  allowSwitch?: boolean;
18
18
  label: string;
19
- log: Log;
19
+ log?: Log;
20
20
  permissionConfig?: PermissionConfig;
21
21
  extraSkills?: string[];
22
22
  sandboxMode?: BuilderAdapterOptions["sandboxMode"];
@@ -29,11 +29,12 @@ export interface RoleBuilder {
29
29
  effort?: EffortLevel;
30
30
  roleBundle: RoleBundle;
31
31
  skills: string[];
32
+ log: Log;
32
33
  }
33
34
  export interface RoleInstructionRunOptions extends Omit<RoleBuilderOptions, "log"> {
34
35
  instruction: string;
35
36
  logPath?: string;
36
- logEvent?: "rafi-plan" | "ticket-populate" | "preflight";
37
+ logEvent?: "rafi-plan" | "ticket-populate" | "preflight" | "uninstall-proposal";
37
38
  }
38
39
  export interface RoleInstructionRunResult {
39
40
  turn: {
package/dist/agentRun.js CHANGED
@@ -1,15 +1,17 @@
1
- import { existsSync } from "node:fs";
1
+ import { existsSync, readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { loadConfig, DEFAULT_CONFIG } from "./config.js";
4
4
  import { Log } from "./log.js";
5
5
  import { PermissionPolicy } from "./permissions/policy.js";
6
6
  import { ClaudeAdapter } from "./adapters/claude.js";
7
7
  import { CodexAdapter } from "./adapters/codex.js";
8
+ import { RecoveringAdapter } from "./adapters/recovering.js";
8
9
  import { Foreman, createPermissionHandler } from "./foreman.js";
9
10
  import { printEvents } from "./cli/events.js";
10
11
  import { loadRoleBundle } from "./roles.js";
11
12
  import { ensureRuntimeReadyForCommand } from "./cli/runtimeAuthPrompt.js";
12
13
  import { resolveAgentForProject } from "./cli/runtimeSelection.js";
14
+ import { parse as parseYaml } from "yaml";
13
15
  export const VALID_EFFORT = ["low", "medium", "high", "xhigh"];
14
16
  export function assertEffortLevel(effort) {
15
17
  if (effort && !VALID_EFFORT.includes(effort)) {
@@ -84,12 +86,16 @@ export async function createRoleBuilder(opts) {
84
86
  if (!existsSync(opts.projectDir)) {
85
87
  throw new Error(`project directory not found: ${opts.projectDir}`);
86
88
  }
87
- assertEffortLevel(opts.effort);
88
- let runtime = resolveAgentForProject(opts.projectDir, opts.agent);
89
+ const saved = readRoleDefaults(opts.projectDir, opts.role);
90
+ const configuredEffort = saved?.reasoning && saved.reasoning !== "default" ? saved.reasoning : undefined;
91
+ const effectiveEffort = opts.effort ?? (VALID_EFFORT.includes(configuredEffort) ? configuredEffort : undefined);
92
+ assertEffortLevel(effectiveEffort);
93
+ let runtime = resolveAgentForProject(opts.projectDir, opts.agent ?? saved?.make);
89
94
  const config = loadConfig(join(opts.projectDir, "foreman.yaml"));
95
+ const log = opts.log ?? new Log(makeLogPath(opts.projectDir, opts.label.replace(/\s+/g, "-")));
90
96
  const roleBundle = loadRoleBundle(opts.role, { projectDir: opts.projectDir });
91
- const initialModel = opts.model ?? roleBundle.model ?? undefined;
92
- const effort = opts.effort ?? roleBundle.effort ?? undefined;
97
+ const initialModel = opts.model ?? (saved?.model !== "default" ? saved?.model : undefined) ?? roleBundle.model ?? undefined;
98
+ const effort = effectiveEffort ?? roleBundle.effort ?? undefined;
93
99
  const ready = await ensureRuntimeReadyForCommand(opts.projectDir, runtime, {
94
100
  label: opts.label,
95
101
  yes: Boolean(opts.yes),
@@ -97,32 +103,82 @@ export async function createRoleBuilder(opts) {
97
103
  model: initialModel,
98
104
  });
99
105
  runtime = ready.runtime;
100
- const model = ready.model;
106
+ let model = ready.model;
107
+ let runtimeExecutable = ready.executable;
101
108
  const policy = new PermissionPolicy(opts.permissionConfig ?? config.permissions, opts.projectDir);
102
109
  const skills = mergeSkills(roleBundle.skills, opts.extraSkills);
103
- const adapterOpts = {
104
- cwd: opts.projectDir,
105
- model,
106
- resumeSessionId: opts.resumeSessionId,
107
- permission: createPermissionHandler(policy, opts.log),
108
- effort,
109
- fast: opts.fast,
110
- sandboxMode: opts.sandboxMode,
111
- systemPromptAppend: roleBundle.system || undefined,
112
- skills: skills.length > 0 ? skills : undefined,
110
+ const makeAdapter = async (nextRuntime, resumeSessionId) => {
111
+ const adapterOpts = {
112
+ cwd: opts.projectDir,
113
+ runtimeExecutable,
114
+ runtimePhase: phaseForRole(opts.role),
115
+ model,
116
+ resumeSessionId,
117
+ permission: createPermissionHandler(policy, log),
118
+ effort,
119
+ fast: opts.fast ?? saved?.fast,
120
+ sandboxMode: opts.sandboxMode,
121
+ systemPromptAppend: roleBundle.system || undefined,
122
+ skills: skills.length > 0 ? skills : undefined,
123
+ };
124
+ return nextRuntime === "codex"
125
+ ? new CodexAdapter(adapterOpts)
126
+ : await ClaudeAdapter.create(adapterOpts);
113
127
  };
114
- const builder = runtime === "codex"
115
- ? new CodexAdapter(adapterOpts)
116
- : await ClaudeAdapter.create(adapterOpts);
117
- return { builder, runtime, model, effort, roleBundle, skills };
128
+ const initial = await makeAdapter(runtime, opts.resumeSessionId);
129
+ const builder = new RecoveringAdapter({
130
+ initial,
131
+ runtime,
132
+ label: opts.label,
133
+ enabled: !opts.yes && Boolean(process.stdin.isTTY && process.stdout.isTTY),
134
+ allowSwitch: opts.allowSwitch !== false && !opts.resumeSessionId,
135
+ recreate: async (nextRuntime, resumeSessionId) => {
136
+ const nextReady = await ensureRuntimeReadyForCommand(opts.projectDir, nextRuntime, {
137
+ label: opts.label,
138
+ allowSwitch: false,
139
+ model: nextRuntime === runtime ? model : undefined,
140
+ });
141
+ runtime = nextReady.runtime;
142
+ model = nextReady.model;
143
+ runtimeExecutable = nextReady.executable;
144
+ return makeAdapter(runtime, resumeSessionId);
145
+ },
146
+ });
147
+ return { builder, runtime, model, effort, roleBundle, skills, log };
148
+ }
149
+ function phaseForRole(role) {
150
+ if (role === "planner")
151
+ return "planning";
152
+ if (role === "ticket-maker")
153
+ return "ticket-population";
154
+ if (role === "qa")
155
+ return "qa";
156
+ if (role === "uninstaller")
157
+ return "uninstaller";
158
+ return "builder";
159
+ }
160
+ function readRoleDefaults(projectDir, role) {
161
+ const path = join(projectDir, "rafi-config.yaml");
162
+ if (!existsSync(path))
163
+ return undefined;
164
+ try {
165
+ const config = parseYaml(readFileSync(path, "utf8"));
166
+ return config?.agent_defaults?.version === 1
167
+ ? config.agent_defaults.roles[role]
168
+ : undefined;
169
+ }
170
+ catch {
171
+ return undefined;
172
+ }
118
173
  }
119
174
  export async function runRoleInstruction(opts) {
120
175
  const logPath = opts.logPath ?? makeLogPath(opts.projectDir, opts.label.replace(/\s+/g, "-"));
121
176
  const log = new Log(logPath);
122
- const { builder, runtime, model, effort, roleBundle, skills } = await createRoleBuilder({
177
+ const roleBuilder = await createRoleBuilder({
123
178
  ...opts,
124
179
  log,
125
180
  });
181
+ const { builder, effort, roleBundle, skills } = roleBuilder;
126
182
  const viewer = printEvents(builder.events());
127
183
  const config = loadConfig(join(opts.projectDir, "foreman.yaml"));
128
184
  const foreman = new Foreman(builder, log, config.notifications.enabled, false, 3, opts.projectDir);
@@ -133,8 +189,8 @@ export async function runRoleInstruction(opts) {
133
189
  if (opts.logEvent) {
134
190
  log.write(opts.logEvent, {
135
191
  role: opts.role,
136
- runtime,
137
- model,
192
+ runtime: builder.agent,
193
+ model: builder.agent === roleBuilder.runtime ? roleBuilder.model : undefined,
138
194
  effort,
139
195
  sessionId: builder.sessionId(),
140
196
  statusKind: turn.status.kind,
@@ -144,7 +200,16 @@ export async function runRoleInstruction(opts) {
144
200
  isError: turn.result.isError,
145
201
  });
146
202
  }
147
- return { turn, runtime, model, effort, sessionId: builder.sessionId(), logPath, roleBundle, skills };
203
+ return {
204
+ turn,
205
+ runtime: builder.agent,
206
+ model: builder.agent === roleBuilder.runtime ? roleBuilder.model : undefined,
207
+ effort,
208
+ sessionId: builder.sessionId(),
209
+ logPath,
210
+ roleBundle,
211
+ skills,
212
+ };
148
213
  }
149
214
  catch (err) {
150
215
  await builder.close().catch(() => { });
@@ -14,6 +14,7 @@ export declare function generatedTrackerDirtyPaths(paths: {
14
14
  export declare function ensureCleanBaseWorktree(cwd: string, opts?: BaseWorktreeCleanOptions): void;
15
15
  export declare function ensureForemanExcluded(cwd: string): void;
16
16
  export declare function createTicketWorktree(projectDir: string, runId: string, branch: string, base: string): string;
17
+ export declare function findWorktreeForBranch(projectDir: string, branch: string): string | undefined;
17
18
  export declare function removeTicketWorktree(projectDir: string, worktreePath: string): void;
18
19
  export declare function currentWorktreeBranch(worktreePath: string): string;
19
20
  export declare function hasTrackerChanges(worktreePath: string, paths?: {
@@ -24,5 +25,7 @@ export declare function hasWorktreeChanges(worktreePath: string): boolean;
24
25
  export declare function commitAll(worktreePath: string, message: string): string | undefined;
25
26
  export declare function headCommitIfAhead(worktreePath: string, base: string): string | undefined;
26
27
  export declare function pushBranch(worktreePath: string, branch: string): void;
28
+ export declare function mergeBranchToLocalBase(projectDir: string, branch: string, baseBranch: string, message: string, method?: "squash" | "merge" | "rebase"): string;
29
+ /** Compatibility export for callers compiled against the older helper. */
27
30
  export declare function squashMergeBranchToLocalBase(projectDir: string, branch: string, baseBranch: string, message: string): string;
28
31
  export declare function deleteLocalBranch(projectDir: string, branch: string): void;
@@ -51,6 +51,19 @@ export function createTicketWorktree(projectDir, runId, branch, base) {
51
51
  runGit(projectDir, ["worktree", "add", "-b", branch, worktreePath, base]);
52
52
  return worktreePath;
53
53
  }
54
+ export function findWorktreeForBranch(projectDir, branch) {
55
+ const output = runGit(projectDir, ["worktree", "list", "--porcelain"]).stdout;
56
+ let worktree;
57
+ for (const line of output.split(/\r?\n/)) {
58
+ if (line.startsWith("worktree "))
59
+ worktree = line.slice("worktree ".length);
60
+ if (line === `branch refs/heads/${branch}` && worktree)
61
+ return worktree;
62
+ if (!line.trim())
63
+ worktree = undefined;
64
+ }
65
+ return undefined;
66
+ }
54
67
  export function removeTicketWorktree(projectDir, worktreePath) {
55
68
  try {
56
69
  runGit(projectDir, ["worktree", "remove", "--force", worktreePath]);
@@ -101,15 +114,28 @@ export function headCommitIfAhead(worktreePath, base) {
101
114
  export function pushBranch(worktreePath, branch) {
102
115
  runGit(worktreePath, ["push", "-u", "origin", branch]);
103
116
  }
104
- export function squashMergeBranchToLocalBase(projectDir, branch, baseBranch, message) {
105
- const current = currentGitRef(projectDir);
106
- if (current !== baseBranch) {
117
+ export function mergeBranchToLocalBase(projectDir, branch, baseBranch, message, method = "squash") {
118
+ if (method === "rebase")
119
+ runGit(projectDir, ["rebase", baseBranch, branch]);
120
+ if (currentGitRef(projectDir) !== baseBranch) {
107
121
  runGit(projectDir, ["checkout", baseBranch]);
108
122
  }
109
- runGit(projectDir, ["merge", "--squash", branch]);
110
- runGit(projectDir, ["commit", "-m", message]);
123
+ if (method === "squash") {
124
+ runGit(projectDir, ["merge", "--squash", branch]);
125
+ runGit(projectDir, ["commit", "-m", message]);
126
+ }
127
+ else if (method === "merge") {
128
+ runGit(projectDir, ["merge", "--no-ff", branch, "-m", message]);
129
+ }
130
+ else {
131
+ runGit(projectDir, ["merge", "--ff-only", branch]);
132
+ }
111
133
  return runGit(projectDir, ["rev-parse", "--short", "HEAD"]).stdout;
112
134
  }
135
+ /** Compatibility export for callers compiled against the older helper. */
136
+ export function squashMergeBranchToLocalBase(projectDir, branch, baseBranch, message) {
137
+ return mergeBranchToLocalBase(projectDir, branch, baseBranch, message, "squash");
138
+ }
113
139
  export function deleteLocalBranch(projectDir, branch) {
114
140
  try {
115
141
  runGit(projectDir, ["branch", "-D", branch]);