ai-foreman 1.6.0 → 1.6.2

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
@@ -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 {};
package/dist/agentRun.js CHANGED
@@ -5,6 +5,7 @@ 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";
@@ -102,25 +103,60 @@ export async function createRoleBuilder(opts) {
102
103
  model: initialModel,
103
104
  });
104
105
  runtime = ready.runtime;
105
- const model = ready.model;
106
+ let model = ready.model;
107
+ let runtimeExecutable = ready.executable;
106
108
  const policy = new PermissionPolicy(opts.permissionConfig ?? config.permissions, opts.projectDir);
107
109
  const skills = mergeSkills(roleBundle.skills, opts.extraSkills);
108
- const adapterOpts = {
109
- cwd: opts.projectDir,
110
- model,
111
- resumeSessionId: opts.resumeSessionId,
112
- permission: createPermissionHandler(policy, log),
113
- effort,
114
- fast: opts.fast ?? saved?.fast,
115
- sandboxMode: opts.sandboxMode,
116
- systemPromptAppend: roleBundle.system || undefined,
117
- 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);
118
127
  };
119
- const builder = runtime === "codex"
120
- ? new CodexAdapter(adapterOpts)
121
- : await ClaudeAdapter.create(adapterOpts);
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
+ });
122
147
  return { builder, runtime, model, effort, roleBundle, skills, log };
