ai-foreman 1.2.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 +9 -5
- package/dist/adapters/codex.d.ts +2 -1
- package/dist/adapters/codex.js +45 -5
- package/dist/adapters/types.d.ts +2 -0
- package/dist/agentRun.d.ts +55 -0
- package/dist/agentRun.js +151 -0
- package/dist/cli/tickets.d.ts +14 -0
- package/dist/cli/tickets.js +86 -50
- package/dist/config.d.ts +2 -0
- package/dist/config.js +3 -0
- package/dist/log.d.ts +1 -1
- package/dist/permissions/policy.js +13 -5
- package/package.json +5 -1
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
|
|
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
|
|
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
|
|
@@ -159,7 +160,7 @@ gh repo view <host>/<owner>/<repo>
|
|
|
159
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/**` |
|
|
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
|
|
|
@@ -283,7 +284,7 @@ With `--docs-root docs-rafi`, the generated tracker docs are written under `docs
|
|
|
283
284
|
|
|
284
285
|
If the project already has planning material:
|
|
285
286
|
|
|
286
|
-
- Ask
|
|
287
|
+
- Ask the ticket-maker role to convert it into Foreman's ticket format.
|
|
287
288
|
|
|
288
289
|
Supported source material can include:
|
|
289
290
|
|
|
@@ -297,6 +298,7 @@ Supported source material can include:
|
|
|
297
298
|
```bash
|
|
298
299
|
# Claude Code
|
|
299
300
|
ai-foreman tickets populate --project ./my-project
|
|
301
|
+
ai-foreman tickets populate --project ./my-project --sources docs/rafi-plan.md
|
|
300
302
|
ai-foreman tickets populate --project ./my-project --sources docs/tickets.md docs/plans/**
|
|
301
303
|
|
|
302
304
|
# Codex
|
|
@@ -308,6 +310,7 @@ ai-foreman tickets populate --project ./my-project --agent codex --model gpt-5.5
|
|
|
308
310
|
|
|
309
311
|
- read `.tickets/*`
|
|
310
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
|
|
311
314
|
- read existing planning files
|
|
312
315
|
- fill `.tickets/tickets.yaml`
|
|
313
316
|
- render the progress doc
|
|
@@ -535,7 +538,8 @@ For Claude:
|
|
|
535
538
|
For Codex:
|
|
536
539
|
|
|
537
540
|
- Codex tool calls are not currently intercepted by Foreman.
|
|
538
|
-
-
|
|
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`.
|
|
539
543
|
- Codex safety depends on Codex's own sandboxing.
|
|
540
544
|
|
|
541
545
|
Default config:
|
package/dist/adapters/codex.d.ts
CHANGED
|
@@ -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
|
|
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>;
|
package/dist/adapters/codex.js
CHANGED
|
@@ -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
|
|
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
|
|
67
|
-
|
|
68
|
-
|
|
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 = [
|
|
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
|
+
}
|
package/dist/adapters/types.d.ts
CHANGED
|
@@ -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>;
|
package/dist/agentRun.js
ADDED
|
@@ -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
|
+
}
|
package/dist/cli/tickets.d.ts
CHANGED
|
@@ -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;
|
package/dist/cli/tickets.js
CHANGED
|
@@ -1,20 +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 {
|
|
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 {
|
|
17
|
-
import { resolveAgentForProject } from "./runtimeSelection.js";
|
|
15
|
+
import { assertEffortLevel, createRoleBuilder, makeLogPath, } from "../agentRun.js";
|
|
18
16
|
function fail(msg) {
|
|
19
17
|
console.error(`foreman tickets: ${msg}`);
|
|
20
18
|
process.exit(1);
|
|
@@ -22,15 +20,18 @@ function fail(msg) {
|
|
|
22
20
|
function cwd(opts) {
|
|
23
21
|
return resolve(opts.project ?? ".");
|
|
24
22
|
}
|
|
25
|
-
|
|
23
|
+
function unique(values) {
|
|
24
|
+
return [...new Set(values)];
|
|
25
|
+
}
|
|
26
|
+
const TICKET_POPULATE_ROLE = "ticket-maker";
|
|
27
|
+
const RAFI_CONFIG_FILES = ["rafi-config.yaml", "project.yaml"];
|
|
26
28
|
function validateEffort(effort) {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
+
try {
|
|
30
|
+
assertEffortLevel(effort);
|
|
31
|
+
}
|
|
32
|
+
catch (err) {
|
|
33
|
+
fail(err instanceof Error ? err.message : String(err));
|
|
29
34
|
}
|
|
30
|
-
}
|
|
31
|
-
function makeLogPath(projectDir, label) {
|
|
32
|
-
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
33
|
-
return join(projectDir, ".foreman", `${stamp}-${label}.jsonl`);
|
|
34
35
|
}
|
|
35
36
|
export function buildPopulateInstruction(sourceHints, progressDoc = "docs/ticket-progress.md") {
|
|
36
37
|
const sources = sourceHints ?? [];
|
|
@@ -81,6 +82,49 @@ STEP_STATUS: done | summary="populated Foreman tickets from existing project tic
|
|
|
81
82
|
or
|
|
82
83
|
STEP_STATUS: blocked | reason="why ticket population cannot proceed"`;
|
|
83
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
|
+
}
|
|
84
128
|
export function buildTicketsCommand() {
|
|
85
129
|
const tickets = new Command("tickets").description("Manage the structured ticket tracker for a project.");
|
|
86
130
|
// ── init ────────────────────────────────────────────────────────────────────
|
|
@@ -111,7 +155,7 @@ export function buildTicketsCommand() {
|
|
|
111
155
|
// ── populate ────────────────────────────────────────────────────────────────
|
|
112
156
|
tickets
|
|
113
157
|
.command("populate")
|
|
114
|
-
.description("Ask
|
|
158
|
+
.description("Ask the ticket-maker role to populate .tickets/tickets.yaml from existing project ticket/backlog docs.")
|
|
115
159
|
.option("-p, --project <dir>", "project directory (default: cwd)")
|
|
116
160
|
.option("-a, --agent <agent>", "builder agent (claude | codex)")
|
|
117
161
|
.option("-m, --model <model>", "override the builder's model")
|
|
@@ -124,14 +168,6 @@ export function buildTicketsCommand() {
|
|
|
124
168
|
validateEffort(opts.effort);
|
|
125
169
|
if (!existsSync(dir))
|
|
126
170
|
fail(`project directory not found: ${dir}`);
|
|
127
|
-
let agent;
|
|
128
|
-
try {
|
|
129
|
-
agent = resolveAgentForProject(dir, opts.agent);
|
|
130
|
-
}
|
|
131
|
-
catch (err) {
|
|
132
|
-
fail(err instanceof Error ? err.message : String(err));
|
|
133
|
-
}
|
|
134
|
-
let model = opts.model;
|
|
135
171
|
if (!isTicketsInitialized(dir)) {
|
|
136
172
|
fail(`ticket tracker is not initialized in ${dir}; run \`foreman tickets init --project ${dir}\` first`);
|
|
137
173
|
}
|
|
@@ -148,35 +184,34 @@ export function buildTicketsCommand() {
|
|
|
148
184
|
process.exit(0);
|
|
149
185
|
}
|
|
150
186
|
}
|
|
151
|
-
const config = loadConfig(join(dir, "foreman.yaml"));
|
|
152
187
|
const logPath = makeLogPath(dir, "tickets-populate");
|
|
153
188
|
const log = new Log(logPath);
|
|
154
|
-
const
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
yes: Boolean(opts.yes),
|
|
158
|
-
model,
|
|
159
|
-
});
|
|
160
|
-
agent = ready.runtime;
|
|
161
|
-
model = ready.model;
|
|
162
|
-
const adapterOpts = {
|
|
163
|
-
cwd: dir,
|
|
164
|
-
model,
|
|
165
|
-
permission: createPermissionHandler(policy, log),
|
|
166
|
-
effort: opts.effort,
|
|
167
|
-
fast: opts.fast,
|
|
168
|
-
};
|
|
169
|
-
const builder = agent === "codex"
|
|
170
|
-
? new CodexAdapter(adapterOpts)
|
|
171
|
-
: await ClaudeAdapter.create(adapterOpts);
|
|
172
|
-
const viewer = printEvents(builder.events());
|
|
173
|
-
const foreman = new Foreman(builder, log, config.notifications.enabled, false, 3, dir);
|
|
174
|
-
console.log(`foreman tickets: populating tickets with ${agent}`);
|
|
175
|
-
console.log(`foreman tickets: project ${dir}`);
|
|
176
|
-
console.log(`foreman tickets: log ${logPath}\n`);
|
|
189
|
+
const config = loadConfig(join(dir, "foreman.yaml"));
|
|
190
|
+
let builder;
|
|
191
|
+
let viewer;
|
|
177
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`);
|
|
178
209
|
const ticketsConfig = loadTicketsConfig(dir);
|
|
179
|
-
const
|
|
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));
|
|
180
215
|
await builder.close();
|
|
181
216
|
await viewer;
|
|
182
217
|
log.write("ticket-populate", {
|
|
@@ -211,7 +246,8 @@ export function buildTicketsCommand() {
|
|
|
211
246
|
console.log(`foreman tickets: populated .tickets/tickets.yaml and rendered ${ticketsConfig.paths.progressDoc}`);
|
|
212
247
|
}
|
|
213
248
|
catch (err) {
|
|
214
|
-
await builder
|
|
249
|
+
await builder?.close().catch(() => { });
|
|
250
|
+
await viewer?.catch(() => { });
|
|
215
251
|
fail(String(err instanceof Error ? err.message : err));
|
|
216
252
|
}
|
|
217
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
|
|
8
|
-
|
|
9
|
-
|
|
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 =
|
|
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.
|
|
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",
|
|
@@ -17,6 +17,10 @@
|
|
|
17
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"
|