ai-foreman 1.7.3 → 1.7.4

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.
@@ -1,12 +1,14 @@
1
+ import type { PermissionResult } from "@anthropic-ai/claude-agent-sdk";
1
2
  /** Lazy-load the Claude Agent SDK. Throws an actionable error if not installed. */
2
3
  export declare function requireClaudeSDK(): Promise<typeof import("@anthropic-ai/claude-agent-sdk")>;
3
- import type { BuilderAdapter, BuilderAdapterOptions, BuilderEvent, CompactResult, ContextUsage, ProviderSettingSwitch, TurnResult } from "./types.js";
4
+ import type { BuilderAdapter, BuilderAdapterOptions, BuilderEvent, CompactResult, ContextUsage, PermissionDecision, ProviderSettingSwitch, TurnResult } from "./types.js";
4
5
  /**
5
6
  * Pure function: build the `options` object passed to `query()`.
6
7
  * Extracted so tests can assert on the shape without making a live SDK call.
7
8
  * `canUseTool` is omitted here — it's a closure that the constructor adds.
8
9
  */
9
10
  export declare function buildClaudeQueryOptions(opts: Omit<BuilderAdapterOptions, "permission">): Record<string, unknown>;
11
+ export declare function permissionDecisionToClaudeResult(decision: PermissionDecision, toolUseID?: string): PermissionResult;
10
12
  /**
11
13
  * Drives Claude Code through the Claude Agent SDK in streaming-input mode:
12
14
  * one persistent session, follow-up turns pushed as user messages, permission
@@ -37,6 +37,22 @@ export function buildClaudeQueryOptions(opts) {
37
37
  }
38
38
  return base;
39
39
  }
40
+ export function permissionDecisionToClaudeResult(decision, toolUseID) {
41
+ if (decision.behavior === "allow") {
42
+ return {
43
+ behavior: "allow",
44
+ updatedInput: decision.updatedInput,
45
+ updatedPermissions: decision.updatedPermissions,
46
+ toolUseID,
47
+ };
48
+ }
49
+ return {
50
+ behavior: "deny",
51
+ message: decision.message,
52
+ interrupt: decision.interrupt,
53
+ toolUseID,
54
+ };
55
+ }
40
56
  /**
41
57
  * Drives Claude Code through the Claude Agent SDK in streaming-input mode:
42
58
  * one persistent session, follow-up turns pushed as user messages, permission
@@ -81,11 +97,19 @@ export class ClaudeAdapter {
81
97
  ...buildClaudeQueryOptions(opts),
82
98
  abortController: this.abort,
83
99
  stderr: (data) => this.captureStderr(data),
84
- canUseTool: async (toolName, input) => {
85
- const decision = await opts.permission({ toolName, input });
86
- return decision.behavior === "allow"
87
- ? { behavior: "allow" }
88
- : { behavior: "deny", message: decision.message };
100
+ canUseTool: async (toolName, input, requestOptions = {}) => {
101
+ const decision = await opts.permission({
102
+ toolName,
103
+ input,
104
+ signal: requestOptions.signal,
105
+ title: requestOptions.title,
106
+ displayName: requestOptions.displayName,
107
+ description: requestOptions.description,
108
+ decisionReason: requestOptions.decisionReason,
109
+ blockedPath: requestOptions.blockedPath,
110
+ toolUseID: requestOptions.toolUseID,
111
+ });
112
+ return permissionDecisionToClaudeResult(decision, requestOptions.toolUseID);
89
113
  },
90
114
  },
91
115
  });
@@ -8,13 +8,30 @@ export interface PermissionRequest {
8
8
  input: Record<string, unknown>;
9
9
  /** Human-readable prompt from the agent, when available. */
10
10
  title?: string;
11
+ /** Signaled if the provider-side operation should be aborted. */
12
+ signal?: AbortSignal;
13
+ /** Provider-native identifier for this specific tool call, when available. */
14
+ toolUseID?: string;
15
+ /** Short provider-rendered name for the requested action, when available. */
16
+ displayName?: string;
17
+ /** Provider-rendered detail for the requested action, when available. */
18
+ description?: string;
19
+ /** Provider explanation for why permission was requested, when available. */
20
+ decisionReason?: string;
21
+ /** Provider path that triggered the permission request, when available. */
22
+ blockedPath?: string;
11
23
  }
12
24
  /** The foreman's verdict on a permission request. */
