ai-foreman 1.1.0 → 1.3.0

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
@@ -51,7 +51,7 @@ Requires:
51
51
 
52
52
  - Check the target project.
53
53
  - Initialize Foreman's local ticket tracker.
54
- - Ask agent to convert existing tickets or populate them
54
+ - Ask the ticket-maker role to convert existing tickets or populate them
55
55
  - Run foreman for 5 tickets (and QA each one when done)
56
56
 
57
57
  ```bash
@@ -65,12 +65,13 @@ What each part does:
65
65
 
66
66
  - `./my-project`: the repo Foreman will work on.
67
67
  - `tickets init`: creates the local `.tickets/` tracker.
68
- - `tickets populate`: asks Codex to convert project planning material.
68
+ - `tickets populate`: asks the ticket-maker role to convert project planning material.
69
69
  - Converted output uses Foreman's schema.
70
70
  - `start`: drives Codex through the next tickets one step at a time.
71
71
 
72
72
  `tickets populate` can convert:
73
73
 
74
+ - a managed Rafi plan at `<docs.root>/rafi-plan.md`
74
75
  - plans
75
76
  - TODOs
76
77
  - roadmap docs
@@ -85,7 +86,7 @@ What each part does:
85
86
  | --- | --- | --- | --- |
86
87
  | `<project>` | `./my-project` / `../repo` | required | Target repo the agent works in. |
87
88
  | `-s, --steps <n>` | `1` / `5` / `10` | required | Max tickets or implementation steps to drive. |
88
- | `-a, --agent <agent>` | `claude` / `codex` | `claude` | Builder agent. |
89
+ | `-a, --agent <agent>` | `claude` / `codex` | single `rafi-config.yaml` target, otherwise `claude` | Builder agent. Explicit `--agent` always wins. |
89
90
  | `-m, --model <model>` | `gpt-5.5` / any supported agent model | agent default | Overrides the builder model. |
90
91
  | `--effort <level>` | Claude Code: `low` / `medium` / `high` / `xhigh`<br>Codex: `low` / `medium` / `high` / `xhigh` | agent default | Reasoning level. |
91
92
  | `--fast` | flag | off | Lower latency. For Codex, maps to `effort=low` when `--effort` is not set. |
@@ -156,10 +157,10 @@ gh repo view <host>/<owner>/<repo>
156
157
  | Option | Common values | Default | Notes |
157
158
  | --- | --- | --- | --- |
158
159
  | `-p, --project <dir>` | `./my-project` / `../repo` | current directory | Target repo with `.tickets/`. |
159
- | `-a, --agent <agent>` | `claude` / `codex` | `claude` | Builder agent. |
160
+ | `-a, --agent <agent>` | `claude` / `codex` | single `rafi-config.yaml` target, otherwise `claude` | Builder agent. Explicit `--agent` always wins. |
160
161
  | `-m, --model <model>` | `gpt-5.5` / any supported agent model | agent default | Overrides the builder model. |
161
162
  | `--effort <level>` | Claude Code: `low` / `medium` / `high` / `xhigh`<br>Codex: `low` / `medium` / `high` / `xhigh` | agent default | Reasoning level. |
162
- | `--sources <paths...>` | `docs/tickets.md docs/plans/**` | scans relevant docs automatically | Optional files, folders, or globs for the agent to check first. Any reasonable planning format is OK. |
163
+ | `--sources <paths...>` | `docs/tickets.md docs/plans/**` | Configured Rafi `<docs.root>/rafi-plan.md`, then ticket docs root, then `docs/rafi-plan.md`, otherwise scans | Optional files, folders, or globs for the agent to check first. Any reasonable planning format is OK. |
163
164
  | `--fast` | flag | off | Lower latency. |
164
165
  | `-y, --yes` | flag | off | Skips confirmation before builder edits ticket files. |
165
166
 
@@ -188,17 +189,25 @@ Without tickets, Foreman still works with:
188
189
 
189
190
  Claude Code:
190
191
 
191
- - Default agent.
192
+ - Default agent when no single runtime target is configured.
192
193
  - Uses your existing Claude Code credentials.
193
194
  - Runs through the Claude Agent SDK.
194
- - `start` and `tickets populate` check Claude auth before launching the builder and prompt you to repair it if needed.
195
+ - `start` and `tickets populate` check Claude auth before launching the builder and prompt you to repair it, switch to Codex for the current run, or cancel when interactive.
195
196
 
196
197
  Codex:
197
198
 
198
- - Use `--agent codex`.
199
+ - Use `--agent codex`, or set `harness.targets: ["codex"]` in `rafi-config.yaml` and omit `--agent`.
199
200
  - Shells out to `codex exec`.
200
201
  - Requires the `codex` CLI on your `PATH`.
201
- - `start` and `tickets populate --agent codex` check Codex auth before launching the builder and prompt you to repair it if needed.
202
+ - `start` and `tickets populate --agent codex` check Codex auth before launching the builder and prompt you to repair it, switch to Claude for the current run, or cancel when interactive.
203
+
204
+ Runtime defaults and recovery:
205
+
206
+ - If `--agent` is omitted, Foreman reads `rafi-config.yaml`. A single `harness.targets` value selects that runtime; missing config or both targets default to Claude.
207
+ - Non-interactive and `--yes` runs fail clearly instead of switching runtimes automatically.
208
+ - Resume and continue flows do not offer runtime switching because session IDs are runtime-specific.
209
+ - If an interactive command switches providers, a provider-specific `--model` override is ignored for that run. `--effort` and `--fast` remain in effect.
210
+ - Cancel stops the command and keeps project files, generated files, and installed packages in place.
202
211
 
203
212
  Task file:
204
213
 
@@ -275,7 +284,7 @@ With `--docs-root docs-rafi`, the generated tracker docs are written under `docs
275
284
 
276
285
  If the project already has planning material:
277
286
 
278
- - Ask a builder to convert it into Foreman's ticket format.
287
+ - Ask the ticket-maker role to convert it into Foreman's ticket format.
279
288
 
280
289
  Supported source material can include:
281
290
 
@@ -289,6 +298,7 @@ Supported source material can include:
289
298
  ```bash
290
299
  # Claude Code
291
300
  ai-foreman tickets populate --project ./my-project
301
+ ai-foreman tickets populate --project ./my-project --sources docs/rafi-plan.md
292
302
  ai-foreman tickets populate --project ./my-project --sources docs/tickets.md docs/plans/**
293
303
 
294
304
  # Codex
@@ -300,6 +310,7 @@ ai-foreman tickets populate --project ./my-project --agent codex --model gpt-5.5
300
310
 
301
311
  - read `.tickets/*`
302
312
  - read the configured progress doc
313
+ - prefer the configured Rafi `<docs.root>/rafi-plan.md`, then the ticket docs root, then `docs/rafi-plan.md` when no `--sources` are provided
303
314
  - read existing planning files
304
315
  - fill `.tickets/tickets.yaml`
305
316
  - render the progress doc
@@ -527,7 +538,8 @@ For Claude:
527
538
  For Codex:
528
539
 
529
540
  - Codex tool calls are not currently intercepted by Foreman.
530
- - Codex runs under `codex exec --sandbox workspace-write`.
541
+ - Runtime implementation and ticket population runs use `codex exec --sandbox workspace-write`.
542
+ - Read-only callers such as `rafi plan` can request `codex exec --sandbox read-only`.
531
543
  - Codex safety depends on Codex's own sandboxing.
532
544
 
533
545
  Default config:
@@ -21,7 +21,7 @@ export declare function parseCodexLine(raw: Record<string, unknown>): CodexLineR
21
21
  * subsequent turns.
22
22
  *
23
23
  * NOTE: opts.permission is accepted for interface conformance but is not
24
- * invoked. Codex manages its own tool sandboxing via --sandbox workspace-write.
24
+ * invoked. Codex manages its own tool sandboxing via --sandbox.
25
25
  */
