ai-foreman 1.0.12 → 1.1.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
@@ -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
 
@@ -249,6 +250,9 @@ Init options:
249
250
  | `--app-name <name>` | `"My App"` | none | Application name stored in tracker config. |
250
251
  | `--timezone <tz>` | `America/Chicago` / `UTC` | `UTC` | IANA timezone. |
251
252
  | `--queue-limit <n>` | `25` / `50` / `100` | `50` | Next-queue window size. |
253
+ | `--docs-root <dir>` | `docs` / `docs-rafi` | `rafi-config.yaml` docs.root, then `docs` | Root for generated progress/archive docs. |
254
+
255
+ `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
256
 
253
257
  This creates:
254
258
 
@@ -265,6 +269,8 @@ docs/
265
269
  ticket-progress.md
266
270
  ```
267
271
 
272
+ With `--docs-root docs-rafi`, the generated tracker docs are written under `docs-rafi/` instead.
273
+
268
274
  ### 2. Add Tickets
269
275
 
270
276
  If the project already has planning material:
@@ -293,7 +299,7 @@ ai-foreman tickets populate --project ./my-project --agent codex --model gpt-5.5
293
299
  `populate` tells the builder to:
294
300
 
295
301
  - read `.tickets/*`
296
- - read `docs/ticket-progress.md`
302
+ - read the configured progress doc
297
303
  - read existing planning files
298
304
  - fill `.tickets/tickets.yaml`
299
305
  - render the progress doc
@@ -401,7 +407,7 @@ ai-foreman tickets queue --project ./my-project
401
407
 
402
408
  Generated output:
403
409
 
404
- - `docs/ticket-progress.md` is generated from `.tickets/tickets.yaml`.
410
+ - The configured progress doc is generated from `.tickets/tickets.yaml`.
405
411
  - It also includes state from `.tickets/ticket-state.sqlite`.
406
412
  - Builders should read it.
407
413
  - 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
 