13
25
  export type PermissionDecision = {
14
26
  behavior: "allow";
27
+ /** Provider-native tool input to continue with after host interaction. */
28
+ updatedInput?: Record<string, unknown>;
29
+ /** Provider-native permission updates to apply after approval. */
30
+ updatedPermissions?: Array<Record<string, unknown>>;
15
31
  } | {
16
32
  behavior: "deny";
17
33
  message: string;
34
+ interrupt?: boolean;
18
35
  };
19
36
  /** Decides each permission request. Supplied by the foreman, called by the adapter. */
20
37
  export type PermissionHandler = (req: PermissionRequest) => Promise<PermissionDecision>;
package/dist/agentRun.js CHANGED
@@ -107,6 +107,7 @@ export async function createRoleBuilder(opts) {
107
107
  let runtimeExecutable = ready.executable;
108
108
  const policy = new PermissionPolicy(opts.permissionConfig ?? config.permissions, opts.projectDir);
109
109
  const skills = mergeSkills(roleBundle.skills, opts.extraSkills);
110
+ const interactive = !opts.yes && Boolean(process.stdin.isTTY && process.stdout.isTTY);
110
111
  const makeAdapter = async (nextRuntime, resumeSessionId) => {
111
112
  const adapterOpts = {
112
113
  cwd: opts.projectDir,
@@ -114,7 +115,7 @@ export async function createRoleBuilder(opts) {
114
115
  runtimePhase: phaseForRole(opts.role),
115
116
  model,
116
117
  resumeSessionId,
117
- permission: createPermissionHandler(policy, log),
118
+ permission: createPermissionHandler(policy, log, { interactive }),
118
119
  effort,
119
120
  fast: opts.fast ?? saved?.fast,
120
121
  sandboxMode: opts.sandboxMode,
@@ -130,7 +131,7 @@ export async function createRoleBuilder(opts) {
130
131
  initial,
131
132
  runtime,
132
133
  label: opts.label,
133
- enabled: !opts.yes && Boolean(process.stdin.isTTY && process.stdout.isTTY),
134
+ enabled: interactive,
134
135
  allowSwitch: opts.allowSwitch !== false && !opts.resumeSessionId,
135
136
  recreate: async (nextRuntime, resumeSessionId) => {
136
137
  const nextReady = await ensureRuntimeReadyForCommand(opts.projectDir, nextRuntime, {
package/dist/foreman.d.ts CHANGED
@@ -36,7 +36,9 @@ export declare function buildQaFixInstruction(issues: string): string;
36
36
  /** Pre-flight planning turn: ask the builder to list its next N steps without implementing anything. */
37
37
  export declare function buildPlanningTurn(n: number, ticketsContent?: string): string;
38
38
  /** Builds the permission callback: classify, log, allow or escalate. */
39
- export declare function createPermissionHandler(policy: PermissionPolicy, log: Log): PermissionHandler;
39
+ export declare function createPermissionHandler(policy: PermissionPolicy, log: Log, opts?: {
40
+ interactive?: boolean;
41
+ }): PermissionHandler;
40
42
  export interface BatchResult {
41
43
  completed: number;
42
44
  requested: number;
package/dist/foreman.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { select, isCancel } from "@clack/prompts";
2
2
  import { MARKER_SPEC, QA_MARKER_SPEC } from "./markers.js";
3
+ import { countProviderQuestions, handleProviderQuestionTool } from "./providerQuestions.js";
3
4
  import { fireNotification } from "./notify.js";
4
5
  import { isTicketsInitialized, loadTicketsConfig } from "./tickets/config.js";
5
6
  import { cmdUpdate, cmdComplete, cmdBlock, cmdImplementationQueue } from "./tickets/commands.js";
@@ -166,8 +167,20 @@ export function buildPlanningTurn(n, ticketsContent) {
166
167
  return `${header}Before we begin, list the next ${n} ticket(s) or step(s) you plan to implement, in order. Be specific — reference ticket IDs or titles where applicable. Do not implement anything yet; output the numbered list only. Do not emit a STEP_STATUS marker on this turn.`;
167
168
  }
168
169
  /** Builds the permission callback: classify, log, allow or escalate. */
169
- export function createPermissionHandler(policy, log) {
170
+ export function createPermissionHandler(policy, log, opts = {}) {
170
171
  return async (req) => {
172
+ const providerQuestion = await handleProviderQuestionTool(req, {
173
+ interactive: opts.interactive ?? true,
174
+ });
175
+ if (providerQuestion) {
176
+ log.write("permission", {
177
+ tool: req.toolName,
178
+ decision: providerQuestion.behavior,
179
+ reason: "provider question prompt",
180
+ questionCount: countProviderQuestions(req.input),
181
+ });
182
+ return providerQuestion;
183
+ }
171
184
  const verdict = policy.classify(req);
172
185
  log.write("permission", {
173
186
  tool: req.toolName,
@@ -0,0 +1,14 @@
1
+ import { isCancel, multiselect, select, text } from "@clack/prompts";
2
+ import type { PermissionDecision, PermissionRequest } from "./adapters/types.js";
3
+ export interface ProviderQuestionPromptDeps {
4
+ select: typeof select;
5
+ multiselect: typeof multiselect;
6
+ text: typeof text;
7
+ isCancel: typeof isCancel;
8
+ }
9
+ export interface ProviderQuestionOptions {
10
+ interactive: boolean;
11
+ prompts?: ProviderQuestionPromptDeps;
12
+ }
13
+ export declare function handleProviderQuestionTool(req: PermissionRequest, opts: ProviderQuestionOptions): Promise<PermissionDecision | undefined>;
14
+ export declare function countProviderQuestions(input: Record<string, unknown>): number;
@@ -0,0 +1,192 @@
1
+ import { isCancel, multiselect, select, text } from "@clack/prompts";
2
+ const ASK_USER_QUESTION_TOOL = "AskUserQuestion";
3
+ const CUSTOM_VALUE = "__rafi_custom_response__";
4
+ export async function handleProviderQuestionTool(req, opts) {
5
+ if (req.toolName !== ASK_USER_QUESTION_TOOL)
6
+ return undefined;
7
+ if (!opts.interactive) {
8
+ return {
9
+ behavior: "deny",
10
+ interrupt: true,
11
+ message: "AskUserQuestion requires interactive input, but this Rafi run is non-interactive.",
12
+ };
13
+ }
14
+ const questions = parseQuestions(req.input);
15
+ if (questions.length === 0) {
16
+ return {
17
+ behavior: "deny",
18
+ interrupt: true,
19
+ message: "AskUserQuestion did not include any readable questions.",
20
+ };
21
+ }
22
+ const prompts = opts.prompts ?? { select, multiselect, text, isCancel };
23
+ const answers = recordFromUnknown(req.input.answers);
24
+ const annotations = annotationRecordFromUnknown(req.input.annotations);
25
+ for (const question of questions) {
26
+ const answered = await askOneQuestion(question, prompts, req.signal);
27
+ if (answered.cancelled) {
28
+ return {
29
+ behavior: "deny",
30
+ interrupt: true,
31
+ message: "User cancelled the provider question prompt.",
32
+ };
33
+ }
34
+ answers[question.question] = answered.answer;
35
+ if (answered.annotation)
36
+ annotations[question.question] = answered.annotation;
37
+ }
38
+ const updatedInput = {
39
+ ...req.input,
40
+ answers,
41
+ };
42
+ if (Object.keys(annotations).length > 0)
43
+ updatedInput.annotations = annotations;
44
+ return { behavior: "allow", updatedInput };
45
+ }
46
+ export function countProviderQuestions(input) {
47
+ const questions = input.questions;
48
+ return Array.isArray(questions) ? questions.length : 0;
49
+ }
50
+ function parseQuestions(input) {
51
+ const rawQuestions = input.questions;
52
+ if (!Array.isArray(rawQuestions))
53
+ return [];
54
+ return rawQuestions.flatMap((raw) => {
55
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
56
+ return [];
57
+ const record = raw;
58
+ const question = typeof record.question === "string" ? record.question.trim() : "";
59
+ if (!question)
60
+ return [];
61
+ const header = typeof record.header === "string" && record.header.trim()
62
+ ? record.header.trim()
63
+ : undefined;
64
+ const options = Array.isArray(record.options)
65
+ ? record.options.flatMap((option) => {
66
+ if (!option || typeof option !== "object" || Array.isArray(option))
67
+ return [];
68
+ const optionRecord = option;
69
+ const label = typeof optionRecord.label === "string" ? optionRecord.label.trim() : "";
70
+ if (!label)
71
+ return [];
72
+ return [{
73
+ label,
74
+ description: typeof optionRecord.description === "string" ? optionRecord.description : undefined,
75
+ preview: typeof optionRecord.preview === "string" ? optionRecord.preview : undefined,
76
+ }];
77
+ })
78
+ : [];
79
+ return [{ question, header, options, multiSelect: record.multiSelect === true }];
80
+ });
81
+ }
82
+ async function askOneQuestion(question, prompts, signal) {
83
+ const message = question.header ? `${question.header}: ${question.question}` : question.question;
84
+ if (question.options.length === 0) {
85
+ const answer = await prompts.text({ message, signal });
86
+ if (prompts.isCancel(answer))
87
+ return { cancelled: true, answer: "" };
88
+ return { cancelled: false, answer: String(answer) };
89
+ }
90
+ if (question.multiSelect) {
91
+ const selected = await prompts.multiselect({
92
+ message,
93
+ options: [
94
+ ...question.options.map((option, index) => ({
95
+ value: String(index),
96
+ label: option.label,
97
+ hint: option.description,
98
+ })),
99
+ { value: CUSTOM_VALUE, label: "Custom response", hint: "Type a different answer" },
100
+ ],
101
+ required: true,
102
+ signal,
103
+ });
104
+ if (prompts.isCancel(selected))
105
+ return { cancelled: true, answer: "" };
106
+ const selectedValues = selected;
107
+ const labels = selectedValues
108
+ .filter((value) => value !== CUSTOM_VALUE)
109
+ .map((value) => question.options[Number(value)]?.label)
110
+ .filter((value) => Boolean(value));
111
+ const previews = selectedValues
112
+ .filter((value) => value !== CUSTOM_VALUE)
113
+ .map((value) => question.options[Number(value)]?.preview)
114
+ .filter((value) => Boolean(value));
115
+ let notes;
116
+ if (selectedValues.includes(CUSTOM_VALUE)) {
117
+ const custom = await prompts.text({ message: "Custom response:", signal });
118
+ if (prompts.isCancel(custom))
119
+ return { cancelled: true, answer: "" };
120
+ notes = String(custom);
121
+ labels.push(notes);
122
+ }
123
+ return {
124
+ cancelled: false,
125
+ answer: labels.join(", "),
126
+ annotation: annotationFor(previews, notes),
127
+ };
128
+ }
129
+ const selected = await prompts.select({
130
+ message,
131
+ options: [
132
+ ...question.options.map((option, index) => ({
133
+ value: String(index),
134
+ label: option.label,
135
+ hint: option.description,
136
+ })),
137
+ { value: CUSTOM_VALUE, label: "Custom response", hint: "Type a different answer" },
138
+ ],
139
+ signal,
140
+ });
141
+ if (prompts.isCancel(selected))
142
+ return { cancelled: true, answer: "" };
143
+ if (selected === CUSTOM_VALUE) {
144
+ const custom = await prompts.text({ message: "Custom response:", signal });
145
+ if (prompts.isCancel(custom))
146
+ return { cancelled: true, answer: "" };
147
+ const answer = String(custom);
148
+ return { cancelled: false, answer, annotation: { notes: answer } };
149
+ }
150
+ const option = question.options[Number(selected)];
151
+ return {
152
+ cancelled: false,
153
+ answer: option?.label ?? String(selected),
154
+ annotation: annotationFor(option?.preview ? [option.preview] : [], undefined),
155
+ };
156
+ }
157
+ function annotationFor(previews, notes) {
158
+ const annotation = {};
159
+ if (previews.length > 0)
160
+ annotation.preview = previews.join("\n\n");
161
+ if (notes)
162
+ annotation.notes = notes;
163
+ return Object.keys(annotation).length > 0 ? annotation : undefined;
164
+ }
165
+ function recordFromUnknown(value) {
166
+ if (!value || typeof value !== "object" || Array.isArray(value))
167
+ return {};
168
+ const result = {};
169
+ for (const [key, entry] of Object.entries(value)) {
170
+ if (typeof entry === "string")
171
+ result[key] = entry;
172
+ }
173
+ return result;
174
+ }
175
+ function annotationRecordFromUnknown(value) {
176
+ if (!value || typeof value !== "object" || Array.isArray(value))
177
+ return {};
178
+ const result = {};
179
+ for (const [key, entry] of Object.entries(value)) {
180
+ if (!entry || typeof entry !== "object" || Array.isArray(entry))
181
+ continue;
182
+ const record = entry;
183
+ const annotation = {};
184
+ if (typeof record.preview === "string")
185
+ annotation.preview = record.preview;
186
+ if (typeof record.notes === "string")
187
+ annotation.notes = record.notes;
188
+ if (Object.keys(annotation).length > 0)
189
+ result[key] = annotation;
190
+ }
191
+ return result;
192
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-foreman",
3
- "version": "1.7.3",
3
+ "version": "1.7.4",
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",