ai-foreman 1.0.11 → 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 +34 -3
- 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/start.js +15 -1
- package/dist/cli/tickets.d.ts +1 -1
- package/dist/cli/tickets.js +18 -11
- package/dist/config.js +1 -0
- 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/foreman.yaml +3 -1
- package/package.json +2 -2
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
|
|
|
@@ -124,6 +125,32 @@ ai-foreman start ./my-project --steps 3 --create-pr --pr-ready --agent codex
|
|
|
124
125
|
ai-foreman start ./my-project --steps 1 --branch-per-ticket --continue --ticket T001
|
|
125
126
|
```
|
|
126
127
|
|
|
128
|
+
### GitHub PR Failure Recovery
|
|
129
|
+
|
|
130
|
+
When `--create-pr` cannot prepare GitHub, push a branch, or create a PR, Foreman records a structured failure and blocks the affected ticket cleanly. Blocked worktrees are retained automatically so you can repair the environment and retry without losing the ticket session.
|
|
131
|
+
|
|
132
|
+
`ai-foreman status ./my-project` shows the latest GitHub failure code, repair commands, last command output, and a retry command when a resumable ticket session exists.
|
|
133
|
+
|
|
134
|
+
Common failure codes:
|
|
135
|
+
|
|
136
|
+
| Code | Meaning |
|
|
137
|
+
| --- | --- |
|
|
138
|
+
| `gh_missing` | GitHub CLI is not installed or not on `PATH`. |
|
|
139
|
+
| `gh_not_authenticated` | `gh` is installed but not authenticated for the remote host. |
|
|
140
|
+
| `repo_unreachable` | `gh repo view` cannot access the target repository. |
|
|
141
|
+
| `git_remote_unreachable` | `git ls-remote` cannot access the configured remote. |
|
|
142
|
+
| `network_or_timeout` | A GitHub or git command timed out or hit a network error. |
|
|
143
|
+
| `push_failed` | The ticket branch could not be pushed. |
|
|
144
|
+
| `pr_create_failed` | The branch pushed, but PR creation or PR body writing failed. |
|
|
145
|
+
|
|
146
|
+
For GitHub Enterprise remotes, repair commands include the remote host:
|
|
147
|
+
|
|
148
|
+
```bash
|
|
149
|
+
gh auth login --hostname <host>
|
|
150
|
+
gh auth status --hostname <host>
|
|
151
|
+
gh repo view <host>/<owner>/<repo>
|
|
152
|
+
```
|
|
153
|
+
|
|
127
154
|
### `ai-foreman tickets populate`
|
|
128
155
|
|
|
129
156
|
| Option | Common values | Default | Notes |
|
|
@@ -223,6 +250,9 @@ Init options:
|
|
|
223
250
|
| `--app-name <name>` | `"My App"` | none | Application name stored in tracker config. |
|
|
224
251
|
| `--timezone <tz>` | `America/Chicago` / `UTC` | `UTC` | IANA timezone. |
|
|
225
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`.
|
|
226
256
|
|
|
227
257
|
This creates:
|
|
228
258
|
|
|
@@ -239,6 +269,8 @@ docs/
|
|
|
239
269
|
ticket-progress.md
|
|
240
270
|
```
|
|
241
271
|
|
|
272
|
+
With `--docs-root docs-rafi`, the generated tracker docs are written under `docs-rafi/` instead.
|
|
273
|
+
|
|
242
274
|
### 2. Add Tickets
|
|
243
275
|
|
|
244
276
|
If the project already has planning material:
|
|
@@ -267,7 +299,7 @@ ai-foreman tickets populate --project ./my-project --agent codex --model gpt-5.5
|
|
|
267
299
|
`populate` tells the builder to:
|
|
268
300
|
|
|
269
301
|
- read `.tickets/*`
|
|
270
|
-
- read
|
|
302
|
+
- read the configured progress doc
|
|
271
303
|
- read existing planning files
|
|
272
304
|
- fill `.tickets/tickets.yaml`
|
|
273
305
|
- render the progress doc
|
|
@@ -375,7 +407,7 @@ ai-foreman tickets queue --project ./my-project
|
|
|
375
407
|
|
|
376
408
|
Generated output:
|
|
377
409
|
|
|
378
|
-
-
|
|
410
|
+
- The configured progress doc is generated from `.tickets/tickets.yaml`.
|
|
379
411
|
- It also includes state from `.tickets/ticket-state.sqlite`.
|
|
380
412
|
- Builders should read it.
|
|
381
413
|
- You should not manually edit generated sections.
|
|
@@ -541,4 +573,3 @@ Package output:
|
|
|
541
573
|
- **`special-agents`** — library (rules + skills + agents + composition)
|
|
542
574
|
- **`ai-foreman`** — this runtime
|
|
543
575
|
- **`@rafi-ai/cli`** — CLI for `rafi create` and `rafi compile`
|
|
544
|
-
|
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
|
|
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
|
|
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()),
|
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,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
|
-
-
|
|
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
|
|
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(
|
|
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
|
|
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(
|
|
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
|
|
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
|
|
490
|
+
.option("--progress <path>", "path to existing ticket progress Markdown")
|
|
487
491
|
.action((opts) => {
|
|
488
492
|
try {
|
|
489
|
-
|
|
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/config.js
CHANGED
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/foreman.yaml
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Foreman configuration. Any field omitted here falls back to the built-in
|
|
2
2
|
# defaults in src/config.ts. This file is read from the *project* directory
|
|
3
|
-
# passed to `foreman start`, so each project can tune its own policy.
|
|
3
|
+
# passed to `ai-foreman start`, so each project can tune its own policy.
|
|
4
4
|
|
|
5
5
|
permissions:
|
|
6
6
|
# Bash commands are auto-approved only when EVERY segment (split on && || ; |)
|
|
@@ -26,6 +26,8 @@ permissions:
|
|
|
26
26
|
- ls
|
|
27
27
|
- "cat "
|
|
28
28
|
- "mkdir "
|
|
29
|
+
- ai-foreman tickets
|
|
30
|
+
# Legacy-compatible prefix for older generated docs and logs.
|
|
29
31
|
- foreman tickets
|
|
30
32
|
- pytest
|
|
31
33
|
- "python "
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ai-foreman",
|
|
3
|
-
"version": "1.0
|
|
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.
|
|
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",
|