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 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 if needed.
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 if needed.
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 `docs/ticket-progress.md`
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
- - `docs/ticket-progress.md` is generated from `.tickets/tickets.yaml`.
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.
@@ -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): boolean;
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;
@@ -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
- return [
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 status = runGit(worktreePath, ["status", "--porcelain", "--", ".tickets", "docs/ticket-progress.md", "ticket-progress.md"]).stdout;
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
+ }
@@ -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): string;
28
- export declare function buildBranchTicketResumeInstruction(node: BranchPlanNode): string;
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;
@@ -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/, docs/ticket-progress.md, or other tracker state.
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/, docs/ticket-progress.md, or other tracker state.
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 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";
@@ -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)", "claude")
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
- const TRACKER_SEARCH_PATHS = ["docs/ticket-progress.md", "ticket-progress.md"];
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: opts.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, "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;
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: opts.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, "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;
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
- opts.model ? `model=${opts.model}` : null,
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",
@@ -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;
@@ -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
- - docs/ticket-progress.md if it exists
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)", "claude")
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, "tickets populate");
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: opts.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 turn = await foreman.runInstruction(buildPopulateInstruction(opts.sources));
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("foreman tickets: populated .tickets/tickets.yaml and rendered docs/ticket-progress.md");
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 docs/ticket-progress.md from current structured sources.")
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("foreman tickets: rendered docs/ticket-progress.md");
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 docs/ticket-archive.md and prune old completed rows.")
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 docs/ticket-progress.md")
497
+ .option("--progress <path>", "path to existing ticket progress Markdown")
487
498
  .action((opts) => {
488
499
  try {
489
- importFromMarkdown(opts.progress ?? "docs/ticket-progress.md");
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
- trackerRule = `\n- Ticket state is managed by foreman — you do NOT need to manually edit docs/ticket-progress.md.` +
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.`;
@@ -15,6 +15,7 @@ export interface InitOptions {
15
15
  appName?: string;
16
16
  timezone?: string;
17
17
  queueLimit?: number;
18
+ docsRoot?: string;
18
19
  }
19
20
  export declare function cmdInit(projectDir: string, opts: InitOptions): void;
20
21
  export interface UpdateOptions {
@@ -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, DEFAULT_TRACKER_RULES } from "./renderMarkdown.js";
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, "docs"), { recursive: true });
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: "docs/ticket-progress.md",
50
- archive_doc: "docs/ticket-archive.md",
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"), DEFAULT_TRACKER_RULES, "utf8");
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",
@@ -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;
@@ -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 const DEFAULT_TRACKER_RULES = "# Tracker Rules\n\n## Purpose\nThis tracker is the operational build control surface for humans and builder agents.\n\n## First file to read\nRead `docs/ticket-progress.md` first. Do not scan the full backlog to choose work\nunless the queue is empty or the user explicitly asks for broader planning.\n\n## How to choose work\nChoose the first row in `LLM_NEXT_QUEUE` where `Status` is `next` and `Blocked By` is `None`.\n\n## Required queue maintenance rule\nEvery ticket update must regenerate `docs/ticket-progress.md`, including `LLM_NEXT_QUEUE`.\nUse `foreman tickets update <id>` \u2014 never manually edit generated sections.\n\n## Blocked work\nDo not implement blocked rows unless the blocker itself is the approved next work.\n\n## Done policy\nNever mark a ticket `done` without required validation evidence or a documented test exception.\nUse `foreman tickets complete <id> --validation-result passed --evidence \"...\"`.\n\n## New work policy\nNewly discovered work belongs in the Discovered Future Work Inbox.\nUse `foreman tickets discover --summary \"...\" --rationale \"...\"`.\n\n## Generated document policy\nDo not manually edit generated sections of `docs/ticket-progress.md`.\nUse the `foreman tickets` commands instead.\n\n## STEP_STATUS protocol\nWhen Foreman is running this project, include the ticket ID in your done marker:\n STEP_STATUS: done | ticket=\"T001\" summary=\"implemented the feature\"\nUse `foreman tickets discover` for newly discovered work during a build run.\n";
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 const DEFAULT_TRACKER_RULES = `# Tracker Rules
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 \`docs/ticket-progress.md\` first. Do not scan the full backlog to choose work
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 \`docs/ticket-progress.md\`, including \`LLM_NEXT_QUEUE\`.
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 \`docs/ticket-progress.md\`.
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
- : DEFAULT_TRACKER_RULES.trim();
72
+ : renderTrackerRules(config.paths.progressDoc).trim();
70
73
  const lines = [];
71
74
  // Header
72
75
  lines.push("# Ticket Progress Tracker");
@@ -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: "[doc] docs/ticket-progress.md does not exist; run `foreman tickets render`" });
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.12",
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": "./dist/index.js"
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.3.7",
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",