package/dist/cli/start.js CHANGED
@@ -43,6 +43,9 @@ function findLastSessionId(dir) {
43
43
  function collectTicket(value, previous) {
44
44
  return [...previous, value];
45
45
  }
46
+ function unique(values) {
47
+ return [...new Set(values)];
48
+ }
46
49
  function branchContinueTicketHelp(cwd, flag = "--continue") {
47
50
  const sessions = findResumableBranchSessions(join(cwd, ".foreman"));
48
51
  if (sessions.length === 0) {
@@ -127,7 +130,14 @@ export function buildStartCommand() {
127
130
  const cwd = resolve(project);
128
131
  if (!existsSync(cwd))
129
132
  fail(`project directory not found: ${cwd}`);
130
- const TRACKER_SEARCH_PATHS = ["docs/ticket-progress.md", "ticket-progress.md"];
133
+ const configuredTrackerPath = isTicketsInitialized(cwd)
134
+ ? loadTicketsConfig(cwd).paths.progressDoc
135
+ : undefined;
136
+ const TRACKER_SEARCH_PATHS = unique([
137
+ configuredTrackerPath,
138
+ "docs/ticket-progress.md",
139
+ "ticket-progress.md",
140
+ ].filter(Boolean));
131
141
  let ticketsContent;
132
142
  let trackerRelPath;
133
143
  if (opts.tickets) {
@@ -336,6 +346,10 @@ export function buildStartCommand() {
336
346
  prReady: Boolean(opts.prReady),
337
347
  keepWorktrees: Boolean(opts.keepWorktrees),
338
348
  allowedBaseDirtyPaths,
349
+ trackerPaths: {
350
+ progressDoc: ticketsConfig.paths.progressDoc,
351
+ archiveDoc: ticketsConfig.paths.archiveDoc,
352
+ },
339
353
  resumeSessions: resumeSessionByTicket,
340
354
  createBuilder: (builderCwd, sessionId) => createBuilder(builderCwd, sessionId),
341
355
  observeBuilder: (builder) => printEvents(builder.events()),
@@ -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,7 +10,7 @@ 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";
@@ -37,7 +37,7 @@ function makeLogPath(projectDir, label) {
37
37
  const stamp = new Date().toISOString().replace(/[:.]/g, "-");
38
38
  return join(projectDir, ".foreman", `${stamp}-${label}.jsonl`);
39
39
  }
40
- export function buildPopulateInstruction(sourceHints) {
40
+ export function buildPopulateInstruction(sourceHints, progressDoc = "docs/ticket-progress.md") {
41
41
  const sources = sourceHints ?? [];
42
42
  const sourceHintBlock = sources.length > 0
43
43
  ? `
@@ -60,7 +60,7 @@ Before editing, read these tracker control files:
60
60
  - .tickets/config.yaml
61
61
  - .tickets/tickets.yaml
62
62
  - .tickets/tracker-rules.md
63
- - docs/ticket-progress.md if it exists
63
+ - ${progressDoc} if it exists
64
64
 
65
65
  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
66
 
@@ -96,6 +96,7 @@ export function buildTicketsCommand() {
96
96
  .option("--app-name <name>", "application name")
97
97
  .option("--timezone <tz>", "IANA timezone (e.g. America/Chicago)", "UTC")
98
98
  .option("--queue-limit <n>", "next-queue window size", "50")
99
+ .option("--docs-root <dir>", "repo-relative directory for generated ticket docs")
99
100
  .action((opts) => {
100
101
  const dir = cwd(opts);
101
102
  try {
@@ -103,6 +104,7 @@ export function buildTicketsCommand() {
103
104
  appName: opts.appName,
104
105
  timezone: opts.timezone,
105
106
  queueLimit: Number(opts.queueLimit),
107
+ docsRoot: opts.docsRoot,
106
108
  });
107
109
  console.log(`foreman tickets: initialized .tickets/ in ${dir}`);
108
110
  console.log(`foreman tickets: next — add tickets to .tickets/tickets.yaml and run \`foreman tickets render\``);
@@ -166,7 +168,8 @@ export function buildTicketsCommand() {
166
168
  console.log(`foreman tickets: project ${dir}`);
167
169
  console.log(`foreman tickets: log ${logPath}\n`);
168
170
  try {
169
- const turn = await foreman.runInstruction(buildPopulateInstruction(opts.sources));
171
+ const ticketsConfig = loadTicketsConfig(dir);
172
+ const turn = await foreman.runInstruction(buildPopulateInstruction(opts.sources, ticketsConfig.paths.progressDoc));
170
173
  await builder.close();
171
174
  await viewer;
172
175
  log.write("ticket-populate", {
@@ -198,7 +201,7 @@ export function buildTicketsCommand() {
198
201
  else {
199
202
  console.log("foreman tickets: validation passed — all 4 passes clean");
200
203
  }
201
- console.log("foreman tickets: populated .tickets/tickets.yaml and rendered docs/ticket-progress.md");
204
+ console.log(`foreman tickets: populated .tickets/tickets.yaml and rendered ${ticketsConfig.paths.progressDoc}`);
202
205
  }
203
206
  catch (err) {
204
207
  await builder.close().catch(() => { });
@@ -406,12 +409,13 @@ export function buildTicketsCommand() {
406
409
  // ── render ───────────────────────────────────────────────────────────────────
407
410
  tickets
408
411
  .command("render")
409
- .description("Regenerate docs/ticket-progress.md from current structured sources.")
412
+ .description("Regenerate the configured ticket progress doc from current structured sources.")
410
413
  .option("-p, --project <dir>", "project directory (default: cwd)")
411
414
  .action((opts) => {
412
415
  try {
416
+ const ticketsConfig = loadTicketsConfig(cwd(opts));
413
417
  cmdRender(cwd(opts));
414
- console.log("foreman tickets: rendered docs/ticket-progress.md");
418
+ console.log(`foreman tickets: rendered ${ticketsConfig.paths.progressDoc}`);
415
419
  }
416
420
  catch (err) {
417
421
  fail(String(err instanceof Error ? err.message : err));
@@ -464,7 +468,7 @@ export function buildTicketsCommand() {
464
468
  // ── archive ───────────────────────────────────────────────────────────────────
465
469
  tickets
466
470
  .command("archive")
467
- .description("Update docs/ticket-archive.md and prune old completed rows.")
471
+ .description("Update the configured ticket archive doc and prune old completed rows.")
468
472
  .option("-p, --project <dir>", "project directory (default: cwd)")
469
473
  .option("--older-than-days <n>", "only archive tickets completed more than N days ago")
470
474
  .action((opts) => {
@@ -483,10 +487,13 @@ export function buildTicketsCommand() {
483
487
  .command("import")
484
488
  .description("(stub) Migrate an existing Markdown tracker.")
485
489
  .option("-p, --project <dir>", "project directory (default: cwd)")
486
- .option("--progress <path>", "path to existing docs/ticket-progress.md")
490
+ .option("--progress <path>", "path to existing ticket progress Markdown")
487
491
  .action((opts) => {
488
492
  try {
489
- importFromMarkdown(opts.progress ?? "docs/ticket-progress.md");
493
+ const dir = cwd(opts);
494
+ const configuredProgress = opts.progress ?? loadTicketsConfig(dir).paths.progressDoc;
495
+ const progress = isAbsolute(configuredProgress) ? configuredProgress : join(dir, configuredProgress);
496
+ importFromMarkdown(progress);
490
497
  }
491
498
  catch (err) {
492
499
  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.1.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",
@@ -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",