123
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
+ }
124
160
  function readRoleDefaults(projectDir, role) {
125
161
  const path = join(projectDir, "rafi-config.yaml");
126
162
  if (!existsSync(path))
@@ -138,10 +174,11 @@ function readRoleDefaults(projectDir, role) {
138
174
  export async function runRoleInstruction(opts) {
139
175
  const logPath = opts.logPath ?? makeLogPath(opts.projectDir, opts.label.replace(/\s+/g, "-"));
140
176
  const log = new Log(logPath);
141
- const { builder, runtime, model, effort, roleBundle, skills } = await createRoleBuilder({
177
+ const roleBuilder = await createRoleBuilder({
142
178
  ...opts,
143
179
  log,
144
180
  });
181
+ const { builder, effort, roleBundle, skills } = roleBuilder;
145
182
  const viewer = printEvents(builder.events());
146
183
  const config = loadConfig(join(opts.projectDir, "foreman.yaml"));
147
184
  const foreman = new Foreman(builder, log, config.notifications.enabled, false, 3, opts.projectDir);
@@ -152,8 +189,8 @@ export async function runRoleInstruction(opts) {
152
189
  if (opts.logEvent) {
153
190
  log.write(opts.logEvent, {
154
191
  role: opts.role,
155
- runtime,
156
- model,
192
+ runtime: builder.agent,
193
+ model: builder.agent === roleBuilder.runtime ? roleBuilder.model : undefined,
157
194
  effort,
158
195
  sessionId: builder.sessionId(),
159
196
  statusKind: turn.status.kind,
@@ -163,7 +200,16 @@ export async function runRoleInstruction(opts) {
163
200
  isError: turn.result.isError,
164
201
  });
165
202
  }
166
- 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
+ };
167
213
  }
168
214
  catch (err) {
169
215
  await builder.close().catch(() => { });
@@ -6,6 +6,8 @@ import { loadConfig } from "../config.js";
6
6
  import { isTicketsInitialized } from "../tickets/config.js";
7
7
  import { cmdValidate } from "../tickets/commands.js";
8
8
  import { checkGitHubReadiness, inspectGitHubRemote } from "../branch/github.js";
9
+ import { requireClaudeSDK } from "../adapters/claude.js";
10
+ import { resolveExecutablePath, sanitizeDiagnostics } from "../runtimeReadiness.js";
9
11
  const PACKAGE_VERSION = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8"))?.version;
10
12
  function commandVersion(command) {
11
13
  const result = spawnSync(command, ["--version"], {
@@ -23,7 +25,8 @@ export function buildDoctorCommand() {
23
25
  .description("Check Foreman, agent CLIs, config, and optional ticket tracker readiness.")
24
26
  .argument("[project]", "path to the project directory", ".")
25
27
  .option("--github", "run GitHub PR readiness checks")
26
- .action((project, opts) => {
28
+ .option("--live-claude", "run a bounded no-tools Claude adapter request (uses account quota)")
29
+ .action(async (project, opts) => {
27
30
  const cwd = resolve(project);
28
31
  let errors = 0;
29
32
  const report = (ok, label, detail) => {
@@ -46,11 +49,37 @@ export function buildDoctorCommand() {
46
49
  catch (err) {
47
50
  report(false, "foreman.yaml", err instanceof Error ? err.message : String(err));
48
51
  }
49
- const claude = commandVersion("claude");
52
+ const claudeExecutable = resolveExecutablePath("claude");
53
+ const claude = claudeExecutable ? commandVersion(claudeExecutable) : { ok: false };
50
54
  if (claude.ok)
51
- warn("claude CLI found", claude.detail);
55
+ warn("claude CLI found", `${claudeExecutable} (${claude.detail})`);
52
56
  else
53
- warn("claude CLI not found", "Claude adapter may still work through the SDK if credentials are configured");
57
+ warn("claude CLI not found", "required for Claude runs; Rafi never falls back to the SDK-bundled binary");
58
+ try {
59
+ await requireClaudeSDK();
60
+ warn("Rafi Claude SDK wrapper", "available; execution uses the system Claude CLI above");
61
+ }
62
+ catch (err) {
63
+ report(false, "Rafi Claude SDK wrapper", err instanceof Error ? err.message.split("\n")[0] : String(err));
64
+ }
65
+ warn("Claude setting sources", "user, project, local, and managed policy");
66
+ const relevantEnvironment = Object.keys(process.env)
67
+ .filter((name) => /^(ANTHROPIC|CLAUDE|HTTP_PROXY|HTTPS_PROXY|NO_PROXY|SSL_CERT_FILE|NODE_EXTRA_CA_CERTS)(_|$)/i.test(name))
68
+ .sort();
69
+ warn("Claude environment names", relevantEnvironment.length > 0 ? relevantEnvironment.join(", ") : "none set");
70
+ const projectSdkRemoval = projectClaudeSdkRemoval(cwd);
71
+ if (projectSdkRemoval) {
72
+ warn("project-local Claude Agent SDK", `Rafi does not use this dependency; keep it if the application does, otherwise: ${projectSdkRemoval}`);
73
+ }
74
+ if (opts.liveClaude) {
75
+ if (!claudeExecutable) {
76
+ report(false, "live Claude adapter", "system Claude executable is missing");
77
+ }
78
+ else {
79
+ const live = await liveClaudeSmoke(cwd, claudeExecutable);
80
+ report(live.ok, "live Claude adapter", live.detail);
81
+ }
82
+ }
54
83
  const codex = commandVersion("codex");
55
84
  if (codex.ok)
56
85
  warn("codex CLI found", codex.detail);
@@ -91,6 +120,64 @@ export function buildDoctorCommand() {
91
120
  process.exit(errors === 0 ? 0 : 1);
92
121
  });
93
122
  }
123
+ async function liveClaudeSmoke(cwd, executable) {
124
+ const abortController = new AbortController();
125
+ const timer = setTimeout(() => abortController.abort(), 45_000);
126
+ timer.unref();
127
+ try {
128
+ const { query } = await requireClaudeSDK();
129
+ const queryInstance = query({
130
+ prompt: "Return exactly OK",
131
+ options: {
132
+ cwd,
133
+ pathToClaudeCodeExecutable: executable,
134
+ settingSources: ["user", "project", "local"],
135
+ tools: [],
136
+ permissionMode: "dontAsk",
137
+ persistSession: false,
138
+ abortController,
139
+ },
140
+ });
141
+ for await (const message of queryInstance) {
142
+ if (message.type !== "result")
143
+ continue;
144
+ const output = "result" in message ? message.result : message.errors.join("; ");
145
+ return message.is_error
146
+ ? { ok: false, detail: sanitizeDiagnostics(output || "Claude returned an error") }
147
+ : { ok: output.trim().toUpperCase() === "OK", detail: sanitizeDiagnostics(output) };
148
+ }
149
+ return { ok: false, detail: "Claude adapter ended without a result" };
150
+ }
151
+ catch (err) {
152
+ const message = err instanceof Error ? err.message : String(err);
153
+ return { ok: false, detail: abortController.signal.aborted ? "timed out after 45 seconds" : sanitizeDiagnostics(message) };
154
+ }
155
+ finally {
156
+ clearTimeout(timer);
157
+ abortController.abort();
158
+ }
159
+ }
160
+ function projectClaudeSdkRemoval(cwd) {
161
+ const packagePath = join(cwd, "package.json");
162
+ if (!existsSync(packagePath))
163
+ return undefined;
164
+ try {
165
+ const pkg = JSON.parse(readFileSync(packagePath, "utf8"));
166
+ const name = "@anthropic-ai/claude-agent-sdk";
167
+ if (!pkg.dependencies?.[name] && !pkg.devDependencies?.[name] && !pkg.optionalDependencies?.[name])
168
+ return undefined;
169
+ if (existsSync(join(cwd, "pnpm-lock.yaml")))
170
+ return `pnpm remove ${name}`;
171
+ if (existsSync(join(cwd, "yarn.lock")))
172
+ return `yarn remove ${name}`;
173
+ if (existsSync(join(cwd, "bun.lock")) || existsSync(join(cwd, "bun.lockb")))
174
+ return `bun remove ${name}`;
175
+ return `npm uninstall ${name}`;
176
+ }
177
+ catch {
178
+ return undefined;
179
+ }
180
+ }
94
181
  function truncateOneLine(value) {
95
182
  const compact = value.replace(/\s+/g, " ").trim();
96
183
  return compact.length <= 180 ? compact : `${compact.slice(0, 180).trimEnd()}...`;
@@ -1,4 +1,5 @@
1
1
  import { RuntimeAuthError, type AgentRuntime } from "../runtimeAuth.js";
2
+ import type { RuntimeProbeResult } from "rafi-spec";
2
3
  export type RuntimeCommandRecoveryChoice = "retry" | "switch" | "cancel";
3
4
  export interface RuntimeCommandRecoveryContext {
4
5
  otherRuntime: AgentRuntime;
@@ -9,7 +10,7 @@ export interface RuntimeReadyForCommandOptions {
9
10
  yes?: boolean;
10
11
  allowSwitch?: boolean;
11
12
  model?: string | undefined;
12
- check?: (projectDir: string, runtime: AgentRuntime) => void | Promise<void>;
13
+ check?: (projectDir: string, runtime: AgentRuntime) => void | RuntimeProbeResult | Promise<void | RuntimeProbeResult>;
13
14
  checkClaudeSdk?: () => Promise<void>;
14
15
  choose?: (err: RuntimeAuthError, context: RuntimeCommandRecoveryContext) => Promise<RuntimeCommandRecoveryChoice>;
15
16
  }
@@ -17,5 +18,6 @@ export interface RuntimeReadyForCommandResult {
17
18
  runtime: AgentRuntime;
18
19
  model?: string;
19
20
  fellBack: boolean;
21
+ executable: string;
20
22
  }
21
23
  export declare function ensureRuntimeReadyForCommand(projectDir: string, runtime: AgentRuntime, labelOrOptions: string | RuntimeReadyForCommandOptions): Promise<RuntimeReadyForCommandResult>;
@@ -1,6 +1,7 @@
1
1
  import { select, isCancel, log } from "@clack/prompts";
2
2
  import { requireClaudeSDK } from "../adapters/claude.js";
3
3
  import { checkRuntimeReady, RuntimeAuthError, runtimeCommandLabel, } from "../runtimeAuth.js";
4
+ import { resolveExecutablePath } from "../runtimeReadiness.js";
4
5
  import { otherRuntime, runtimeDisplayName } from "./runtimeSelection.js";
5
6
  export async function ensureRuntimeReadyForCommand(projectDir, runtime, labelOrOptions) {
6
7
  const opts = typeof labelOrOptions === "string"
@@ -12,13 +13,23 @@ export async function ensureRuntimeReadyForCommand(projectDir, runtime, labelOrO
12
13
  const nonInteractive = !opts.choose && (Boolean(opts.yes) || !process.stdin.isTTY || !process.stdout.isTTY);
13
14
  while (true) {
14
15
  try {
15
- await check(projectDir, runtime);
16
- return { runtime, model: opts.model, fellBack: false };
16
+ const probe = await check(projectDir, runtime);
17
+ if (runtime === "claude")
18
+ await checkClaudeSdk();
19
+ const executable = probe?.executable ?? resolveExecutablePath(runtime === "claude" ? "claude" : "codex");
20
+ if (!executable)
21
+ throw new Error(`${runtime} executable disappeared after readiness`);
22
+ return { runtime, model: opts.model, fellBack: false, executable };
17
23
  }
18
24
  catch (err) {
19
25
  const failure = err instanceof RuntimeAuthError
20
26
  ? err
21
- : new RuntimeAuthError({ runtime, context: opts.label, cause: err });
27
+ : new RuntimeAuthError({
28
+ runtime,
29
+ context: opts.label,
30
+ stderr: err instanceof Error ? err.message : String(err),
31
+ cause: err,
32
+ });
22
33
  if (nonInteractive) {
23
34
  throw failure;
24
35
  }
@@ -30,7 +41,7 @@ export async function ensureRuntimeReadyForCommand(projectDir, runtime, labelOrO
30
41
  continue;
31
42
  if (choice === "switch" && allowSwitch) {
32
43
  try {
33
- await check(projectDir, fallbackRuntime);
44
+ const probe = await check(projectDir, fallbackRuntime);
34
45
  if (fallbackRuntime === "claude") {
35
46
  await checkClaudeSdk();
36
47
  }
@@ -38,12 +49,20 @@ export async function ensureRuntimeReadyForCommand(projectDir, runtime, labelOrO
38
49
  console.log(`foreman: ignored --model ${opts.model} after switching to ${fallbackRuntime}; model names are provider-specific.`);
39
50
  }
40
51
  console.log(`foreman: using ${runtimeDisplayName(fallbackRuntime)} for this run.`);
41
- return { runtime: fallbackRuntime, model: undefined, fellBack: true };
52
+ const executable = probe?.executable ?? resolveExecutablePath(fallbackRuntime === "claude" ? "claude" : "codex");
53
+ if (!executable)
54
+ throw new Error(`${fallbackRuntime} executable disappeared after readiness`);
55
+ return { runtime: fallbackRuntime, model: undefined, fellBack: true, executable };
42
56
  }
43
57
  catch (switchErr) {
44
58
  const switchFailure = switchErr instanceof RuntimeAuthError
45
59
  ? switchErr
46
- : new RuntimeAuthError({ runtime: fallbackRuntime, context: opts.label, cause: switchErr });
60
+ : new RuntimeAuthError({
61
+ runtime: fallbackRuntime,
62
+ context: opts.label,
63
+ stderr: switchErr instanceof Error ? switchErr.message : String(switchErr),
64
+ cause: switchErr,
65
+ });
47
66
  log.error(`Fallback runtime is not ready:\n${switchFailure.message}`);
48
67
  continue;
49
68
  }
@@ -1,2 +1,4 @@
1
1
  import { Command } from "commander";
2
+ export declare function formatStartResumeCommand(executable: "ai-foreman", projectDir: string, steps: number, sessionId: string): string;
3
+ export declare function formatResumeGuidance(executable: "rafi" | "ai-foreman", projectDir: string, steps: number, sessionId?: string): string[];
2
4
  export declare function buildStartCommand(): Command;
package/dist/cli/start.js CHANGED
@@ -7,6 +7,7 @@ import { Log } from "../log.js";
7
7
  import { PermissionPolicy } from "../permissions/policy.js";
8
8
  import { ClaudeAdapter } from "../adapters/claude.js";
9
9
  import { CodexAdapter } from "../adapters/codex.js";
10
+ import { RecoveringAdapter } from "../adapters/recovering.js";
10
11
  import { Foreman, createPermissionHandler } from "../foreman.js";
11
12
  import { printEvents } from "./events.js";
12
13
  import { loadRoleBundle } from "../roles.js";
@@ -52,6 +53,31 @@ function collectTicket(value, previous) {
52
53
  function unique(values) {
53
54
  return [...new Set(values)];
54
55
  }
56
+ export function formatStartResumeCommand(executable, projectDir, steps, sessionId) {
57
+ return [
58
+ executable,
59
+ "start",
60
+ shellQuote(projectDir),
61
+ "--steps",
62
+ String(steps),
63
+ "--resume",
64
+ shellQuote(sessionId),
65
+ ].join(" ");
66
+ }
67
+ export function formatResumeGuidance(executable, projectDir, steps, sessionId) {
68
+ if (executable === "rafi") {
69
+ return [
70
+ "foreman: resume this run with:",
71
+ ` rafi build:resume ${shellQuote(projectDir)}`,
72
+ ];
73
+ }
74
+ if (!sessionId)
75
+ return [];
76
+ return [
77
+ "foreman: resume this builder with:",
78
+ ` ${formatStartResumeCommand("ai-foreman", projectDir, steps, sessionId)}`,
79
+ ];
80
+ }
55
81
  function optionWasProvided(command, name) {
56
82
  const source = command.getOptionValueSource(name);
57
83
  return source !== undefined && source !== "default";
@@ -291,10 +317,12 @@ export function buildStartCommand() {
291
317
  fail(err instanceof Error ? err.message : String(err));
292
318
  }
293
319
  let model = opts.model ?? explicitDefaultValue(roleDefaults.builder?.model);
320
+ let agentExecutable;
294
321
  const builderEffort = opts.effort ?? explicitEffort(roleDefaults.builder?.reasoning);
295
322
  const builderFast = opts.fast ?? roleDefaults.builder?.fast;
296
323
  let qaAgent = resolveAgentForProject(cwd, roleDefaults.qa?.make);
297
324
  let qaModel = explicitDefaultValue(roleDefaults.qa?.model);
325
+ let qaExecutable;
298
326
  const qaEffort = explicitEffort(roleDefaults.qa?.reasoning);
299
327
  const qaFast = roleDefaults.qa?.fast ?? false;
300
328
  const configuredTrackerPath = isTicketsInitialized(cwd)
@@ -364,11 +392,13 @@ export function buildStartCommand() {
364
392
  const logPath = join(cwd, ".foreman", `${stamp}.jsonl`);
365
393
  const log = new Log(logPath);
366
394
  const qaEnabled = opts.qa !== false && config.qa.enabled !== false;
367
- const createBuilder = async (builderCwd, sessionId) => {
395
+ const createRawBuilder = async (builderCwd, sessionId) => {
368
396
  const builderPolicy = new PermissionPolicy(config.permissions, builderCwd);
369
397
  const roleBundle = loadRoleBundle("builder", { projectDir: builderCwd });
370
398
  const adapterOpts = {
371
399
  cwd: builderCwd,
400
+ runtimeExecutable: agentExecutable,
401
+ runtimePhase: "builder",
372
402
  model,
373
403
  resumeSessionId: sessionId,
374
404
  permission: createPermissionHandler(builderPolicy, log),
@@ -381,11 +411,34 @@ export function buildStartCommand() {
381
411
  ? new CodexAdapter(adapterOpts)
382
412
  : await ClaudeAdapter.create(adapterOpts);
383
413
  };
384
- const createQa = async (qaCwd, sessionId) => {
414
+ const createBuilder = async (builderCwd, sessionId) => {
415
+ const initial = await createRawBuilder(builderCwd, sessionId);
416
+ return new RecoveringAdapter({
417
+ initial,
418
+ runtime: agent,
419
+ label: "builder turn",
420
+ enabled: !opts.yes && Boolean(process.stdin.isTTY && process.stdout.isTTY),
421
+ allowSwitch: !(opts.resume || opts.continue),
422
+ recreate: async (nextRuntime, resumeSessionId) => {
423
+ const nextReady = await ensureRuntimeReadyForCommand(builderCwd, nextRuntime, {
424
+ label: "builder recovery",
425
+ allowSwitch: false,
426
+ model: nextRuntime === agent ? model : undefined,
427
+ });
428
+ agent = nextReady.runtime;
429
+ model = nextReady.model;
430
+ agentExecutable = nextReady.executable;
431
+ return createRawBuilder(builderCwd, resumeSessionId);
432
+ },
433
+ });
434
+ };
435
+ const createRawQa = async (qaCwd, sessionId) => {
385
436
  const qaPolicy = new PermissionPolicy(config.permissions, qaCwd);
386
437
  const roleBundle = loadRoleBundle("qa", { projectDir: qaCwd });
387
438
  const adapterOpts = {
388
439
  cwd: qaCwd,
440
+ runtimeExecutable: qaExecutable,
441
+ runtimePhase: "qa",
389
442
  model: qaModel,
390
443
  resumeSessionId: sessionId,
391
444
  permission: createPermissionHandler(qaPolicy, log),
@@ -398,6 +451,27 @@ export function buildStartCommand() {
398
451
  ? new CodexAdapter(adapterOpts)
399
452
  : await ClaudeAdapter.create(adapterOpts);
400
453
  };
454
+ const createQa = async (qaCwd, sessionId) => {
455
+ const initial = await createRawQa(qaCwd, sessionId);
456
+ return new RecoveringAdapter({
457
+ initial,
458
+ runtime: qaAgent,
459
+ label: "QA turn",
460
+ enabled: !opts.yes && Boolean(process.stdin.isTTY && process.stdout.isTTY),
461
+ allowSwitch: !sessionId,
462
+ recreate: async (nextRuntime, resumeSessionId) => {
463
+ const nextReady = await ensureRuntimeReadyForCommand(qaCwd, nextRuntime, {
464
+ label: "QA recovery",
465
+ allowSwitch: false,
466
+ model: nextRuntime === qaAgent ? qaModel : undefined,
467
+ });
468
+ qaAgent = nextReady.runtime;
469
+ qaModel = nextReady.model;
470
+ qaExecutable = nextReady.executable;
471
+ return createRawQa(qaCwd, resumeSessionId);
472
+ },
473
+ });
474
+ };
401
475
  if (branchMode) {
402
476
  const ticketsConfig = loadTicketsConfig(cwd);
403
477
  const allowedBaseDirtyPaths = generatedTrackerDirtyPaths(ticketsConfig.paths);
@@ -418,6 +492,7 @@ export function buildStartCommand() {
418
492
  });
419
493
  agent = ready.runtime;
420
494
  model = ready.model;
495
+ agentExecutable = ready.executable;
421
496
  const ticketPaths = resolveTicketPaths(ticketsConfig, cwd);
422
497
  const tickets = loadTickets(ticketPaths.tickets);
423
498
  const db = new StateDb(ticketPaths.stateDb);
@@ -481,6 +556,8 @@ export function buildStartCommand() {
481
556
  auditBuilder = await createBuilder(cwd);
482
557
  auditViewer = printEvents(auditBuilder.events());
483
558
  const audit = await auditBuilder.sendTurn(buildBranchAuditInstruction(plan.nodes.map((node) => node.ticket)));
559
+ if (audit.isError)
560
+ throw new Error(audit.text);
484
561
  const auditDependencies = parseAuditDependencies(audit.text);
485
562
  plan = buildBranchPlan(tickets, states, {
486
563
  steps,
@@ -604,10 +681,12 @@ export function buildStartCommand() {
604
681
  });
605
682
  agent = ready.runtime;
606
683
  model = ready.model;
684
+ agentExecutable = ready.executable;
607
685
  if (qaEnabled) {
608
686
  const qaReady = await ensureRuntimeReadyForCommand(cwd, qaAgent, { label: "independent QA", yes: Boolean(opts.yes), model: qaModel });
609
687
  qaAgent = qaReady.runtime;
610
688
  qaModel = qaReady.model;
689
+ qaExecutable = qaReady.executable;
611
690
  }
612
691
  const capturedBuilder = {
613
692
  role: "builder", source: opts.agent || opts.model || opts.effort || opts.fast ? "cli" : roleDefaults.builder ? "project" : "provider",
@@ -698,9 +777,13 @@ export function buildStartCommand() {
698
777
  console.log(`foreman: outcome — ${result.outcome}`);
699
778
  if (result.detail)
700
779
  console.log(`foreman: ${result.detail}`);
701
- const sid = builder.sessionId();
702
- if (sid)
703
- console.log(`foreman: resume this builder with --resume ${sid}`);
780
+ if (result.outcome !== "all-done" && result.outcome !== "plan-complete") {
781
+ const executable = command.parent?.name() === "rafi" ? "rafi" : "ai-foreman";
782
+ const remainingSteps = Math.max(1, result.requested - result.completed);
783
+ for (const line of formatResumeGuidance(executable, cwd, remainingSteps, builder.sessionId())) {
784
+ console.log(line);
785
+ }
786
+ }
704
787
  process.exit(result.outcome === "needs-human" ? 2 : 0);
705
788
  }
706
789
  catch (err) {
@@ -733,3 +816,8 @@ function explicitDefaultValue(value) {
733
816
  function explicitEffort(value) {
734
817
  return value && ["low", "medium", "high", "xhigh"].includes(value) ? value : undefined;
735
818
  }
819
+ function shellQuote(value) {
820
+ return /^[A-Za-z0-9_./:@%+=,-]+$/.test(value)
821
+ ? value
822
+ : `'${value.replace(/'/g, `'"'"'`)}'`;
823
+ }
package/dist/foreman.js CHANGED
@@ -249,13 +249,18 @@ export class Foreman {
249
249
  this.log.write("preflight", {
250
250
  ticketsProvided: ticketsContent !== undefined,
251
251
  costUsd: result.costUsd,
252
+ isError: result.isError,
252
253
  });
254
+ if (result.isError)
255
+ throw new Error(result.text);
253
256
  return result.text;
254
257
  }
255
258
  /** Send user feedback on the plan; builder responds with a revised list. Does not count toward steps. */
256
259
  async sendPreflightFeedback(feedback) {
257
260
  const result = await this.builder.sendTurn(feedback);
258
- this.log.write("preflight", { feedback: true, costUsd: result.costUsd });
261
+ this.log.write("preflight", { feedback: true, costUsd: result.costUsd, isError: result.isError });
262
+ if (result.isError)
263
+ throw new Error(result.text);
259
264
  }
260
265
  /** Send one custom instruction through the same needs_input loop as a batch turn. */
261
266
  async runInstruction(instruction) {
@@ -472,7 +477,11 @@ function fingerprintProtectedTree(root) {
472
477
  const path = join(directory, entry.name);
473
478
  const rel = relative(root, path).replace(/\\/g, "/");
474
479
  if (entry.isDirectory()) {
475
- if (ignored.has(entry.name) || rel.startsWith(".foreman/") || rel.startsWith(".rafi/cache/"))
480
+ if (ignored.has(entry.name)
481
+ || rel === ".foreman"
482
+ || rel.startsWith(".foreman/")
483
+ || rel === ".rafi/cache"
484
+ || rel.startsWith(".rafi/cache/"))
476
485
  continue;
477
486
  visit(path);
478
487
  }
@@ -15,7 +15,7 @@ export declare class RuntimeAuthError extends Error {
15
15
  readonly authLikely: boolean;
16
16
  constructor(opts: RuntimeAuthErrorOptions);
17
17
  }
18
- export declare function checkRuntimeReady(projectDir: string, runtime: AgentRuntime): Promise<void>;
18
+ export declare function checkRuntimeReady(projectDir: string, runtime: AgentRuntime): Promise<import("rafi-spec").RuntimeProbeResult>;
19
19
  export declare function normalizeRuntimeErrorText(runtime: AgentRuntime, text: string, exitCode?: number | null, context?: string): string;
20
20
  export declare function isRuntimeAuthFailure(output: string): boolean;
21
21
  export declare function runtimeCommandLabel(runtime: AgentRuntime): string;
@@ -24,6 +24,7 @@ export async function checkRuntimeReady(projectDir, runtime) {
24
24
  exitCode: result.exitCode,
25
25
  stderr: formatRuntimeProbeFailure(result),
26
26
  });
27
+ return result;
27
28
  }
28
29
  export function normalizeRuntimeErrorText(runtime, text, exitCode, context = "builder turn") {
29
30
  if (!isRuntimeAuthFailure(text))
@@ -55,12 +56,9 @@ export function runtimeCommandLabel(runtime) {
55
56
  export function runtimeRepairCommands(runtime) {
56
57
  if (runtime === "claude") {
57
58
  return [
58
- "claude auth logout",
59
- "claude auth login --claudeai",
59
+ "Authenticate in Claude Code using the login method approved by your organization.",
60
+ "Enterprise users should keep using their organization-provided flow (for example, /login-okta).",
60
61
  'claude -p "Return exactly OK"',
61
- "",
62
- "Claude subscription users may also need:",
63
- "claude setup-token",
64
62
  ].join("\n");
65
63
  }
66
64
  return [
@@ -73,7 +71,7 @@ export function formatRuntimeAuthFailure(opts) {
73
71
  const exit = opts.exitCode === undefined || opts.exitCode === null ? "unknown" : String(opts.exitCode);
74
72
  const authLine = isRuntimeAuthFailure(output)
75
73
  ? "The runtime output looks like an authentication failure."
76
- : "This often means the selected agent runtime is missing or not authenticated.";
74
+ : "The selected runtime failed; review the actual output below before changing authentication.";
77
75
  const details = output ? `\n\nRuntime output:\n${truncateRuntimeOutput(output)}` : "";
78
76
  return (`${runtimeCommandLabel(opts.runtime)} failed during ${opts.context} (exit code ${exit}).\n\n` +
79
77
  `${authLine}\n\n` +
@@ -8,7 +8,10 @@ export interface ProbeRuntimeOptions {
8
8
  maxDiagnosticsBytes?: number;
9
9
  env?: NodeJS.ProcessEnv;
10
10
  }
11
+ /** Resolve the executable Node will launch without involving a shell. */
12
+ export declare function resolveExecutablePath(command: string, env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform): string | undefined;
11
13
  export declare function probeRuntime(cwd: string, runtime: AgentRuntime, opts?: ProbeRuntimeOptions): Promise<RuntimeProbeResult>;
12
14
  export declare function classifyRuntimeFailure(text: string, phase?: RuntimeProbePhase): RuntimeProbeCategory;
15
+ export declare function classifyClaudeSdkFailure(error: string | undefined, status: number | null | undefined, diagnostics: string): RuntimeProbeCategory;
13
16
  export declare function sanitizeDiagnostics(text: string, maxBytes?: number): string;
14
17
  export declare function formatRuntimeProbeFailure(result: RuntimeProbeResult): string;
@@ -1,14 +1,64 @@
1
1
  import { spawn } from "node:child_process";
2
+ import { accessSync, constants, statSync } from "node:fs";
3
+ import { delimiter, isAbsolute, resolve } from "node:path";
2
4
  export const RUNTIME_PROBE_TIMEOUT_MS = 30_000;
3
5
  export const RUNTIME_DIAGNOSTIC_LIMIT = 8 * 1024;
4
6
  const RELEVANT_ENV = /^(ANTHROPIC|CLAUDE|CODEX|OPENAI|HTTP_PROXY|HTTPS_PROXY|NO_PROXY|SSL_CERT_FILE|NODE_EXTRA_CA_CERTS)(_|$)/i;
7
+ /** Resolve the executable Node will launch without involving a shell. */
8
+ export function resolveExecutablePath(command, env = process.env, platform = process.platform) {
9
+ const hasPathSeparator = command.includes("/") || command.includes("\\");
10
+ if (isAbsolute(command) || hasPathSeparator) {
11
+ const candidate = resolve(command);
12
+ return isRunnableFile(candidate, platform) ? candidate : undefined;
13
+ }
14
+ const pathValue = platform === "win32"
15
+ ? env.Path ?? env.PATH ?? env.path
16
+ : env.PATH;
17
+ if (!pathValue)
18
+ return undefined;
19
+ const extensions = platform === "win32"
20
+ ? executableExtensions(command, env.PATHEXT)
21
+ : [""];
22
+ const pathDelimiter = platform === "win32" ? ";" : delimiter;
23
+ for (const entry of pathValue.split(pathDelimiter)) {
24
+ const directory = entry || process.cwd();
25
+ for (const extension of extensions) {
26
+ const candidate = resolve(directory, `${command}${extension}`);
27
+ if (isRunnableFile(candidate, platform))
28
+ return candidate;
29
+ }
30
+ }
31
+ return undefined;
32
+ }
33
+ function executableExtensions(command, pathExt) {
34
+ const extensions = (pathExt ?? ".COM;.EXE;.BAT;.CMD")
35
+ .split(";")
36
+ .filter(Boolean)
37
+ .map((value) => value.startsWith(".") ? value : `.${value}`);
38
+ const lower = command.toLowerCase();
39
+ return extensions.some((extension) => lower.endsWith(extension.toLowerCase()))
40
+ ? [""]
41
+ : extensions;
42
+ }
43
+ function isRunnableFile(path, platform) {
44
+ try {
45
+ if (!statSync(path).isFile())
46
+ return false;
47
+ accessSync(path, platform === "win32" ? constants.F_OK : constants.X_OK);
48
+ return true;
49
+ }
50
+ catch {
51
+ return false;
52
+ }
53
+ }
5
54
  export async function probeRuntime(cwd, runtime, opts = {}) {
6
- const executable = runtime === "claude" ? "claude" : "codex";
55
+ const env = opts.env ?? process.env;
56
+ const command = runtime === "claude" ? "claude" : "codex";
57
+ const executable = resolveExecutablePath(command, env) ?? command;
7
58
  const args = runtime === "claude"
8
59
  ? ["-p", "Return exactly OK"]
9
60
  : ["exec", "--skip-git-repo-check", "-C", cwd, "Return exactly OK"];
10
61
  const phase = opts.phase ?? "readiness";
11
- const env = opts.env ?? process.env;
12
62
  const limit = opts.maxDiagnosticsBytes ?? RUNTIME_DIAGNOSTIC_LIMIT;
13
63
  const timeoutMs = opts.timeoutMs ?? RUNTIME_PROBE_TIMEOUT_MS;
14
64
  return new Promise((resolveResult) => {
@@ -89,6 +139,29 @@ export function classifyRuntimeFailure(text, phase = "readiness") {
89
139
  return "capability-discovery";
90
140
  return "unknown";
91
141
  }
142
+ export function classifyClaudeSdkFailure(error, status, diagnostics) {
143
+ switch (error) {
144
+ case "authentication_failed": return "authentication";
145
+ case "oauth_org_not_allowed": return "authorization";
146
+ case "rate_limit": return "rate-limit";
147
+ case "model_not_found":
148
+ case "invalid_request":
149
+ case "max_output_tokens": return "configuration";
150
+ case "billing_error": return "authorization";
151
+ case "server_error": return "agent-stream";
152
+ }
153
+ if (status === 401)
154
+ return "authentication";
155
+ if (status === 403)
156
+ return "authorization";
157
+ if (status === 407)
158
+ return "network";
159
+ if (status === 429)
160
+ return "rate-limit";
161
+ if (status !== undefined && status !== null && status >= 500)
162
+ return "agent-stream";
163
+ return classifyRuntimeFailure(diagnostics, "builder");
164
+ }
92
165
  export function sanitizeDiagnostics(text, maxBytes = RUNTIME_DIAGNOSTIC_LIMIT) {
93
166
  const clean = text
94
167
  .replace(/\u001b\[[0-?]*[ -/]*[@-~]/g, "")
@@ -110,7 +183,7 @@ export function formatRuntimeProbeFailure(result) {
110
183
  ];
111
184
  if (result.category === "authentication") {
112
185
  lines.push(result.runtime === "claude"
113
- ? "Authenticate with `claude auth login --claudeai`, then retry."
186
+ ? "Authenticate with the Claude Code login method approved by your organization, then rerun `claude -p \"Return exactly OK\"`."
114
187
  : "Authenticate with `codex login`, then retry.");
115
188
  }
116
189
  else {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-foreman",
3
- "version": "1.6.0",
3
+ "version": "1.6.2",
4
4
  "description": "An orchestrator that keeps Codex CLI / Claude Code builders moving through their step list unattended. Part of Rafi.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -29,6 +29,10 @@
29
29
  "types": "./dist/runtimeReadiness.d.ts",
30
30
  "default": "./dist/runtimeReadiness.js"
31
31
  },
32
+ "./claude-adapter.js": {
33
+ "types": "./dist/adapters/claude.d.ts",
34
+ "default": "./dist/adapters/claude.js"
35
+ },
32
36
  "./roles.js": {
33
37
  "types": "./dist/roles.d.ts",
34
38
  "default": "./dist/roles.js"