26
26
  export declare class CodexAdapter implements BuilderAdapter {
27
27
  private readonly opts;
@@ -39,6 +39,7 @@ export declare class CodexAdapter implements BuilderAdapter {
39
39
  buildInstruction(instruction: string): string;
40
40
  /** Build the `codex exec` argument list for the given instruction. */
41
41
  buildArgs(instruction: string): string[];
42
+ private buildSkillsAppendix;
42
43
  sendTurn(instruction: string): Promise<TurnResult>;
43
44
  sessionId(): string | undefined;
44
45
  events(): AsyncIterable<BuilderEvent>;
@@ -1,5 +1,8 @@
1
1
  import { spawn } from "node:child_process";
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { join } from "node:path";
2
4
  import { createInterface } from "node:readline";
5
+ import { loadSkill } from "special-agents";
3
6
  import { AsyncQueue } from "../util/asyncQueue.js";
4
7
  import { normalizeRuntimeErrorText } from "../runtimeAuth.js";
5
8
  /**
@@ -44,7 +47,7 @@ export function parseCodexLine(raw) {
44
47
  * subsequent turns.
45
48
  *
46
49
  * NOTE: opts.permission is accepted for interface conformance but is not
47
- * invoked. Codex manages its own tool sandboxing via --sandbox workspace-write.
50
+ * invoked. Codex manages its own tool sandboxing via --sandbox.
48
51
  */
49
52
  export class CodexAdapter {
50
53
  opts;
@@ -63,13 +66,22 @@ export class CodexAdapter {
63
66
  * role guidance must ride along with each turn.
64
67
  */
65
68
  buildInstruction(instruction) {
66
- return this.opts.systemPromptAppend
67
- ? `${this.opts.systemPromptAppend}\n\n${instruction}`
68
- : instruction;
69
+ return [
70
+ this.opts.systemPromptAppend,
71
+ this.buildSkillsAppendix(),
72
+ instruction,
73
+ ].filter((part) => Boolean(part)).join("\n\n");
69
74
  }
70
75
  /** Build the `codex exec` argument list for the given instruction. */
71
76
  buildArgs(instruction) {
72
- const args = ["exec", "--json", "--sandbox", "workspace-write", "-C", this.opts.cwd];
77
+ const args = [
78
+ "exec",
79
+ "--json",
80
+ "--sandbox",
81
+ this.opts.sandboxMode ?? "workspace-write",
82
+ "-C",
83
+ this.opts.cwd,
84
+ ];
73
85
  if (this.opts.model)
74
86
  args.push("-m", this.opts.model);
75
87
  if (this.opts.effort) {
@@ -84,6 +96,20 @@ export class CodexAdapter {
84
96
  args.push(instruction);
85
97
  return args;
86
98
  }
99
+ buildSkillsAppendix() {
100
+ if (!this.opts.skills || this.opts.skills.length === 0)
101
+ return undefined;
102
+ const blocks = this.opts.skills
103
+ .map((skill) => loadSkillMarkdown(this.opts.cwd, skill))
104
+ .filter((block) => Boolean(block));
105
+ if (blocks.length === 0)
106
+ return undefined;
107
+ return [
108
+ "# Preloaded Skills",
109
+ "Use the following skills for this run.",
110
+ ...blocks,
111
+ ].join("\n\n");
112
+ }
87
113
  sendTurn(instruction) {
88
114
  if (this._closed)
89
115
  return Promise.reject(new Error("builder is closed"));
@@ -158,3 +184,17 @@ export class CodexAdapter {
158
184
  return Promise.resolve();
159
185
  }
160
186
  }
187
+ function loadSkillMarkdown(cwd, skill) {
188
+ const projectPath = join(cwd, ".agents", "skills", skill, "SKILL.md");
189
+ if (existsSync(projectPath)) {
190
+ return `## ${skill}\n${readFileSync(projectPath, "utf8").trim()}`;
191
+ }
192
+ try {
193
+ const bundled = loadSkill(skill);
194
+ const body = bundled.body?.trim();
195
+ return body ? `## ${bundled.name}\n${body}` : undefined;
196
+ }
197
+ catch {
198
+ return undefined;
199
+ }
200
+ }
@@ -54,6 +54,8 @@ export interface BuilderAdapterOptions {
54
54
  effort?: EffortLevel;
55
55
  /** Fast mode: lower latency at the cost of some quality. */
56
56
  fast?: boolean;
57
+ /** Codex sandbox mode. Defaults to workspace-write for implementation runs. */
58
+ sandboxMode?: "workspace-write" | "read-only";
57
59
  /** Role system text to append to the harness system prompt (from .rafi/compiled or library). */
58
60
  systemPromptAppend?: string;
59
61
  /** Skill names to preload for this session (Claude: lazy-loaded; Codex: flattened). */
@@ -0,0 +1,55 @@
1
+ import { type PermissionConfig } from "./config.js";
2
+ import { Log } from "./log.js";
3
+ import { type StepStatus } from "./foreman.js";
4
+ import type { BuilderAdapter, BuilderAdapterOptions, EffortLevel, TurnResult } from "./adapters/types.js";
5
+ import { type RoleBundle } from "./roles.js";
6
+ import type { AgentRuntime } from "./runtimeAuth.js";
7
+ export type { EffortLevel } from "./adapters/types.js";
8
+ export declare const VALID_EFFORT: readonly EffortLevel[];
9
+ export interface RoleBuilderOptions {
10
+ projectDir: string;
11
+ role: string;
12
+ agent?: string;
13
+ model?: string;
14
+ effort?: EffortLevel;
15
+ fast?: boolean;
16
+ yes?: boolean;
17
+ allowSwitch?: boolean;
18
+ label: string;
19
+ log: Log;
20
+ permissionConfig?: PermissionConfig;
21
+ extraSkills?: string[];
22
+ sandboxMode?: BuilderAdapterOptions["sandboxMode"];
23
+ resumeSessionId?: string;
24
+ }
25
+ export interface RoleBuilder {
26
+ builder: BuilderAdapter;
27
+ runtime: AgentRuntime;
28
+ model?: string;
29
+ effort?: EffortLevel;
30
+ roleBundle: RoleBundle;
31
+ skills: string[];
32
+ }
33
+ export interface RoleInstructionRunOptions extends Omit<RoleBuilderOptions, "log"> {
34
+ instruction: string;
35
+ logPath?: string;
36
+ logEvent?: "rafi-plan" | "ticket-populate" | "preflight";
37
+ }
38
+ export interface RoleInstructionRunResult {
39
+ turn: {
40
+ result: TurnResult;
41
+ status: StepStatus;
42
+ };
43
+ runtime: AgentRuntime;
44
+ model?: string;
45
+ effort?: EffortLevel;
46
+ logPath: string;
47
+ roleBundle: RoleBundle;
48
+ skills: string[];
49
+ }
50
+ export declare function assertEffortLevel(effort: string | undefined): asserts effort is EffortLevel | undefined;
51
+ export declare function makeLogPath(projectDir: string, label: string): string;
52
+ export declare function mergeSkills(roleSkills: readonly string[], extraSkills?: readonly string[]): string[];
53
+ export declare function readOnlyPermissionConfig(): PermissionConfig;
54
+ export declare function createRoleBuilder(opts: RoleBuilderOptions): Promise<RoleBuilder>;
55
+ export declare function runRoleInstruction(opts: RoleInstructionRunOptions): Promise<RoleInstructionRunResult>;
@@ -0,0 +1,151 @@
1
+ import { existsSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { loadConfig, DEFAULT_CONFIG } from "./config.js";
4
+ import { Log } from "./log.js";
5
+ import { PermissionPolicy } from "./permissions/policy.js";
6
+ import { ClaudeAdapter } from "./adapters/claude.js";
7
+ import { CodexAdapter } from "./adapters/codex.js";
8
+ import { Foreman, createPermissionHandler } from "./foreman.js";
9
+ import { printEvents } from "./cli/events.js";
10
+ import { loadRoleBundle } from "./roles.js";
11
+ import { ensureRuntimeReadyForCommand } from "./cli/runtimeAuthPrompt.js";
12
+ import { resolveAgentForProject } from "./cli/runtimeSelection.js";
13
+ export const VALID_EFFORT = ["low", "medium", "high", "xhigh"];
14
+ export function assertEffortLevel(effort) {
15
+ if (effort && !VALID_EFFORT.includes(effort)) {
16
+ throw new Error(`unknown effort "${effort}" - choose: ${VALID_EFFORT.join(" | ")}`);
17
+ }
18
+ }
19
+ export function makeLogPath(projectDir, label) {
20
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
21
+ return join(projectDir, ".foreman", `${stamp}-${label}.jsonl`);
22
+ }
23
+ export function mergeSkills(roleSkills, extraSkills = []) {
24
+ return [...new Set([...roleSkills, ...extraSkills])];
25
+ }
26
+ export function readOnlyPermissionConfig() {
27
+ return {
28
+ allowBash: [
29
+ "pwd",
30
+ "ls",
31
+ "cat ",
32
+ "sed -n ",
33
+ "grep ",
34
+ "rg ",
35
+ "head ",
36
+ "tail ",
37
+ "wc ",
38
+ "git status",
39
+ "git diff --stat",
40
+ "git diff --name-only",
41
+ "git log --oneline",
42
+ "git show --stat",
43
+ "git ls-files",
44
+ "git grep",
45
+ ],
46
+ escalateBash: [
47
+ ...DEFAULT_CONFIG.permissions.escalateBash,
48
+ " --output",
49
+ " -o",
50
+ " --exec",
51
+ " --format",
52
+ "git add",
53
+ "git commit",
54
+ "git checkout",
55
+ "git restore",
56
+ "git stash",
57
+ "npm install",
58
+ "npm run",
59
+ "npm ci",
60
+ "pnpm ",
61
+ "yarn ",
62
+ "mkdir ",
63
+ "touch ",
64
+ "mv ",
65
+ "cp ",
66
+ "rm ",
67
+ "ai-foreman tickets",
68
+ "foreman tickets",
69
+ ],
70
+ strictShellRedirection: true,
71
+ allowTools: ["Read", "Glob", "Grep", "TodoWrite"],
72
+ escalateTools: [
73
+ ...new Set([
74
+ ...DEFAULT_CONFIG.permissions.escalateTools,
75
+ "Edit",
76
+ "Write",
77
+ "MultiEdit",
78
+ "NotebookEdit",
79
+ ]),
80
+ ],
81
+ };
82
+ }
83
+ export async function createRoleBuilder(opts) {
84
+ if (!existsSync(opts.projectDir)) {
85
+ throw new Error(`project directory not found: ${opts.projectDir}`);
86
+ }
87
+ assertEffortLevel(opts.effort);
88
+ let runtime = resolveAgentForProject(opts.projectDir, opts.agent);
89
+ const config = loadConfig(join(opts.projectDir, "foreman.yaml"));
90
+ const roleBundle = loadRoleBundle(opts.role, { projectDir: opts.projectDir });
91
+ const initialModel = opts.model ?? roleBundle.model ?? undefined;
92
+ const effort = opts.effort ?? roleBundle.effort ?? undefined;
93
+ const ready = await ensureRuntimeReadyForCommand(opts.projectDir, runtime, {
94
+ label: opts.label,
95
+ yes: Boolean(opts.yes),
96
+ allowSwitch: opts.allowSwitch,
97
+ model: initialModel,
98
+ });
99
+ runtime = ready.runtime;
100
+ const model = ready.model;
101
+ const policy = new PermissionPolicy(opts.permissionConfig ?? config.permissions, opts.projectDir);
102
+ 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,
113
+ };
114
+ const builder = runtime === "codex"
115
+ ? new CodexAdapter(adapterOpts)
116
+ : await ClaudeAdapter.create(adapterOpts);
117
+ return { builder, runtime, model, effort, roleBundle, skills };
118
+ }
119
+ export async function runRoleInstruction(opts) {
120
+ const logPath = opts.logPath ?? makeLogPath(opts.projectDir, opts.label.replace(/\s+/g, "-"));
121
+ const log = new Log(logPath);
122
+ const { builder, runtime, model, effort, roleBundle, skills } = await createRoleBuilder({
123
+ ...opts,
124
+ log,
125
+ });
126
+ const viewer = printEvents(builder.events());
127
+ const config = loadConfig(join(opts.projectDir, "foreman.yaml"));
128
+ const foreman = new Foreman(builder, log, config.notifications.enabled, false, 3, opts.projectDir);
129
+ try {
130
+ const turn = await foreman.runInstruction(opts.instruction);
131
+ await builder.close();
132
+ await viewer;
133
+ if (opts.logEvent) {
134
+ log.write(opts.logEvent, {
135
+ role: opts.role,
136
+ statusKind: turn.status.kind,
137
+ summary: turn.status.summary,
138
+ reason: turn.status.reason,
139
+ costUsd: turn.result.costUsd,
140
+ isError: turn.result.isError,
141
+ });
142
+ }
143
+ return { turn, runtime, model, effort, logPath, roleBundle, skills };
144
+ }
145
+ catch (err) {
146
+ await builder.close().catch(() => { });
147
+ await viewer.catch(() => { });
148
+ log.write("error", { message: String(err) });
149
+ throw err;
150
+ }
151
+ }
@@ -1,2 +1,21 @@
1
- import { type AgentRuntime } from "../runtimeAuth.js";
2
- export declare function ensureRuntimeReadyForCommand(projectDir: string, runtime: AgentRuntime, label: string): Promise<void>;
1
+ import { RuntimeAuthError, type AgentRuntime } from "../runtimeAuth.js";
2
+ export type RuntimeCommandRecoveryChoice = "retry" | "switch" | "cancel";
3
+ export interface RuntimeCommandRecoveryContext {
4
+ otherRuntime: AgentRuntime;
5
+ allowSwitch: boolean;
6
+ }
7
+ export interface RuntimeReadyForCommandOptions {
8
+ label: string;
9
+ yes?: boolean;
10
+ allowSwitch?: boolean;
11
+ model?: string | undefined;
12
+ check?: (projectDir: string, runtime: AgentRuntime) => void;
13
+ checkClaudeSdk?: () => Promise<void>;
14
+ choose?: (err: RuntimeAuthError, context: RuntimeCommandRecoveryContext) => Promise<RuntimeCommandRecoveryChoice>;
15
+ }
16
+ export interface RuntimeReadyForCommandResult {
17
+ runtime: AgentRuntime;
18
+ model?: string;
19
+ fellBack: boolean;
20
+ }
21
+ export declare function ensureRuntimeReadyForCommand(projectDir: string, runtime: AgentRuntime, labelOrOptions: string | RuntimeReadyForCommandOptions): Promise<RuntimeReadyForCommandResult>;
@@ -1,27 +1,79 @@
1
1
  import { select, isCancel, log } from "@clack/prompts";
2
+ import { requireClaudeSDK } from "../adapters/claude.js";
2
3
  import { checkRuntimeReady, RuntimeAuthError, runtimeCommandLabel, } from "../runtimeAuth.js";
3
- export async function ensureRuntimeReadyForCommand(projectDir, runtime, label) {
4
+ import { otherRuntime, runtimeDisplayName } from "./runtimeSelection.js";
5
+ export async function ensureRuntimeReadyForCommand(projectDir, runtime, labelOrOptions) {
6
+ const opts = typeof labelOrOptions === "string"
7
+ ? { label: labelOrOptions }
8
+ : labelOrOptions;
9
+ const check = opts.check ?? checkRuntimeReady;
10
+ const checkClaudeSdk = opts.checkClaudeSdk ?? requireClaudeSDK;
11
+ const allowSwitch = opts.allowSwitch !== false;
12
+ const nonInteractive = !opts.choose && (Boolean(opts.yes) || !process.stdin.isTTY || !process.stdout.isTTY);
4
13
  while (true) {
5
14
  try {
6
- checkRuntimeReady(projectDir, runtime);
7
- return;
15
+ check(projectDir, runtime);
16
+ return { runtime, model: opts.model, fellBack: false };
8
17
  }
9
18
  catch (err) {
10
19
  const failure = err instanceof RuntimeAuthError
11
20
  ? err
12
- : new RuntimeAuthError({ runtime, context: label, cause: err });
13
- log.error(failure.message);
14
- const choice = await select({
15
- message: `${runtimeCommandLabel(runtime)} is not ready for ${label}. Fix authentication, then retry the check.`,
16
- options: [
17
- { value: "retry", label: "Retry check" },
18
- { value: "cancel", label: "Cancel" },
19
- ],
20
- });
21
- if (isCancel(choice) || choice === "cancel") {
21
+ : new RuntimeAuthError({ runtime, context: opts.label, cause: err });
22
+ if (nonInteractive) {
23
+ throw failure;
24
+ }
25
+ const fallbackRuntime = otherRuntime(runtime);
26
+ const choice = opts.choose
27
+ ? await opts.choose(failure, { otherRuntime: fallbackRuntime, allowSwitch })
28
+ : await promptRuntimeRecovery(failure, opts.label, fallbackRuntime, allowSwitch);
29
+ if (choice === "retry")
30
+ continue;
31
+ if (choice === "switch" && allowSwitch) {
32
+ try {
33
+ check(projectDir, fallbackRuntime);
34
+ if (fallbackRuntime === "claude") {
35
+ await checkClaudeSdk();
36
+ }
37
+ if (opts.model) {
38
+ console.log(`foreman: ignored --model ${opts.model} after switching to ${fallbackRuntime}; model names are provider-specific.`);
39
+ }
40
+ console.log(`foreman: using ${runtimeDisplayName(fallbackRuntime)} for this run.`);
41
+ return { runtime: fallbackRuntime, model: undefined, fellBack: true };
42
+ }
43
+ catch (switchErr) {
44
+ const switchFailure = switchErr instanceof RuntimeAuthError
45
+ ? switchErr
46
+ : new RuntimeAuthError({ runtime: fallbackRuntime, context: opts.label, cause: switchErr });
47
+ log.error(`Fallback runtime is not ready:\n${switchFailure.message}`);
48
+ continue;
49
+ }
50
+ }
51
+ if (choice === "cancel") {
22
52
  console.log("foreman: cancelled");
23
53
  process.exit(0);
24
54
  }
55
+ throw failure;
25
56
  }
26
57
  }
27
58
  }
59
+ async function promptRuntimeRecovery(err, label, fallbackRuntime, allowSwitch) {
60
+ log.error(err.message);
61
+ log.info("Cancel stops this command and keeps project files in place. It does not uninstall packages, delete generated files, or change configuration.");
62
+ if (!allowSwitch) {
63
+ log.info("Switching runtimes is disabled while resuming because session IDs are runtime-specific.");
64
+ }
65
+ const options = [
66
+ { value: "retry", label: "Fix manually and retry check" },
67
+ ...(allowSwitch
68
+ ? [{ value: "switch", label: `Use ${runtimeDisplayName(fallbackRuntime)} for now` }]
69
+ : []),
70
+ { value: "cancel", label: "Cancel - stop here; keep generated files" },
71
+ ];
72
+ const choice = await select({
73
+ message: `${runtimeCommandLabel(err.runtime)} is not ready for ${label}. What should Foreman do?`,
74
+ options,
75
+ });
76
+ if (isCancel(choice))
77
+ return "cancel";
78
+ return choice;
79
+ }
@@ -0,0 +1,9 @@
1
+ import type { AgentRuntime } from "../runtimeAuth.js";
2
+ export declare const VALID_AGENTS: readonly ["claude", "codex"];
3
+ export declare function isAgentRuntime(value: string): value is AgentRuntime;
4
+ export declare function validateAgentRuntime(value: string): AgentRuntime;
5
+ export declare function otherRuntime(runtime: AgentRuntime): AgentRuntime;
6
+ export declare function runtimeDisplayName(runtime: AgentRuntime): string;
7
+ export declare function defaultAgentForProject(projectDir: string): AgentRuntime;
8
+ export declare function resolveAgentForProject(projectDir: string, explicitAgent?: string): AgentRuntime;
9
+ export declare function readRafiRuntimeTargets(projectDir: string): AgentRuntime[] | undefined;
@@ -0,0 +1,48 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { parse as parseYaml } from "yaml";
4
+ export const VALID_AGENTS = ["claude", "codex"];
5
+ export function isAgentRuntime(value) {
6
+ return VALID_AGENTS.includes(value);
7
+ }
8
+ export function validateAgentRuntime(value) {
9
+ if (isAgentRuntime(value))
10
+ return value;
11
+ throw new Error(`unknown agent "${value}" - choose: ${VALID_AGENTS.join(" | ")}`);
12
+ }
13
+ export function otherRuntime(runtime) {
14
+ return runtime === "claude" ? "codex" : "claude";
15
+ }
16
+ export function runtimeDisplayName(runtime) {
17
+ return runtime === "claude" ? "Claude" : "Codex";
18
+ }
19
+ export function defaultAgentForProject(projectDir) {
20
+ const targets = readRafiRuntimeTargets(projectDir);
21
+ return targets?.length === 1 ? targets[0] : "claude";
22
+ }
23
+ export function resolveAgentForProject(projectDir, explicitAgent) {
24
+ if (explicitAgent !== undefined)
25
+ return validateAgentRuntime(explicitAgent);
26
+ return defaultAgentForProject(projectDir);
27
+ }
28
+ export function readRafiRuntimeTargets(projectDir) {
29
+ const configPath = join(projectDir, "rafi-config.yaml");
30
+ if (!existsSync(configPath))
31
+ return undefined;
32
+ try {
33
+ const raw = parseYaml(readFileSync(configPath, "utf8"));
34
+ const targets = raw?.harness?.targets;
35
+ if (!Array.isArray(targets))
36
+ return undefined;
37
+ const out = [];
38
+ for (const target of targets) {
39
+ if ((target === "claude" || target === "codex") && !out.includes(target)) {
40
+ out.push(target);
41
+ }
42
+ }
43
+ return out.length > 0 ? out : undefined;
44
+ }
45
+ catch {
46
+ return undefined;
47
+ }
48
+ }
package/dist/cli/start.js CHANGED
@@ -11,6 +11,7 @@ import { Foreman, createPermissionHandler } from "../foreman.js";
11
11
  import { printEvents } from "./events.js";
12
12
  import { loadRoleBundle } from "../roles.js";
13
13
  import { ensureRuntimeReadyForCommand } from "./runtimeAuthPrompt.js";
14
+ import { resolveAgentForProject } from "./runtimeSelection.js";
14
15
  import { isTicketsInitialized, loadTicketsConfig, resolveTicketPaths } from "../tickets/config.js";
15
16
  import { loadTickets } from "../tickets/ticketLoader.js";
16
17
  import { StateDb } from "../tickets/stateDb.js";
@@ -97,7 +98,7 @@ export function buildStartCommand() {
97
98
  .description("Enlist a builder and drive it through a batch of N steps.")
98
99
  .argument("<project>", "path to the project directory the builder works in")
99
100
  .requiredOption("-s, --steps <n>", "number of steps to drive")
100
- .option("-a, --agent <agent>", "builder agent (claude | codex)", "claude")
101
+ .option("-a, --agent <agent>", "builder agent (claude | codex)")
101
102
  .option("-m, --model <model>", "override the builder's model")
102
103
  .option("-r, --resume <sessionId>", "resume a prior builder session")
103
104
  .option("--continue", "resume the most recent logged session for this project")
@@ -119,10 +120,6 @@ export function buildStartCommand() {
119
120
  if (!Number.isInteger(steps) || steps < 1) {
120
121
  fail("--steps must be a positive integer");
121
122
  }
122
- const VALID_AGENTS = ["claude", "codex"];
123
- if (!VALID_AGENTS.includes(opts.agent)) {
124
- fail(`unknown agent "${opts.agent}" — choose: ${VALID_AGENTS.join(" | ")}`);
125
- }
126
123
  const VALID_EFFORT = ["low", "medium", "high", "xhigh"];
127
124
  if (opts.effort && !VALID_EFFORT.includes(opts.effort)) {
128
125
  fail(`unknown effort "${opts.effort}" — choose: ${VALID_EFFORT.join(" | ")}`);
@@ -130,6 +127,14 @@ export function buildStartCommand() {
130
127
  const cwd = resolve(project);
131
128
  if (!existsSync(cwd))
132
129
  fail(`project directory not found: ${cwd}`);
130
+ let agent;
131
+ try {
132
+ agent = resolveAgentForProject(cwd, opts.agent);
133
+ }
134
+ catch (err) {
135
+ fail(err instanceof Error ? err.message : String(err));
136
+ }
137
+ let model = opts.model;
133
138
  const configuredTrackerPath = isTicketsInitialized(cwd)
134
139
  ? loadTicketsConfig(cwd).paths.progressDoc
135
140
  : undefined;
@@ -193,14 +198,13 @@ export function buildStartCommand() {
193
198
  const stamp = new Date().toISOString().replace(/[:.]/g, "-");
194
199
  const logPath = join(cwd, ".foreman", `${stamp}.jsonl`);
195
200
  const log = new Log(logPath);
196
- const agent = opts.agent;
197
201
  const qaEnabled = opts.qa !== false && config.qa.enabled !== false;
198
202
  const createBuilder = async (builderCwd, sessionId) => {
199
203
  const builderPolicy = new PermissionPolicy(config.permissions, builderCwd);
200
204
  const roleBundle = loadRoleBundle("builder", { projectDir: builderCwd });
201
205
  const adapterOpts = {
202
206
  cwd: builderCwd,
203
- model: opts.model,
207
+ model,
204
208
  resumeSessionId: sessionId,
205
209
  permission: createPermissionHandler(builderPolicy, log),
206
210
  effort: opts.effort,
@@ -224,7 +228,14 @@ export function buildStartCommand() {
224
228
  if (opts.createPr) {
225
229
  await ensureGitHubReadyForCreatePr(cwd, log, Boolean(opts.yes));
226
230
  }
227
- await ensureRuntimeReadyForCommand(cwd, agent, "start");
231
+ const ready = await ensureRuntimeReadyForCommand(cwd, agent, {
232
+ label: "start",
233
+ yes: Boolean(opts.yes),
234
+ allowSwitch: !(opts.resume || opts.continue),
235
+ model,
236
+ });
237
+ agent = ready.runtime;
238
+ model = ready.model;
228
239
  const ticketPaths = resolveTicketPaths(ticketsConfig, cwd);
229
240
  const tickets = loadTickets(ticketPaths.tickets);
230
241
  const db = new StateDb(ticketPaths.stateDb);
@@ -337,7 +348,7 @@ export function buildStartCommand() {
337
348
  plan,
338
349
  log,
339
350
  agent,
340
- model: opts.model,
351
+ model,
341
352
  effort: opts.effort,
342
353
  fast: opts.fast,
343
354
  notificationsEnabled: config.notifications.enabled,
@@ -378,11 +389,18 @@ export function buildStartCommand() {
378
389
  const failed = summaries.some((row) => row.buildStatus === "blocked" || row.buildStatus === "needs-human");
379
390
  process.exit(failed ? 2 : 0);
380
391
  }
381
- await ensureRuntimeReadyForCommand(cwd, agent, "start");
392
+ const ready = await ensureRuntimeReadyForCommand(cwd, agent, {
393
+ label: "start",
394
+ yes: Boolean(opts.yes),
395
+ allowSwitch: !(opts.resume || opts.continue),
396
+ model,
397
+ });
398
+ agent = ready.runtime;
399
+ model = ready.model;
382
400
  const builder = await createBuilder(cwd, resumeSessionId);
383
401
  const foreman = new Foreman(builder, log, config.notifications.enabled, qaEnabled, 3, cwd);
384
402
  const modifiers = [
385
- opts.model ? `model=${opts.model}` : null,
403
+ model ? `model=${model}` : null,
386
404
  opts.effort ? `effort=${opts.effort}` : null,
387
405
  opts.fast ? "fast" : null,
388
406
  qaEnabled ? null : "qa=off",
@@ -1,3 +1,17 @@
1
1
  import { Command } from "commander";
2
+ import { Log } from "../log.js";
3
+ import type { EffortLevel } from "../adapters/types.js";
4
+ import { type RoleBuilderOptions } from "../agentRun.js";
5
+ import type { TicketsConfig } from "../tickets/config.js";
2
6
  export declare function buildPopulateInstruction(sourceHints?: string[], progressDoc?: string): string;
7
+ export declare function resolvePopulateSources(projectDir: string, explicitSources: string[] | undefined, ticketsConfig: TicketsConfig): string[] | undefined;
8
+ export declare function buildPopulateAgentRunOptions(opts: {
9
+ projectDir: string;
10
+ agent?: string;
11
+ model?: string;
12
+ effort?: EffortLevel;
13
+ fast?: boolean;
14
+ yes?: boolean;
15
+ log: Log;
16
+ }): RoleBuilderOptions;
3
17
  export declare function buildTicketsCommand(): Command;
@@ -1,19 +1,18 @@
1
1
  import { Command } from "commander";
2
- import { existsSync } from "node:fs";
3
- import { isAbsolute, join, resolve } from "node:path";
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { dirname, isAbsolute, join, resolve } from "node:path";
4
+ import { parse as parseYaml } from "yaml";
4
5
  import { select, isCancel } from "@clack/prompts";
5
6
  import { loadConfig } from "../config.js";
6
7
  import { Log } from "../log.js";
7
- import { PermissionPolicy } from "../permissions/policy.js";
8
- import { ClaudeAdapter } from "../adapters/claude.js";
9
- import { CodexAdapter } from "../adapters/codex.js";
10
- import { Foreman, createPermissionHandler } from "../foreman.js";
8
+ import { Foreman } from "../foreman.js";
11
9
  import { printEvents } from "./events.js";
12
10
  import { cmdInit, cmdUpdate, cmdComplete, cmdBlock, cmdUnblock, cmdCancel, cmdDiscover, cmdAcceptFutureWork, cmdReorder, cmdRender, cmdValidate, cmdQueue, cmdArchive, } from "../tickets/commands.js";
13
11
  import { isTicketsInitialized, loadTicketsConfig } from "../tickets/config.js";
12
+ import { validateDocsRoot } from "../tickets/config.js";
14
13
  import { importFromMarkdown } from "../tickets/importer.js";
15
14
  import { formatValidationIssues } from "../tickets/validate.js";
16
- import { ensureRuntimeReadyForCommand } from "./runtimeAuthPrompt.js";
15
+ import { assertEffortLevel, createRoleBuilder, makeLogPath, } from "../agentRun.js";
17
16
  function fail(msg) {
18
17
  console.error(`foreman tickets: ${msg}`);
19
18
  process.exit(1);
@@ -21,21 +20,18 @@ function fail(msg) {
21
20
  function cwd(opts) {
22
21
  return resolve(opts.project ?? ".");
23
22
  }
24
- const VALID_AGENTS = ["claude", "codex"];
25
- const VALID_EFFORT = ["low", "medium", "high", "xhigh"];
26
- function validateAgent(agent) {
27
- if (!VALID_AGENTS.includes(agent)) {
28
- fail(`unknown agent "${agent}" — choose: ${VALID_AGENTS.join(" | ")}`);
29
- }
23
+ function unique(values) {
24
+ return [...new Set(values)];
30
25
  }
26
+ const TICKET_POPULATE_ROLE = "ticket-maker";
27
+ const RAFI_CONFIG_FILES = ["rafi-config.yaml", "project.yaml"];
31
28
  function validateEffort(effort) {
32
- if (effort && !VALID_EFFORT.includes(effort)) {
33
- fail(`unknown effort "${effort}" — choose: ${VALID_EFFORT.join(" | ")}`);
29
+ try {
30
+ assertEffortLevel(effort);
31
+ }
32
+ catch (err) {
33
+ fail(err instanceof Error ? err.message : String(err));
34
34
  }
35
- }
36
- function makeLogPath(projectDir, label) {
37
- const stamp = new Date().toISOString().replace(/[:.]/g, "-");
38
- return join(projectDir, ".foreman", `${stamp}-${label}.jsonl`);
39
35
  }
40
36
  export function buildPopulateInstruction(sourceHints, progressDoc = "docs/ticket-progress.md") {
41
37
  const sources = sourceHints ?? [];
@@ -86,6 +82,49 @@ STEP_STATUS: done | summary="populated Foreman tickets from existing project tic
86
82
  or
87
83
  STEP_STATUS: blocked | reason="why ticket population cannot proceed"`;
88
84
  }
85
+ export function resolvePopulateSources(projectDir, explicitSources, ticketsConfig) {
86
+ if (explicitSources && explicitSources.length > 0)
87
+ return explicitSources;
88
+ const rafiPlan = resolveConfiguredRafiPlanSource(projectDir, "rafi-config.yaml");
89
+ if (rafiPlan)
90
+ return [rafiPlan];
91
+ const legacyPlan = resolveConfiguredRafiPlanSource(projectDir, "project.yaml");
92
+ if (legacyPlan)
93
+ return [legacyPlan];
94
+ const candidates = unique([
95
+ join(dirname(ticketsConfig.paths.progressDoc), "rafi-plan.md"),
96
+ "docs/rafi-plan.md",
97
+ ]);
98
+ const plan = candidates.find((candidate) => existsSync(join(projectDir, candidate)));
99
+ return plan ? [plan] : undefined;
100
+ }
101
+ function resolveConfiguredRafiPlanSource(projectDir, file) {
102
+ const configPath = join(projectDir, file);
103
+ if (!existsSync(configPath))
104
+ return undefined;
105
+ const raw = parseYaml(readFileSync(configPath, "utf8"));
106
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
107
+ throw new Error(`${file}: expected a YAML object`);
108
+ }
109
+ const docs = raw.docs;
110
+ if (typeof docs?.root !== "string")
111
+ return undefined;
112
+ const candidate = `${validateDocsRoot(projectDir, docs.root)}/rafi-plan.md`;
113
+ return existsSync(join(projectDir, candidate)) ? candidate : undefined;
114
+ }
115
+ export function buildPopulateAgentRunOptions(opts) {
116
+ return {
117
+ projectDir: opts.projectDir,
118
+ role: TICKET_POPULATE_ROLE,
119
+ agent: opts.agent,
120
+ model: opts.model,
121
+ effort: opts.effort,
122
+ fast: opts.fast,
123
+ yes: opts.yes,
124
+ label: "tickets populate",
125
+ log: opts.log,
126
+ };
127
+ }
89
128
  export function buildTicketsCommand() {
90
129
  const tickets = new Command("tickets").description("Manage the structured ticket tracker for a project.");
91
130
  // ── init ────────────────────────────────────────────────────────────────────
@@ -116,9 +155,9 @@ export function buildTicketsCommand() {
116
155
  // ── populate ────────────────────────────────────────────────────────────────
117
156
  tickets
118
157
  .command("populate")
119
- .description("Ask a builder to populate .tickets/tickets.yaml from existing project ticket/backlog docs.")
158
+ .description("Ask the ticket-maker role to populate .tickets/tickets.yaml from existing project ticket/backlog docs.")
120
159
  .option("-p, --project <dir>", "project directory (default: cwd)")
121
- .option("-a, --agent <agent>", "builder agent (claude | codex)", "claude")
160
+ .option("-a, --agent <agent>", "builder agent (claude | codex)")
122
161
  .option("-m, --model <model>", "override the builder's model")
123
162
  .option("--effort <level>", "reasoning effort level (low|medium|high|xhigh)")
124
163
  .option("--sources <paths...>", "source hint files, folders, or globs to check first")
@@ -126,8 +165,6 @@ export function buildTicketsCommand() {
126
165
  .option("-y, --yes", "skip confirmation prompt before letting the builder edit tickets")
127
166
  .action(async (opts) => {
128
167
  const dir = cwd(opts);
129
- const agent = opts.agent;
130
- validateAgent(agent);
131
168
  validateEffort(opts.effort);
132
169
  if (!existsSync(dir))
133
170
  fail(`project directory not found: ${dir}`);
@@ -147,29 +184,34 @@ export function buildTicketsCommand() {
147
184
  process.exit(0);
148
185
  }
149
186
  }
150
- const config = loadConfig(join(dir, "foreman.yaml"));
151
187
  const logPath = makeLogPath(dir, "tickets-populate");
152
188
  const log = new Log(logPath);
153
- const policy = new PermissionPolicy(config.permissions, dir);
154
- await ensureRuntimeReadyForCommand(dir, agent, "tickets populate");
155
- const adapterOpts = {
156
- cwd: dir,
157
- model: opts.model,
158
- permission: createPermissionHandler(policy, log),
159
- effort: opts.effort,
160
- fast: opts.fast,
161
- };
162
- const builder = agent === "codex"
163
- ? new CodexAdapter(adapterOpts)
164
- : await ClaudeAdapter.create(adapterOpts);
165
- const viewer = printEvents(builder.events());
166
- const foreman = new Foreman(builder, log, config.notifications.enabled, false, 3, dir);
167
- console.log(`foreman tickets: populating tickets with ${agent}`);
168
- console.log(`foreman tickets: project ${dir}`);
169
- console.log(`foreman tickets: log ${logPath}\n`);
189
+ const config = loadConfig(join(dir, "foreman.yaml"));
190
+ let builder;
191
+ let viewer;
170
192
  try {
193
+ const roleBuilder = await createRoleBuilder(buildPopulateAgentRunOptions({
194
+ projectDir: dir,
195
+ agent: opts.agent,
196
+ model: opts.model,
197
+ effort: opts.effort,
198
+ fast: opts.fast,
199
+ yes: Boolean(opts.yes),
200
+ log,
201
+ }));
202
+ builder = roleBuilder.builder;
203
+ viewer = printEvents(builder.events());
204
+ const foreman = new Foreman(builder, log, config.notifications.enabled, false, 3, dir);
205
+ console.log(`foreman tickets: populating tickets with ${roleBuilder.runtime}`);
206
+ console.log(`foreman tickets: project ${dir}`);
207
+ console.log(`foreman tickets: role ${TICKET_POPULATE_ROLE}`);
208
+ console.log(`foreman tickets: log ${logPath}\n`);
171
209
  const ticketsConfig = loadTicketsConfig(dir);
172
- const turn = await foreman.runInstruction(buildPopulateInstruction(opts.sources, ticketsConfig.paths.progressDoc));
210
+ const sourceHints = resolvePopulateSources(dir, opts.sources, ticketsConfig);
211
+ if (sourceHints?.length) {
212
+ console.log(`foreman tickets: source hints ${sourceHints.join(" ")}`);
213
+ }
214
+ const turn = await foreman.runInstruction(buildPopulateInstruction(sourceHints, ticketsConfig.paths.progressDoc));
173
215
  await builder.close();
174
216
  await viewer;
175
217
  log.write("ticket-populate", {
@@ -204,7 +246,8 @@ export function buildTicketsCommand() {
204
246
  console.log(`foreman tickets: populated .tickets/tickets.yaml and rendered ${ticketsConfig.paths.progressDoc}`);
205
247
  }
206
248
  catch (err) {
207
- await builder.close().catch(() => { });
249
+ await builder?.close().catch(() => { });
250
+ await viewer?.catch(() => { });
208
251
  fail(String(err instanceof Error ? err.message : err));
209
252
  }
210
253
  });
package/dist/config.d.ts CHANGED
@@ -3,6 +3,8 @@ export interface PermissionConfig {
3
3
  allowBash: string[];
4
4
  /** Bash command substrings that always escalate (checked first). */
5
5
  escalateBash: string[];
6
+ /** Escalate shell redirection even when it is attached to an allowed command. */
7
+ strictShellRedirection?: boolean;
6
8
  /** Tool names auto-approved when their target stays inside the worktree. */
7
9
  allowTools: string[];
8
10
  /** Tool names that always escalate. */
package/dist/config.js CHANGED
@@ -98,6 +98,9 @@ function validateConfig(raw, path) {
98
98
  throw new Error(`${path}: permissions.${key} must be an array of strings`);
99
99
  }
100
100
  }
101
+ if (permissions.strictShellRedirection !== undefined && typeof permissions.strictShellRedirection !== "boolean") {
102
+ throw new Error(`${path}: permissions.strictShellRedirection must be a boolean`);
103
+ }
101
104
  }
102
105
  if (cfg.notifications !== undefined) {
103
106
  validateBooleanObject(cfg.notifications, "notifications", path);
package/dist/log.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  /** A single structured log record. */
2
2
  export interface LogRecord {
3
3
  ts: string;
4
- event: "batch-start" | "batch-end" | "branch-plan" | "branch-start" | "branch-session" | "branch-resume" | "branch-issue" | "branch-complete" | "branch-push" | "github-readiness-failed" | "pr-created" | "pr-existing" | "pr-failed" | "step" | "permission" | "escalation" | "needs_input" | "preflight" | "ticket-populate" | "qa" | "qa-fix" | "error";
4
+ event: "batch-start" | "batch-end" | "branch-plan" | "branch-start" | "branch-session" | "branch-resume" | "branch-issue" | "branch-complete" | "branch-push" | "github-readiness-failed" | "pr-created" | "pr-existing" | "pr-failed" | "step" | "permission" | "escalation" | "needs_input" | "preflight" | "rafi-plan" | "ticket-populate" | "qa" | "qa-fix" | "error";
5
5
  [key: string]: unknown;
6
6
  }
7
7
  /** Append-only JSONL logger. One line per record, plus an echo to the console. */
@@ -4,10 +4,12 @@ const FILE_TOOLS = new Set(["Edit", "Write", "MultiEdit", "NotebookEdit"]);
4
4
  /** Shell operators we split on to vet each segment of a chained command. */
5
5
  const SHELL_SPLIT = /\s*(?:&&|\|\||;|\|)\s*/;
6
6
  /** Shell features that make a prefix allow-list too easy to bypass. */
7
- const ESCALATE_SHELL_SYNTAX = [
8
- { pattern: /\s\d?>|\s>>|\s</, reason: "shell redirection is not auto-approved" },
9
- { pattern: /\$\(|`/, reason: "shell substitution is not auto-approved" },
10
- ];
7
+ const SHELL_REDIRECTION = {
8
+ standard: /\s\d?>|\s>>|\s</,
9
+ strict: /(?:^|[^\\])(?:\d+)?(?:>>?|<<?)/,
10
+ reason: "shell redirection is not auto-approved",
11
+ };
12
+ const SHELL_SUBSTITUTION = { pattern: /\$\(|`/, reason: "shell substitution is not auto-approved" };
11
13
  /**
12
14
  * Rules-based permission classifier. No LLM: routine requests are auto-approved,
13
15
  * anything risky or unrecognized escalates to the human (fail safe).
@@ -51,7 +53,13 @@ export class PermissionPolicy {
51
53
  if (hitEscalate) {
52
54
  return { decision: "escalate", reason: `command matches "${hitEscalate}"` };
53
55
  }
54
- const syntax = ESCALATE_SHELL_SYNTAX.find((s) => s.pattern.test(command));
56
+ const syntax = [
57
+ {
58
+ pattern: this.config.strictShellRedirection ? SHELL_REDIRECTION.strict : SHELL_REDIRECTION.standard,
59
+ reason: SHELL_REDIRECTION.reason,
60
+ },
61
+ SHELL_SUBSTITUTION,
62
+ ].find((s) => s.pattern.test(command));
55
63
  if (syntax) {
56
64
  return { decision: "escalate", reason: syntax.reason };
57
65
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-foreman",
3
- "version": "1.1.0",
3
+ "version": "1.3.0",
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",
@@ -14,9 +14,13 @@
14
14
  },
15
15
  "homepage": "https://github.com/ttante/rafi/tree/main/packages/ai-foreman#readme",
16
16
  "bin": {
17
- "ai-foreman": "./dist/index.js"
17
+ "ai-foreman": "dist/index.js"
18
18
  },
19
19
  "exports": {
20
+ "./agent-run.js": {
21
+ "types": "./dist/agentRun.d.ts",
22
+ "default": "./dist/agentRun.js"
23
+ },
20
24
  "./cli/tickets.js": {
21
25
  "types": "./dist/cli/tickets.d.ts",
22
26
  "default": "./dist/cli/tickets.js"