ai-foreman 1.0.12 → 1.2.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 +22 -8
- package/dist/branch/git.d.ts +5 -1
- package/dist/branch/git.js +22 -3
- package/dist/branch/runner.d.ts +12 -2
- package/dist/branch/runner.js +8 -6
- package/dist/cli/runtimeAuthPrompt.d.ts +21 -2
- package/dist/cli/runtimeAuthPrompt.js +65 -13
- package/dist/cli/runtimeSelection.d.ts +9 -0
- package/dist/cli/runtimeSelection.js +48 -0
- package/dist/cli/start.js +44 -12
- package/dist/cli/tickets.d.ts +1 -1
- package/dist/cli/tickets.js +36 -22
- package/dist/foreman.js +2 -1
- package/dist/tickets/commands.d.ts +1 -0
- package/dist/tickets/commands.js +13 -6
- package/dist/tickets/config.d.ts +5 -0
- package/dist/tickets/config.js +124 -2
- package/dist/tickets/renderMarkdown.d.ts +2 -1
- package/dist/tickets/renderMarkdown.js +8 -5
- package/dist/tickets/validate.js +1 -1
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -85,7 +85,7 @@ What each part does:
|
|
|
85
85
|
| --- | --- | --- | --- |
|
|
86
86
|
| `<project>` | `./my-project` / `../repo` | required | Target repo the agent works in. |
|
|
87
87
|
| `-s, --steps <n>` | `1` / `5` / `10` | required | Max tickets or implementation steps to drive. |
|
|
88
|
-
| `-a, --agent <agent>` | `claude` / `codex` | `claude` | Builder agent. |
|
|
88
|
+
| `-a, --agent <agent>` | `claude` / `codex` | single `rafi-config.yaml` target, otherwise `claude` | Builder agent. Explicit `--agent` always wins. |
|
|
89
89
|
| `-m, --model <model>` | `gpt-5.5` / any supported agent model | agent default | Overrides the builder model. |
|
|
90
90
|
| `--effort <level>` | Claude Code: `low` / `medium` / `high` / `xhigh`<br>Codex: `low` / `medium` / `high` / `xhigh` | agent default | Reasoning level. |
|
|
91
91
|
| `--fast` | flag | off | Lower latency. For Codex, maps to `effort=low` when `--effort` is not set. |
|
|
@@ -105,6 +105,7 @@ What each part does:
|
|
|
105
105
|
|
|
106
106
|
Auto-detected tracker files:
|
|
107
107
|
|
|
108
|
+
- configured `paths.progress_doc` from `.tickets/config.yaml`
|
|
108
109
|
- `docs/ticket-progress.md`
|
|
109
110
|
- `ticket-progress.md`
|
|
110
111
|
|
|
@@ -155,7 +156,7 @@ gh repo view <host>/<owner>/<repo>
|
|
|
155
156
|
| Option | Common values | Default | Notes |
|
|
156
157
|
| --- | --- | --- | --- |
|
|
157
158
|
| `-p, --project <dir>` | `./my-project` / `../repo` | current directory | Target repo with `.tickets/`. |
|
|
158
|
-
| `-a, --agent <agent>` | `claude` / `codex` | `claude` | Builder agent. |
|
|
159
|
+
| `-a, --agent <agent>` | `claude` / `codex` | single `rafi-config.yaml` target, otherwise `claude` | Builder agent. Explicit `--agent` always wins. |
|
|
159
160
|
| `-m, --model <model>` | `gpt-5.5` / any supported agent model | agent default | Overrides the builder model. |
|
|
160
161
|
| `--effort <level>` | Claude Code: `low` / `medium` / `high` / `xhigh`<br>Codex: `low` / `medium` / `high` / `xhigh` | agent default | Reasoning level. |
|
|
161
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. |
|
|
@@ -187,17 +188,25 @@ Without tickets, Foreman still works with:
|
|
|
187
188
|
|
|
188
189
|
Claude Code:
|
|
189
190
|
|
|
190
|
-
- Default agent.
|
|
191
|
+
- Default agent when no single runtime target is configured.
|
|
191
192
|
- Uses your existing Claude Code credentials.
|
|
192
193
|
- Runs through the Claude Agent SDK.
|
|
193
|
-
- `start` and `tickets populate` check Claude auth before launching the builder and prompt you to repair it
|
|
194
|
+
- `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.
|
|
194
195
|
|
|
195
196
|
Codex:
|
|
196
197
|
|
|
197
|
-
- Use `--agent codex`.
|
|
198
|
+
- Use `--agent codex`, or set `harness.targets: ["codex"]` in `rafi-config.yaml` and omit `--agent`.
|
|
198
199
|
- Shells out to `codex exec`.
|
|
199
200
|
- Requires the `codex` CLI on your `PATH`.
|
|
200
|
-
- `start` and `tickets populate --agent codex` check Codex auth before launching the builder and prompt you to repair it
|
|
201
|
+
- `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.
|
|
202
|
+
|
|
203
|
+
Runtime defaults and recovery:
|
|
204
|
+
|
|
205
|
+
- 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.
|
|
206
|
+
- Non-interactive and `--yes` runs fail clearly instead of switching runtimes automatically.
|
|
207
|
+
- Resume and continue flows do not offer runtime switching because session IDs are runtime-specific.
|
|
208
|
+
- If an interactive command switches providers, a provider-specific `--model` override is ignored for that run. `--effort` and `--fast` remain in effect.
|
|
209
|
+
- Cancel stops the command and keeps project files, generated files, and installed packages in place.
|
|
201
210
|
|
|
202
211
|
Task file:
|
|
203
212
|
|
|
@@ -249,6 +258,9 @@ Init options:
|
|
|
249
258
|
| `--app-name <name>` | `"My App"` | none | Application name stored in tracker config. |
|
|
250
259
|
| `--timezone <tz>` | `America/Chicago` / `UTC` | `UTC` | IANA timezone. |
|
|
251
260
|
| `--queue-limit <n>` | `25` / `50` / `100` | `50` | Next-queue window size. |
|
|
261
|
+
| `--docs-root <dir>` | `docs` / `docs-rafi` | `rafi-config.yaml` docs.root, then `docs` | Root for generated progress/archive docs. |
|
|
262
|
+
|
|
263
|
+
`tickets init` reads `docs.root` from `rafi-config.yaml` when present. `--docs-root` overrides that value. The selected root must be a safe repo-relative directory, and init refuses to overwrite an existing selected `ticket-progress.md`.
|
|
252
264
|
|
|
253
265
|
This creates:
|
|
254
266
|
|
|
@@ -265,6 +277,8 @@ docs/
|
|
|
265
277
|
ticket-progress.md
|
|
266
278
|
```
|
|
267
279
|
|
|
280
|
+
With `--docs-root docs-rafi`, the generated tracker docs are written under `docs-rafi/` instead.
|
|
281
|
+
|
|
268
282
|
### 2. Add Tickets
|
|
269
283
|
|
|
270
284
|
If the project already has planning material:
|
|
@@ -293,7 +307,7 @@ ai-foreman tickets populate --project ./my-project --agent codex --model gpt-5.5
|
|
|
293
307
|
`populate` tells the builder to:
|
|
294
308
|
|
|
295
309
|
- read `.tickets/*`
|
|
296
|
-
- read
|
|
310
|
+
- read the configured progress doc
|
|
297
311
|
- read existing planning files
|
|
298
312
|
- fill `.tickets/tickets.yaml`
|
|
299
313
|
- render the progress doc
|
|
@@ -401,7 +415,7 @@ ai-foreman tickets queue --project ./my-project
|
|
|
401
415
|
|
|
402
416
|
Generated output:
|
|
403
417
|
|
|
404
|
-
-
|
|
418
|
+
- The configured progress doc is generated from `.tickets/tickets.yaml`.
|
|
405
419
|
- It also includes state from `.tickets/ticket-state.sqlite`.
|
|
406
420
|
- Builders should read it.
|
|
407
421
|
- You should not manually edit generated sections.
|
package/dist/branch/git.d.ts
CHANGED
|
@@ -9,13 +9,17 @@ export declare function currentGitRef(cwd: string): string;
|
|
|
9
9
|
export declare function generatedTrackerDirtyPaths(paths: {
|
|
10
10
|
stateDb: string;
|
|
11
11
|
progressDoc: string;
|
|
12
|
+
archiveDoc?: string;
|
|
12
13
|
}): string[];
|
|
13
14
|
export declare function ensureCleanBaseWorktree(cwd: string, opts?: BaseWorktreeCleanOptions): void;
|
|
14
15
|
export declare function ensureForemanExcluded(cwd: string): void;
|
|
15
16
|
export declare function createTicketWorktree(projectDir: string, runId: string, branch: string, base: string): string;
|
|
16
17
|
export declare function removeTicketWorktree(projectDir: string, worktreePath: string): void;
|
|
17
18
|
export declare function currentWorktreeBranch(worktreePath: string): string;
|
|
18
|
-
export declare function hasTrackerChanges(worktreePath: string
|
|
19
|
+
export declare function hasTrackerChanges(worktreePath: string, paths?: {
|
|
20
|
+
progressDoc?: string;
|
|
21
|
+
archiveDoc?: string;
|
|
22
|
+
}): boolean;
|
|
19
23
|
export declare function hasWorktreeChanges(worktreePath: string): boolean;
|
|
20
24
|
export declare function commitAll(worktreePath: string, message: string): string | undefined;
|
|
21
25
|
export declare function headCommitIfAhead(worktreePath: string, base: string): string | undefined;
|
package/dist/branch/git.js
CHANGED
|
@@ -12,13 +12,18 @@ export function currentGitRef(cwd) {
|
|
|
12
12
|
export function generatedTrackerDirtyPaths(paths) {
|
|
13
13
|
const stateDb = normalizeRepoPath(paths.stateDb);
|
|
14
14
|
const progressDoc = normalizeRepoPath(paths.progressDoc);
|
|
15
|
-
|
|
15
|
+
const out = [
|
|
16
16
|
stateDb,
|
|
17
17
|
`${stateDb}-wal`,
|
|
18
18
|
`${stateDb}-shm`,
|
|
19
19
|
progressDoc,
|
|
20
20
|
`${progressDoc}.tmp`,
|
|
21
21
|
];
|
|
22
|
+
if (paths.archiveDoc) {
|
|
23
|
+
const archiveDoc = normalizeRepoPath(paths.archiveDoc);
|
|
24
|
+
out.push(archiveDoc, `${archiveDoc}.tmp`);
|
|
25
|
+
}
|
|
26
|
+
return out;
|
|
22
27
|
}
|
|
23
28
|
export function ensureCleanBaseWorktree(cwd, opts = {}) {
|
|
24
29
|
const allowedDirtyPaths = new Set((opts.allowedDirtyPaths ?? []).map(normalizeRepoPath));
|
|
@@ -57,8 +62,19 @@ export function removeTicketWorktree(projectDir, worktreePath) {
|
|
|
57
62
|
export function currentWorktreeBranch(worktreePath) {
|
|
58
63
|
return runGit(worktreePath, ["branch", "--show-current"]).stdout;
|
|
59
64
|
}
|
|
60
|
-
export function hasTrackerChanges(worktreePath) {
|
|
61
|
-
const
|
|
65
|
+
export function hasTrackerChanges(worktreePath, paths = {}) {
|
|
66
|
+
const progressDoc = normalizeRepoPath(paths.progressDoc ?? "docs/ticket-progress.md");
|
|
67
|
+
const archiveDoc = normalizeRepoPath(paths.archiveDoc ?? "docs/ticket-archive.md");
|
|
68
|
+
const pathspecs = [
|
|
69
|
+
".tickets",
|
|
70
|
+
progressDoc,
|
|
71
|
+
`${progressDoc}.tmp`,
|
|
72
|
+
archiveDoc,
|
|
73
|
+
`${archiveDoc}.tmp`,
|
|
74
|
+
"docs/ticket-progress.md",
|
|
75
|
+
"ticket-progress.md",
|
|
76
|
+
];
|
|
77
|
+
const status = runGit(worktreePath, ["status", "--porcelain", "--", ...unique(pathspecs)]).stdout;
|
|
62
78
|
return status.trim().length > 0;
|
|
63
79
|
}
|
|
64
80
|
export function hasWorktreeChanges(worktreePath) {
|
|
@@ -92,3 +108,6 @@ function normalizeRepoPath(path) {
|
|
|
92
108
|
.replace(/\/+/g, "/")
|
|
93
109
|
.replace(/\/$/, "");
|
|
94
110
|
}
|
|
111
|
+
function unique(values) {
|
|
112
|
+
return [...new Set(values)];
|
|
113
|
+
}
|
package/dist/branch/runner.d.ts
CHANGED
|
@@ -16,6 +16,10 @@ export interface BranchRunnerOptions {
|
|
|
16
16
|
prReady: boolean;
|
|
17
17
|
keepWorktrees: boolean;
|
|
18
18
|
allowedBaseDirtyPaths?: string[];
|
|
19
|
+
trackerPaths?: {
|
|
20
|
+
progressDoc: string;
|
|
21
|
+
archiveDoc: string;
|
|
22
|
+
};
|
|
19
23
|
resumeSessions?: Map<string, {
|
|
20
24
|
worktreePath: string;
|
|
21
25
|
sessionId: string;
|
|
@@ -24,5 +28,11 @@ export interface BranchRunnerOptions {
|
|
|
24
28
|
observeBuilder?: (builder: BuilderAdapter) => Promise<void>;
|
|
25
29
|
}
|
|
26
30
|
export declare function runBranchPlan(opts: BranchRunnerOptions): Promise<BranchRunSummary[]>;
|
|
27
|
-
export declare function buildBranchTicketInstruction(node: BranchPlanNode
|
|
28
|
-
|
|
31
|
+
export declare function buildBranchTicketInstruction(node: BranchPlanNode, trackerPaths?: {
|
|
32
|
+
progressDoc?: string;
|
|
33
|
+
archiveDoc?: string;
|
|
34
|
+
}): string;
|
|
35
|
+
export declare function buildBranchTicketResumeInstruction(node: BranchPlanNode, trackerPaths?: {
|
|
36
|
+
progressDoc?: string;
|
|
37
|
+
archiveDoc?: string;
|
|
38
|
+
}): string;
|
package/dist/branch/runner.js
CHANGED
|
@@ -86,7 +86,7 @@ export async function runBranchPlan(opts) {
|
|
|
86
86
|
builder = await opts.createBuilder(worktreePath, resumeSession?.sessionId);
|
|
87
87
|
viewer = opts.observeBuilder?.(builder);
|
|
88
88
|
const foreman = new Foreman(builder, opts.log, opts.notificationsEnabled, opts.qaEnabled, 3, undefined);
|
|
89
|
-
const { result, status } = await foreman.runInstruction(resumeSession ? buildBranchTicketResumeInstruction(node) : buildBranchTicketInstruction(node));
|
|
89
|
+
const { result, status } = await foreman.runInstruction(resumeSession ? buildBranchTicketResumeInstruction(node, opts.trackerPaths) : buildBranchTicketInstruction(node, opts.trackerPaths));
|
|
90
90
|
const sessionId = builder.sessionId();
|
|
91
91
|
if (sessionId) {
|
|
92
92
|
opts.log.write("branch-session", {
|
|
@@ -150,7 +150,7 @@ export async function runBranchPlan(opts) {
|
|
|
150
150
|
if (currentBranch !== node.branch) {
|
|
151
151
|
throw new Error(`builder switched branch from ${node.branch} to ${currentBranch}`);
|
|
152
152
|
}
|
|
153
|
-
if (hasTrackerChanges(worktreePath)) {
|
|
153
|
+
if (hasTrackerChanges(worktreePath, opts.trackerPaths)) {
|
|
154
154
|
throw new Error("builder modified tracker/control files in ticket worktree");
|
|
155
155
|
}
|
|
156
156
|
let commit;
|
|
@@ -286,7 +286,8 @@ function failureLogFields(node, pr) {
|
|
|
286
286
|
output: pr.output,
|
|
287
287
|
};
|
|
288
288
|
}
|
|
289
|
-
export function buildBranchTicketInstruction(node) {
|
|
289
|
+
export function buildBranchTicketInstruction(node, trackerPaths = {}) {
|
|
290
|
+
const progressDoc = trackerPaths.progressDoc ?? "docs/ticket-progress.md";
|
|
290
291
|
return `Implement exactly this ticket in the current branch/worktree:
|
|
291
292
|
|
|
292
293
|
${node.ticket.id}: ${node.ticket.title}
|
|
@@ -307,7 +308,7 @@ Branch-mode rules:
|
|
|
307
308
|
- Implement only ${node.ticket.id}.
|
|
308
309
|
- Do not switch branches.
|
|
309
310
|
- Do not push, create PRs, or run git commit.
|
|
310
|
-
- Do not edit .tickets/,
|
|
311
|
+
- Do not edit .tickets/, ${progressDoc}, or other tracker state.
|
|
311
312
|
- If another selected ticket is required before this one can be completed, stop and end with STEP_STATUS: blocked | ticket="${node.ticket.id}" branch_dependency="<ticket-id>" reason="<why>".
|
|
312
313
|
- End with STEP_STATUS: done | ticket="${node.ticket.id}" summary="<what changed>" when the ticket is implemented.
|
|
313
314
|
|
|
@@ -315,7 +316,8 @@ QA will happen in this same builder session after implementation.
|
|
|
315
316
|
|
|
316
317
|
${MARKER_SPEC}`;
|
|
317
318
|
}
|
|
318
|
-
export function buildBranchTicketResumeInstruction(node) {
|
|
319
|
+
export function buildBranchTicketResumeInstruction(node, trackerPaths = {}) {
|
|
320
|
+
const progressDoc = trackerPaths.progressDoc ?? "docs/ticket-progress.md";
|
|
319
321
|
return `Continue the existing builder session for this ticket in the current branch/worktree:
|
|
320
322
|
|
|
321
323
|
${node.ticket.id}: ${node.ticket.title}
|
|
@@ -326,7 +328,7 @@ Branch-mode rules:
|
|
|
326
328
|
- Implement only ${node.ticket.id}.
|
|
327
329
|
- Do not switch branches.
|
|
328
330
|
- Do not push, create PRs, or run git commit.
|
|
329
|
-
- Do not edit .tickets/,
|
|
331
|
+
- Do not edit .tickets/, ${progressDoc}, or other tracker state.
|
|
330
332
|
- If another selected ticket is required before this one can be completed, stop and end with STEP_STATUS: blocked | ticket="${node.ticket.id}" branch_dependency="<ticket-id>" reason="<why>".
|
|
331
333
|
- End with STEP_STATUS: done | ticket="${node.ticket.id}" summary="<what changed>" when the ticket is implemented.
|
|
332
334
|
|
|
@@ -1,2 +1,21 @@
|
|
|
1
|
-
import { type AgentRuntime } from "../runtimeAuth.js";
|
|
2
|
-
export
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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";
|
|
@@ -43,6 +44,9 @@ function findLastSessionId(dir) {
|
|
|
43
44
|
function collectTicket(value, previous) {
|
|
44
45
|
return [...previous, value];
|
|
45
46
|
}
|
|
47
|
+
function unique(values) {
|
|
48
|
+
return [...new Set(values)];
|
|
49
|
+
}
|
|
46
50
|
function branchContinueTicketHelp(cwd, flag = "--continue") {
|
|
47
51
|
const sessions = findResumableBranchSessions(join(cwd, ".foreman"));
|
|
48
52
|
if (sessions.length === 0) {
|
|
@@ -94,7 +98,7 @@ export function buildStartCommand() {
|
|
|
94
98
|
.description("Enlist a builder and drive it through a batch of N steps.")
|
|
95
99
|
.argument("<project>", "path to the project directory the builder works in")
|
|
96
100
|
.requiredOption("-s, --steps <n>", "number of steps to drive")
|
|
97
|
-
.option("-a, --agent <agent>", "builder agent (claude | codex)"
|
|
101
|
+
.option("-a, --agent <agent>", "builder agent (claude | codex)")
|
|
98
102
|
.option("-m, --model <model>", "override the builder's model")
|
|
99
103
|
.option("-r, --resume <sessionId>", "resume a prior builder session")
|
|
100
104
|
.option("--continue", "resume the most recent logged session for this project")
|
|
@@ -116,10 +120,6 @@ export function buildStartCommand() {
|
|
|
116
120
|
if (!Number.isInteger(steps) || steps < 1) {
|
|
117
121
|
fail("--steps must be a positive integer");
|
|
118
122
|
}
|
|
119
|
-
const VALID_AGENTS = ["claude", "codex"];
|
|
120
|
-
if (!VALID_AGENTS.includes(opts.agent)) {
|
|
121
|
-
fail(`unknown agent "${opts.agent}" — choose: ${VALID_AGENTS.join(" | ")}`);
|
|
122
|
-
}
|
|
123
123
|
const VALID_EFFORT = ["low", "medium", "high", "xhigh"];
|
|
124
124
|
if (opts.effort && !VALID_EFFORT.includes(opts.effort)) {
|
|
125
125
|
fail(`unknown effort "${opts.effort}" — choose: ${VALID_EFFORT.join(" | ")}`);
|
|
@@ -127,7 +127,22 @@ export function buildStartCommand() {
|
|
|
127
127
|
const cwd = resolve(project);
|
|
128
128
|
if (!existsSync(cwd))
|
|
129
129
|
fail(`project directory not found: ${cwd}`);
|
|
130
|
-
|
|
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;
|
|
138
|
+
const configuredTrackerPath = isTicketsInitialized(cwd)
|
|
139
|
+
? loadTicketsConfig(cwd).paths.progressDoc
|
|
140
|
+
: undefined;
|
|
141
|
+
const TRACKER_SEARCH_PATHS = unique([
|
|
142
|
+
configuredTrackerPath,
|
|
143
|
+
"docs/ticket-progress.md",
|
|
144
|
+
"ticket-progress.md",
|
|
145
|
+
].filter(Boolean));
|
|
131
146
|
let ticketsContent;
|
|
132
147
|
let trackerRelPath;
|
|
133
148
|
if (opts.tickets) {
|
|
@@ -183,14 +198,13 @@ export function buildStartCommand() {
|
|
|
183
198
|
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
184
199
|
const logPath = join(cwd, ".foreman", `${stamp}.jsonl`);
|
|
185
200
|
const log = new Log(logPath);
|
|
186
|
-
const agent = opts.agent;
|
|
187
201
|
const qaEnabled = opts.qa !== false && config.qa.enabled !== false;
|
|
188
202
|
const createBuilder = async (builderCwd, sessionId) => {
|
|
189
203
|
const builderPolicy = new PermissionPolicy(config.permissions, builderCwd);
|
|
190
204
|
const roleBundle = loadRoleBundle("builder", { projectDir: builderCwd });
|
|
191
205
|
const adapterOpts = {
|
|
192
206
|
cwd: builderCwd,
|
|
193
|
-
model
|
|
207
|
+
model,
|
|
194
208
|
resumeSessionId: sessionId,
|
|
195
209
|
permission: createPermissionHandler(builderPolicy, log),
|
|
196
210
|
effort: opts.effort,
|
|
@@ -214,7 +228,14 @@ export function buildStartCommand() {
|
|
|
214
228
|
if (opts.createPr) {
|
|
215
229
|
await ensureGitHubReadyForCreatePr(cwd, log, Boolean(opts.yes));
|
|
216
230
|
}
|
|
217
|
-
await ensureRuntimeReadyForCommand(cwd, agent,
|
|
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;
|
|
218
239
|
const ticketPaths = resolveTicketPaths(ticketsConfig, cwd);
|
|
219
240
|
const tickets = loadTickets(ticketPaths.tickets);
|
|
220
241
|
const db = new StateDb(ticketPaths.stateDb);
|
|
@@ -327,7 +348,7 @@ export function buildStartCommand() {
|
|
|
327
348
|
plan,
|
|
328
349
|
log,
|
|
329
350
|
agent,
|
|
330
|
-
model
|
|
351
|
+
model,
|
|
331
352
|
effort: opts.effort,
|
|
332
353
|
fast: opts.fast,
|
|
333
354
|
notificationsEnabled: config.notifications.enabled,
|
|
@@ -336,6 +357,10 @@ export function buildStartCommand() {
|
|
|
336
357
|
prReady: Boolean(opts.prReady),
|
|
337
358
|
keepWorktrees: Boolean(opts.keepWorktrees),
|
|
338
359
|
allowedBaseDirtyPaths,
|
|
360
|
+
trackerPaths: {
|
|
361
|
+
progressDoc: ticketsConfig.paths.progressDoc,
|
|
362
|
+
archiveDoc: ticketsConfig.paths.archiveDoc,
|
|
363
|
+
},
|
|
339
364
|
resumeSessions: resumeSessionByTicket,
|
|
340
365
|
createBuilder: (builderCwd, sessionId) => createBuilder(builderCwd, sessionId),
|
|
341
366
|
observeBuilder: (builder) => printEvents(builder.events()),
|
|
@@ -364,11 +389,18 @@ export function buildStartCommand() {
|
|
|
364
389
|
const failed = summaries.some((row) => row.buildStatus === "blocked" || row.buildStatus === "needs-human");
|
|
365
390
|
process.exit(failed ? 2 : 0);
|
|
366
391
|
}
|
|
367
|
-
await ensureRuntimeReadyForCommand(cwd, agent,
|
|
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;
|
|
368
400
|
const builder = await createBuilder(cwd, resumeSessionId);
|
|
369
401
|
const foreman = new Foreman(builder, log, config.notifications.enabled, qaEnabled, 3, cwd);
|
|
370
402
|
const modifiers = [
|
|
371
|
-
|
|
403
|
+
model ? `model=${model}` : null,
|
|
372
404
|
opts.effort ? `effort=${opts.effort}` : null,
|
|
373
405
|
opts.fast ? "fast" : null,
|
|
374
406
|
qaEnabled ? null : "qa=off",
|
package/dist/cli/tickets.d.ts
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
import { Command } from "commander";
|
|
2
|
-
export declare function buildPopulateInstruction(sourceHints?: string[]): string;
|
|
2
|
+
export declare function buildPopulateInstruction(sourceHints?: string[], progressDoc?: string): string;
|
|
3
3
|
export declare function buildTicketsCommand(): Command;
|
package/dist/cli/tickets.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Command } from "commander";
|
|
2
2
|
import { existsSync } from "node:fs";
|
|
3
|
-
import { join, resolve } from "node:path";
|
|
3
|
+
import { isAbsolute, join, resolve } from "node:path";
|
|
4
4
|
import { select, isCancel } from "@clack/prompts";
|
|
5
5
|
import { loadConfig } from "../config.js";
|
|
6
6
|
import { Log } from "../log.js";
|
|
@@ -10,10 +10,11 @@ import { CodexAdapter } from "../adapters/codex.js";
|
|
|
10
10
|
import { Foreman, createPermissionHandler } from "../foreman.js";
|
|
11
11
|
import { printEvents } from "./events.js";
|
|
12
12
|
import { cmdInit, cmdUpdate, cmdComplete, cmdBlock, cmdUnblock, cmdCancel, cmdDiscover, cmdAcceptFutureWork, cmdReorder, cmdRender, cmdValidate, cmdQueue, cmdArchive, } from "../tickets/commands.js";
|
|
13
|
-
import { isTicketsInitialized } from "../tickets/config.js";
|
|
13
|
+
import { isTicketsInitialized, loadTicketsConfig } from "../tickets/config.js";
|
|
14
14
|
import { importFromMarkdown } from "../tickets/importer.js";
|
|
15
15
|
import { formatValidationIssues } from "../tickets/validate.js";
|
|
16
16
|
import { ensureRuntimeReadyForCommand } from "./runtimeAuthPrompt.js";
|
|
17
|
+
import { resolveAgentForProject } from "./runtimeSelection.js";
|
|
17
18
|
function fail(msg) {
|
|
18
19
|
console.error(`foreman tickets: ${msg}`);
|
|
19
20
|
process.exit(1);
|
|
@@ -21,13 +22,7 @@ function fail(msg) {
|
|
|
21
22
|
function cwd(opts) {
|
|
22
23
|
return resolve(opts.project ?? ".");
|
|
23
24
|
}
|
|
24
|
-
const VALID_AGENTS = ["claude", "codex"];
|
|
25
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
|
-
}
|
|
30
|
-
}
|
|
31
26
|
function validateEffort(effort) {
|
|
32
27
|
if (effort && !VALID_EFFORT.includes(effort)) {
|
|
33
28
|
fail(`unknown effort "${effort}" — choose: ${VALID_EFFORT.join(" | ")}`);
|
|
@@ -37,7 +32,7 @@ function makeLogPath(projectDir, label) {
|
|
|
37
32
|
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
38
33
|
return join(projectDir, ".foreman", `${stamp}-${label}.jsonl`);
|
|
39
34
|
}
|
|
40
|
-
export function buildPopulateInstruction(sourceHints) {
|
|
35
|
+
export function buildPopulateInstruction(sourceHints, progressDoc = "docs/ticket-progress.md") {
|
|
41
36
|
const sources = sourceHints ?? [];
|
|
42
37
|
const sourceHintBlock = sources.length > 0
|
|
43
38
|
? `
|
|
@@ -60,7 +55,7 @@ Before editing, read these tracker control files:
|
|
|
60
55
|
- .tickets/config.yaml
|
|
61
56
|
- .tickets/tickets.yaml
|
|
62
57
|
- .tickets/tracker-rules.md
|
|
63
|
-
-
|
|
58
|
+
- ${progressDoc} if it exists
|
|
64
59
|
|
|
65
60
|
Then inspect the repository for existing planning sources. Check root and docs-style Markdown/YAML/TXT files whose names suggest tickets, backlog, roadmap, plan, TODOs, milestones, progress, specs, phases, or implementation steps. Preserve every ticket or task you find. Do not leave out details.
|
|
66
61
|
|
|
@@ -96,6 +91,7 @@ export function buildTicketsCommand() {
|
|
|
96
91
|
.option("--app-name <name>", "application name")
|
|
97
92
|
.option("--timezone <tz>", "IANA timezone (e.g. America/Chicago)", "UTC")
|
|
98
93
|
.option("--queue-limit <n>", "next-queue window size", "50")
|
|
94
|
+
.option("--docs-root <dir>", "repo-relative directory for generated ticket docs")
|
|
99
95
|
.action((opts) => {
|
|
100
96
|
const dir = cwd(opts);
|
|
101
97
|
try {
|
|
@@ -103,6 +99,7 @@ export function buildTicketsCommand() {
|
|
|
103
99
|
appName: opts.appName,
|
|
104
100
|
timezone: opts.timezone,
|
|
105
101
|
queueLimit: Number(opts.queueLimit),
|
|
102
|
+
docsRoot: opts.docsRoot,
|
|
106
103
|
});
|
|
107
104
|
console.log(`foreman tickets: initialized .tickets/ in ${dir}`);
|
|
108
105
|
console.log(`foreman tickets: next — add tickets to .tickets/tickets.yaml and run \`foreman tickets render\``);
|
|
@@ -116,7 +113,7 @@ export function buildTicketsCommand() {
|
|
|
116
113
|
.command("populate")
|
|
117
114
|
.description("Ask a builder to populate .tickets/tickets.yaml from existing project ticket/backlog docs.")
|
|
118
115
|
.option("-p, --project <dir>", "project directory (default: cwd)")
|
|
119
|
-
.option("-a, --agent <agent>", "builder agent (claude | codex)"
|
|
116
|
+
.option("-a, --agent <agent>", "builder agent (claude | codex)")
|
|
120
117
|
.option("-m, --model <model>", "override the builder's model")
|
|
121
118
|
.option("--effort <level>", "reasoning effort level (low|medium|high|xhigh)")
|
|
122
119
|
.option("--sources <paths...>", "source hint files, folders, or globs to check first")
|
|
@@ -124,11 +121,17 @@ export function buildTicketsCommand() {
|
|
|
124
121
|
.option("-y, --yes", "skip confirmation prompt before letting the builder edit tickets")
|
|
125
122
|
.action(async (opts) => {
|
|
126
123
|
const dir = cwd(opts);
|
|
127
|
-
const agent = opts.agent;
|
|
128
|
-
validateAgent(agent);
|
|
129
124
|
validateEffort(opts.effort);
|
|
130
125
|
if (!existsSync(dir))
|
|
131
126
|
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;
|
|
132
135
|
if (!isTicketsInitialized(dir)) {
|
|
133
136
|
fail(`ticket tracker is not initialized in ${dir}; run \`foreman tickets init --project ${dir}\` first`);
|
|
134
137
|
}
|
|
@@ -149,10 +152,16 @@ export function buildTicketsCommand() {
|
|
|
149
152
|
const logPath = makeLogPath(dir, "tickets-populate");
|
|
150
153
|
const log = new Log(logPath);
|
|
151
154
|
const policy = new PermissionPolicy(config.permissions, dir);
|
|
152
|
-
await ensureRuntimeReadyForCommand(dir, agent,
|
|
155
|
+
const ready = await ensureRuntimeReadyForCommand(dir, agent, {
|
|
156
|
+
label: "tickets populate",
|
|
157
|
+
yes: Boolean(opts.yes),
|
|
158
|
+
model,
|
|
159
|
+
});
|
|
160
|
+
agent = ready.runtime;
|
|
161
|
+
model = ready.model;
|
|
153
162
|
const adapterOpts = {
|
|
154
163
|
cwd: dir,
|
|
155
|
-
model
|
|
164
|
+
model,
|
|
156
165
|
permission: createPermissionHandler(policy, log),
|
|
157
166
|
effort: opts.effort,
|
|
158
167
|
fast: opts.fast,
|
|
@@ -166,7 +175,8 @@ export function buildTicketsCommand() {
|
|
|
166
175
|
console.log(`foreman tickets: project ${dir}`);
|
|
167
176
|
console.log(`foreman tickets: log ${logPath}\n`);
|
|
168
177
|
try {
|
|
169
|
-
const
|
|
178
|
+
const ticketsConfig = loadTicketsConfig(dir);
|
|
179
|
+
const turn = await foreman.runInstruction(buildPopulateInstruction(opts.sources, ticketsConfig.paths.progressDoc));
|
|
170
180
|
await builder.close();
|
|
171
181
|
await viewer;
|
|
172
182
|
log.write("ticket-populate", {
|
|
@@ -198,7 +208,7 @@ export function buildTicketsCommand() {
|
|
|
198
208
|
else {
|
|
199
209
|
console.log("foreman tickets: validation passed — all 4 passes clean");
|
|
200
210
|
}
|
|
201
|
-
console.log(
|
|
211
|
+
console.log(`foreman tickets: populated .tickets/tickets.yaml and rendered ${ticketsConfig.paths.progressDoc}`);
|
|
202
212
|
}
|
|
203
213
|
catch (err) {
|
|
204
214
|
await builder.close().catch(() => { });
|
|
@@ -406,12 +416,13 @@ export function buildTicketsCommand() {
|
|
|
406
416
|
// ── render ───────────────────────────────────────────────────────────────────
|
|
407
417
|
tickets
|
|
408
418
|
.command("render")
|
|
409
|
-
.description("Regenerate
|
|
419
|
+
.description("Regenerate the configured ticket progress doc from current structured sources.")
|
|
410
420
|
.option("-p, --project <dir>", "project directory (default: cwd)")
|
|
411
421
|
.action((opts) => {
|
|
412
422
|
try {
|
|
423
|
+
const ticketsConfig = loadTicketsConfig(cwd(opts));
|
|
413
424
|
cmdRender(cwd(opts));
|
|
414
|
-
console.log(
|
|
425
|
+
console.log(`foreman tickets: rendered ${ticketsConfig.paths.progressDoc}`);
|
|
415
426
|
}
|
|
416
427
|
catch (err) {
|
|
417
428
|
fail(String(err instanceof Error ? err.message : err));
|
|
@@ -464,7 +475,7 @@ export function buildTicketsCommand() {
|
|
|
464
475
|
// ── archive ───────────────────────────────────────────────────────────────────
|
|
465
476
|
tickets
|
|
466
477
|
.command("archive")
|
|
467
|
-
.description("Update
|
|
478
|
+
.description("Update the configured ticket archive doc and prune old completed rows.")
|
|
468
479
|
.option("-p, --project <dir>", "project directory (default: cwd)")
|
|
469
480
|
.option("--older-than-days <n>", "only archive tickets completed more than N days ago")
|
|
470
481
|
.action((opts) => {
|
|
@@ -483,10 +494,13 @@ export function buildTicketsCommand() {
|
|
|
483
494
|
.command("import")
|
|
484
495
|
.description("(stub) Migrate an existing Markdown tracker.")
|
|
485
496
|
.option("-p, --project <dir>", "project directory (default: cwd)")
|
|
486
|
-
.option("--progress <path>", "path to existing
|
|
497
|
+
.option("--progress <path>", "path to existing ticket progress Markdown")
|
|
487
498
|
.action((opts) => {
|
|
488
499
|
try {
|
|
489
|
-
|
|
500
|
+
const dir = cwd(opts);
|
|
501
|
+
const configuredProgress = opts.progress ?? loadTicketsConfig(dir).paths.progressDoc;
|
|
502
|
+
const progress = isAbsolute(configuredProgress) ? configuredProgress : join(dir, configuredProgress);
|
|
503
|
+
importFromMarkdown(progress);
|
|
490
504
|
}
|
|
491
505
|
catch (err) {
|
|
492
506
|
console.error(String(err instanceof Error ? err.message : err));
|
package/dist/foreman.js
CHANGED
|
@@ -104,7 +104,8 @@ export { QA_MARKER_SPEC } from "./markers.js";
|
|
|
104
104
|
export function buildPrimer(n, trackerPath, ticketsEnabled = false) {
|
|
105
105
|
let trackerRule;
|
|
106
106
|
if (ticketsEnabled) {
|
|
107
|
-
|
|
107
|
+
const progressDoc = trackerPath ?? "docs/ticket-progress.md";
|
|
108
|
+
trackerRule = `\n- Ticket state is managed by foreman — you do NOT need to manually edit ${progressDoc}.` +
|
|
108
109
|
`\n- Use \`foreman tickets discover --summary "..." --rationale "..."\` to log newly discovered work.` +
|
|
109
110
|
`\n- Use \`foreman tickets update <id> --next-action "..."\` to record mid-turn notes.` +
|
|
110
111
|
`\n- Always include ticket="<id>" in your STEP_STATUS marker so foreman can update the tracker.`;
|
package/dist/tickets/commands.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { mkdirSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { stringify } from "yaml";
|
|
4
|
-
import { loadTicketsConfig, resolveTicketPaths, isTicketsInitialized } from "./config.js";
|
|
4
|
+
import { initDocsRoot, loadTicketsConfig, pathExistsOrSymlink, resolveTicketPaths, isTicketsInitialized, } from "./config.js";
|
|
5
5
|
import { loadTickets, saveTickets } from "./ticketLoader.js";
|
|
6
6
|
import { StateDb } from "./stateDb.js";
|
|
7
7
|
import { nowTimestamp, logEvent } from "./events.js";
|
|
8
|
-
import { renderAndWrite,
|
|
8
|
+
import { renderAndWrite, renderTrackerRules } from "./renderMarkdown.js";
|
|
9
9
|
import { runAllValidation } from "./validate.js";
|
|
10
10
|
import { buildNextQueue } from "./queue.js";
|
|
11
11
|
export function openContext(projectDir) {
|
|
@@ -33,10 +33,17 @@ export function cmdInit(projectDir, opts) {
|
|
|
33
33
|
throw new Error(".tickets/config.yaml already exists — already initialized.");
|
|
34
34
|
}
|
|
35
35
|
const ticketsDir = join(projectDir, ".tickets");
|
|
36
|
+
const docsRoot = initDocsRoot(projectDir, opts.docsRoot);
|
|
37
|
+
const progressDoc = `${docsRoot}/ticket-progress.md`;
|
|
38
|
+
const archiveDoc = `${docsRoot}/ticket-archive.md`;
|
|
39
|
+
if (pathExistsOrSymlink(join(projectDir, progressDoc))) {
|
|
40
|
+
throw new Error(`${progressDoc} already exists. Choose another docs root with --docs-root <dir> ` +
|
|
41
|
+
"or move the existing progress doc before initializing tickets.");
|
|
42
|
+
}
|
|
36
43
|
mkdirSync(join(ticketsDir, "schema"), { recursive: true });
|
|
37
44
|
mkdirSync(join(ticketsDir, "migrations"), { recursive: true });
|
|
38
45
|
mkdirSync(join(ticketsDir, "backups"), { recursive: true });
|
|
39
|
-
mkdirSync(join(projectDir,
|
|
46
|
+
mkdirSync(join(projectDir, docsRoot), { recursive: true });
|
|
40
47
|
const config = {
|
|
41
48
|
app_name: opts.appName ?? "My App",
|
|
42
49
|
queue_limit: opts.queueLimit ?? 50,
|
|
@@ -46,8 +53,8 @@ export function cmdInit(projectDir, opts) {
|
|
|
46
53
|
tickets: ".tickets/tickets.yaml",
|
|
47
54
|
state_db: ".tickets/ticket-state.sqlite",
|
|
48
55
|
tracker_rules: ".tickets/tracker-rules.md",
|
|
49
|
-
progress_doc:
|
|
50
|
-
archive_doc:
|
|
56
|
+
progress_doc: progressDoc,
|
|
57
|
+
archive_doc: archiveDoc,
|
|
51
58
|
},
|
|
52
59
|
rendering: {
|
|
53
60
|
preserve_legacy_llm_queue_heading: true,
|
|
@@ -67,7 +74,7 @@ export function cmdInit(projectDir, opts) {
|
|
|
67
74
|
};
|
|
68
75
|
writeFileSync(join(ticketsDir, "config.yaml"), stringify(config, { lineWidth: 80 }), "utf8");
|
|
69
76
|
writeFileSync(join(ticketsDir, "tickets.yaml"), "tickets: []\n", "utf8");
|
|
70
|
-
writeFileSync(join(ticketsDir, "tracker-rules.md"),
|
|
77
|
+
writeFileSync(join(ticketsDir, "tracker-rules.md"), renderTrackerRules(progressDoc), "utf8");
|
|
71
78
|
const ticketSchema = {
|
|
72
79
|
$schema: "http://json-schema.org/draft-07/schema#",
|
|
73
80
|
title: "Ticket",
|
package/dist/tickets/config.d.ts
CHANGED
|
@@ -29,6 +29,10 @@ export interface TicketsConfig {
|
|
|
29
29
|
behavior: TicketsBehavior;
|
|
30
30
|
}
|
|
31
31
|
export declare const DEFAULT_TICKETS_CONFIG: TicketsConfig;
|
|
32
|
+
export declare const DEFAULT_DOCS_ROOT = "docs";
|
|
33
|
+
export declare function normalizeDocsRoot(docsRoot: string): string;
|
|
34
|
+
export declare function validateDocsRoot(projectDir: string, docsRoot: string): string;
|
|
35
|
+
export declare function initDocsRoot(projectDir: string, override?: string): string;
|
|
32
36
|
export declare function loadTicketsConfig(projectDir: string): TicketsConfig;
|
|
33
37
|
export declare function isTicketsInitialized(projectDir: string): boolean;
|
|
34
38
|
export declare function resolveTicketPaths(config: TicketsConfig, projectDir: string): {
|
|
@@ -38,3 +42,4 @@ export declare function resolveTicketPaths(config: TicketsConfig, projectDir: st
|
|
|
38
42
|
progressDoc: string;
|
|
39
43
|
archiveDoc: string;
|
|
40
44
|
};
|
|
45
|
+
export declare function pathExistsOrSymlink(path: string): boolean;
|
package/dist/tickets/config.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { readFileSync, existsSync } from "node:fs";
|
|
2
|
-
import { isAbsolute, join } from "node:path";
|
|
1
|
+
import { readFileSync, existsSync, lstatSync, realpathSync, statSync } from "node:fs";
|
|
2
|
+
import { dirname, isAbsolute, join, normalize, relative, resolve } from "node:path";
|
|
3
3
|
import { parse } from "yaml";
|
|
4
4
|
export const DEFAULT_TICKETS_CONFIG = {
|
|
5
5
|
appName: "My App",
|
|
@@ -28,6 +28,75 @@ export const DEFAULT_TICKETS_CONFIG = {
|
|
|
28
28
|
backupBeforeImportOrMigration: true,
|
|
29
29
|
},
|
|
30
30
|
};
|
|
31
|
+
export const DEFAULT_DOCS_ROOT = "docs";
|
|
32
|
+
const GLOB_CHARS = /[*?[\]{}]/;
|
|
33
|
+
export function normalizeDocsRoot(docsRoot) {
|
|
34
|
+
const raw = docsRoot.trim();
|
|
35
|
+
if (!raw)
|
|
36
|
+
throw new Error("docs root must be a non-empty repo-relative directory");
|
|
37
|
+
if (isAbsolute(raw) || /^[A-Za-z]:[\\/]/.test(raw)) {
|
|
38
|
+
throw new Error(`docs root must be repo-relative: ${docsRoot}`);
|
|
39
|
+
}
|
|
40
|
+
if (GLOB_CHARS.test(raw)) {
|
|
41
|
+
throw new Error(`docs root must be a concrete directory, not a glob: ${docsRoot}`);
|
|
42
|
+
}
|
|
43
|
+
const normalized = normalize(raw.replace(/\\/g, "/")).replace(/\\/g, "/").replace(/\/+$/, "");
|
|
44
|
+
if (!normalized || normalized === "." || normalized === "..") {
|
|
45
|
+
throw new Error(`docs root must be a named repo-relative directory: ${docsRoot}`);
|
|
46
|
+
}
|
|
47
|
+
if (normalized.split("/").includes("..")) {
|
|
48
|
+
throw new Error(`docs root must not contain parent-directory traversal: ${docsRoot}`);
|
|
49
|
+
}
|
|
50
|
+
return normalized.replace(/^\.\/+/, "");
|
|
51
|
+
}
|
|
52
|
+
export function validateDocsRoot(projectDir, docsRoot) {
|
|
53
|
+
const normalized = normalizeDocsRoot(docsRoot);
|
|
54
|
+
const repoRoot = resolve(projectDir);
|
|
55
|
+
const repoRootReal = realOrResolved(repoRoot);
|
|
56
|
+
const rootPath = resolve(repoRoot, normalized);
|
|
57
|
+
const rel = relative(repoRoot, rootPath);
|
|
58
|
+
if (!rel || rel.startsWith("..") || isAbsolute(rel)) {
|
|
59
|
+
throw new Error(`docs root must stay inside the repository: ${docsRoot}`);
|
|
60
|
+
}
|
|
61
|
+
if (pathExistsOrSymlink(rootPath)) {
|
|
62
|
+
let realRoot;
|
|
63
|
+
try {
|
|
64
|
+
realRoot = realpathSync(rootPath);
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
throw new Error(`docs root resolves to a non-directory: ${normalized}`);
|
|
68
|
+
}
|
|
69
|
+
assertInsideRepo(repoRootReal, realRoot, docsRoot);
|
|
70
|
+
let stat;
|
|
71
|
+
try {
|
|
72
|
+
stat = statSync(rootPath);
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
throw new Error(`docs root resolves to a non-directory: ${normalized}`);
|
|
76
|
+
}
|
|
77
|
+
if (!stat.isDirectory()) {
|
|
78
|
+
throw new Error(`docs root resolves to a non-directory: ${normalized}`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
assertExistingAncestorInsideRepo(repoRoot, repoRootReal, rootPath, docsRoot);
|
|
83
|
+
}
|
|
84
|
+
return normalized;
|
|
85
|
+
}
|
|
86
|
+
export function initDocsRoot(projectDir, override) {
|
|
87
|
+
if (override)
|
|
88
|
+
return validateDocsRoot(projectDir, override);
|
|
89
|
+
const configured = readRafiDocsRoot(projectDir);
|
|
90
|
+
return validateDocsRoot(projectDir, configured ?? DEFAULT_DOCS_ROOT);
|
|
91
|
+
}
|
|
92
|
+
function readRafiDocsRoot(projectDir) {
|
|
93
|
+
const configPath = join(projectDir, "rafi-config.yaml");
|
|
94
|
+
if (!existsSync(configPath))
|
|
95
|
+
return undefined;
|
|
96
|
+
const raw = parse(readFileSync(configPath, "utf8")) ?? {};
|
|
97
|
+
const docs = raw.docs;
|
|
98
|
+
return typeof docs?.root === "string" ? docs.root : undefined;
|
|
99
|
+
}
|
|
31
100
|
function snakeToCamel(s) {
|
|
32
101
|
return s.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
|
|
33
102
|
}
|
|
@@ -131,3 +200,56 @@ export function resolveTicketPaths(config, projectDir) {
|
|
|
131
200
|
archiveDoc: r(p.archiveDoc),
|
|
132
201
|
};
|
|
133
202
|
}
|
|
203
|
+
export function pathExistsOrSymlink(path) {
|
|
204
|
+
try {
|
|
205
|
+
lstatSync(path);
|
|
206
|
+
return true;
|
|
207
|
+
}
|
|
208
|
+
catch {
|
|
209
|
+
return false;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
function realOrResolved(path) {
|
|
213
|
+
try {
|
|
214
|
+
return realpathSync(path);
|
|
215
|
+
}
|
|
216
|
+
catch {
|
|
217
|
+
return resolve(path);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
function assertExistingAncestorInsideRepo(repoRoot, repoRootReal, rootPath, docsRoot) {
|
|
221
|
+
let current = dirname(rootPath);
|
|
222
|
+
while (current !== repoRoot && !pathExistsOrSymlink(current)) {
|
|
223
|
+
const parent = dirname(current);
|
|
224
|
+
if (parent === current)
|
|
225
|
+
return;
|
|
226
|
+
current = parent;
|
|
227
|
+
}
|
|
228
|
+
if (!pathExistsOrSymlink(current))
|
|
229
|
+
return;
|
|
230
|
+
try {
|
|
231
|
+
const stat = statSync(current);
|
|
232
|
+
if (!stat.isDirectory()) {
|
|
233
|
+
throw new Error(`docs root resolves to a non-directory: ${docsRoot}`);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
catch {
|
|
237
|
+
throw new Error(`docs root resolves to a non-directory: ${docsRoot}`);
|
|
238
|
+
}
|
|
239
|
+
if (current === repoRoot)
|
|
240
|
+
return;
|
|
241
|
+
let realAncestor;
|
|
242
|
+
try {
|
|
243
|
+
realAncestor = realpathSync(current);
|
|
244
|
+
}
|
|
245
|
+
catch {
|
|
246
|
+
throw new Error(`docs root resolves to a non-directory: ${docsRoot}`);
|
|
247
|
+
}
|
|
248
|
+
assertInsideRepo(repoRootReal, realAncestor, docsRoot);
|
|
249
|
+
}
|
|
250
|
+
function assertInsideRepo(repoRootReal, candidateReal, docsRoot) {
|
|
251
|
+
const rel = relative(repoRootReal, candidateReal);
|
|
252
|
+
if (!rel || rel.startsWith("..") || isAbsolute(rel)) {
|
|
253
|
+
throw new Error(`docs root must stay inside the repository: ${docsRoot}`);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import type { TicketsConfig } from "./config.js";
|
|
2
2
|
import type { TicketDef } from "./ticketSchema.js";
|
|
3
3
|
import type { StateDb, TicketState } from "./stateDb.js";
|
|
4
|
-
export declare
|
|
4
|
+
export declare function renderTrackerRules(progressDoc?: string): string;
|
|
5
|
+
export declare const DEFAULT_TRACKER_RULES: string;
|
|
5
6
|
export interface RenderInput {
|
|
6
7
|
config: TicketsConfig;
|
|
7
8
|
projectDir: string;
|
|
@@ -17,20 +17,21 @@ function section(marker, content) {
|
|
|
17
17
|
return `<!-- ${marker}_START -->\n${content}\n<!-- ${marker}_END -->`;
|
|
18
18
|
}
|
|
19
19
|
// ── Default tracker-rules content ─────────────────────────────────────────────
|
|
20
|
-
export
|
|
20
|
+
export function renderTrackerRules(progressDoc = "docs/ticket-progress.md") {
|
|
21
|
+
return `# Tracker Rules
|
|
21
22
|
|
|
22
23
|
## Purpose
|
|
23
24
|
This tracker is the operational build control surface for humans and builder agents.
|
|
24
25
|
|
|
25
26
|
## First file to read
|
|
26
|
-
Read \`
|
|
27
|
+
Read \`${progressDoc}\` first. Do not scan the full backlog to choose work
|
|
27
28
|
unless the queue is empty or the user explicitly asks for broader planning.
|
|
28
29
|
|
|
29
30
|
## How to choose work
|
|
30
31
|
Choose the first row in \`LLM_NEXT_QUEUE\` where \`Status\` is \`next\` and \`Blocked By\` is \`None\`.
|
|
31
32
|
|
|
32
33
|
## Required queue maintenance rule
|
|
33
|
-
Every ticket update must regenerate
|
|
34
|
+
Every ticket update must regenerate \`${progressDoc}\`, including \`LLM_NEXT_QUEUE\`.
|
|
34
35
|
Use \`foreman tickets update <id>\` — never manually edit generated sections.
|
|
35
36
|
|
|
36
37
|
## Blocked work
|
|
@@ -45,7 +46,7 @@ Newly discovered work belongs in the Discovered Future Work Inbox.
|
|
|
45
46
|
Use \`foreman tickets discover --summary "..." --rationale "..."\`.
|
|
46
47
|
|
|
47
48
|
## Generated document policy
|
|
48
|
-
Do not manually edit generated sections of
|
|
49
|
+
Do not manually edit generated sections of \`${progressDoc}\`.
|
|
49
50
|
Use the \`foreman tickets\` commands instead.
|
|
50
51
|
|
|
51
52
|
## STEP_STATUS protocol
|
|
@@ -53,6 +54,8 @@ When Foreman is running this project, include the ticket ID in your done marker:
|
|
|
53
54
|
STEP_STATUS: done | ticket="T001" summary="implemented the feature"
|
|
54
55
|
Use \`foreman tickets discover\` for newly discovered work during a build run.
|
|
55
56
|
`;
|
|
57
|
+
}
|
|
58
|
+
export const DEFAULT_TRACKER_RULES = renderTrackerRules();
|
|
56
59
|
export function renderProgressDoc(input) {
|
|
57
60
|
const { config, projectDir, ticketDefs, states, db } = input;
|
|
58
61
|
const now = input.now ?? DateTime.now().setZone(config.timezone).toISO();
|
|
@@ -66,7 +69,7 @@ export function renderProgressDoc(input) {
|
|
|
66
69
|
const rulesPath = join(projectDir, config.paths.trackerRules);
|
|
67
70
|
const rulesContent = existsSync(rulesPath)
|
|
68
71
|
? readFileSync(rulesPath, "utf8").trim()
|
|
69
|
-
:
|
|
72
|
+
: renderTrackerRules(config.paths.progressDoc).trim();
|
|
70
73
|
const lines = [];
|
|
71
74
|
// Header
|
|
72
75
|
lines.push("# Ticket Progress Tracker");
|
package/dist/tickets/validate.js
CHANGED
|
@@ -46,7 +46,7 @@ export function runAllValidation(config, projectDir, ticketDefs, states, db) {
|
|
|
46
46
|
// Pass 4: generated Markdown doc
|
|
47
47
|
const progressDocPath = join(projectDir, config.paths.progressDoc);
|
|
48
48
|
if (!existsSync(progressDocPath)) {
|
|
49
|
-
issues.push({ pass: 4, severity: "error", message:
|
|
49
|
+
issues.push({ pass: 4, severity: "error", message: `[doc] ${config.paths.progressDoc} does not exist; run \`foreman tickets render\`` });
|
|
50
50
|
}
|
|
51
51
|
else {
|
|
52
52
|
const doc = readFileSync(progressDocPath, "utf8");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ai-foreman",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.2.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,7 +14,7 @@
|
|
|
14
14
|
},
|
|
15
15
|
"homepage": "https://github.com/ttante/rafi/tree/main/packages/ai-foreman#readme",
|
|
16
16
|
"bin": {
|
|
17
|
-
"ai-foreman": "
|
|
17
|
+
"ai-foreman": "dist/index.js"
|
|
18
18
|
},
|
|
19
19
|
"exports": {
|
|
20
20
|
"./cli/tickets.js": {
|
|
@@ -65,7 +65,7 @@
|
|
|
65
65
|
"@anthropic-ai/claude-agent-sdk": "^0.3.148"
|
|
66
66
|
},
|
|
67
67
|
"dependencies": {
|
|
68
|
-
"special-agents": "0.
|
|
68
|
+
"special-agents": "0.4.0",
|
|
69
69
|
"@clack/prompts": "^1.4.0",
|
|
70
70
|
"ajv": "^8.20.0",
|
|
71
71
|
"ajv-formats": "^3.0.1",
|