ai-foreman 1.0.9 → 1.0.11
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 +21 -2
- package/dist/adapters/claude.js +14 -4
- package/dist/adapters/codex.js +5 -0
- package/dist/branch/git.d.ts +22 -0
- package/dist/branch/git.js +94 -0
- package/dist/branch/github.d.ts +21 -0
- package/dist/branch/github.js +414 -0
- package/dist/branch/planner.d.ts +19 -0
- package/dist/branch/planner.js +196 -0
- package/dist/branch/resume.d.ts +21 -0
- package/dist/branch/resume.js +115 -0
- package/dist/branch/runner.d.ts +28 -0
- package/dist/branch/runner.js +365 -0
- package/dist/branch/types.d.ts +70 -0
- package/dist/branch/types.js +1 -0
- package/dist/cli/doctor.js +27 -1
- package/dist/cli/runtimeAuthPrompt.d.ts +2 -0
- package/dist/cli/runtimeAuthPrompt.js +27 -0
- package/dist/cli/start.js +275 -18
- package/dist/cli/status.js +148 -2
- package/dist/cli/tickets.js +2 -0
- package/dist/foreman.d.ts +6 -0
- package/dist/foreman.js +4 -0
- package/dist/log.d.ts +1 -1
- package/dist/runtimeAuth.d.ts +23 -0
- package/dist/runtimeAuth.js +116 -0
- package/package.json +1 -1
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { TicketDef } from "../tickets/ticketSchema.js";
|
|
2
|
+
import type { TicketState } from "../tickets/stateDb.js";
|
|
3
|
+
import type { BranchPlan } from "./types.js";
|
|
4
|
+
export interface AuditDependency {
|
|
5
|
+
ticket: string;
|
|
6
|
+
dependsOn: string;
|
|
7
|
+
reason?: string;
|
|
8
|
+
}
|
|
9
|
+
export interface BuildBranchPlanOptions {
|
|
10
|
+
steps: number;
|
|
11
|
+
baseRef: string;
|
|
12
|
+
branchPrefix: string;
|
|
13
|
+
maxBranchDepth: number;
|
|
14
|
+
auditDependencies?: AuditDependency[];
|
|
15
|
+
}
|
|
16
|
+
export declare function buildBranchPlan(tickets: TicketDef[], states: Map<string, TicketState>, opts: BuildBranchPlanOptions): BranchPlan;
|
|
17
|
+
export declare function selectTicketsForBranchRun(tickets: TicketDef[], states: Map<string, TicketState>, steps: number): TicketDef[];
|
|
18
|
+
export declare function parseAuditDependencies(text: string): AuditDependency[];
|
|
19
|
+
export declare function buildBranchAuditInstruction(tickets: TicketDef[]): string;
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
export function buildBranchPlan(tickets, states, opts) {
|
|
2
|
+
const selected = selectTicketsForBranchRun(tickets, states, opts.steps);
|
|
3
|
+
const selectedIds = new Set(selected.map((ticket) => ticket.id));
|
|
4
|
+
const issues = [];
|
|
5
|
+
const dependencyMap = new Map();
|
|
6
|
+
for (const ticket of selected) {
|
|
7
|
+
dependencyMap.set(ticket.id, ticket.depends_on.filter((dep) => selectedIds.has(dep)));
|
|
8
|
+
}
|
|
9
|
+
for (const dep of opts.auditDependencies ?? []) {
|
|
10
|
+
if (!selectedIds.has(dep.ticket) || !selectedIds.has(dep.dependsOn))
|
|
11
|
+
continue;
|
|
12
|
+
const existing = dependencyMap.get(dep.ticket) ?? [];
|
|
13
|
+
if (!existing.includes(dep.dependsOn))
|
|
14
|
+
existing.push(dep.dependsOn);
|
|
15
|
+
dependencyMap.set(dep.ticket, existing);
|
|
16
|
+
}
|
|
17
|
+
for (const cycle of detectSelectedCycles(selected, dependencyMap)) {
|
|
18
|
+
issues.push({ code: "cycle", message: `branch dependency cycle: ${cycle}`, blocking: true });
|
|
19
|
+
}
|
|
20
|
+
const usedBranches = new Set();
|
|
21
|
+
const branchById = new Map();
|
|
22
|
+
const nodeById = new Map();
|
|
23
|
+
const depthMemo = new Map();
|
|
24
|
+
const depthOf = (id, visiting = new Set()) => {
|
|
25
|
+
if (depthMemo.has(id))
|
|
26
|
+
return depthMemo.get(id);
|
|
27
|
+
if (visiting.has(id))
|
|
28
|
+
return Number.POSITIVE_INFINITY;
|
|
29
|
+
visiting.add(id);
|
|
30
|
+
const deps = dependencyMap.get(id) ?? [];
|
|
31
|
+
const depth = deps.length === 0 ? 1 : Math.max(...deps.map((dep) => depthOf(dep, visiting))) + 1;
|
|
32
|
+
visiting.delete(id);
|
|
33
|
+
depthMemo.set(id, depth);
|
|
34
|
+
return depth;
|
|
35
|
+
};
|
|
36
|
+
for (const ticket of selected) {
|
|
37
|
+
const dependencies = dependencyMap.get(ticket.id) ?? [];
|
|
38
|
+
if (dependencies.length > 1 && !hasAncestorJoin(dependencies, dependencyMap)) {
|
|
39
|
+
issues.push({
|
|
40
|
+
ticket: ticket.id,
|
|
41
|
+
code: "multi_root_join",
|
|
42
|
+
message: `${ticket.id} depends on multiple selected roots (${dependencies.join(", ")})`,
|
|
43
|
+
blocking: true,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
const depth = depthOf(ticket.id);
|
|
47
|
+
if (depth > opts.maxBranchDepth) {
|
|
48
|
+
issues.push({
|
|
49
|
+
ticket: ticket.id,
|
|
50
|
+
code: "depth_exceeded",
|
|
51
|
+
message: `${ticket.id} branch depth ${depth} exceeds --max-branch-depth ${opts.maxBranchDepth}`,
|
|
52
|
+
blocking: true,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
for (const ticket of selected) {
|
|
57
|
+
branchById.set(ticket.id, uniqueBranchName(opts.branchPrefix, ticket, usedBranches));
|
|
58
|
+
}
|
|
59
|
+
for (const ticket of selected) {
|
|
60
|
+
const dependencies = dependencyMap.get(ticket.id) ?? [];
|
|
61
|
+
const branch = branchById.get(ticket.id);
|
|
62
|
+
const baseTicket = chooseBaseDependency(dependencies, dependencyMap);
|
|
63
|
+
const baseBranch = baseTicket ? (branchById.get(baseTicket) ?? opts.baseRef) : opts.baseRef;
|
|
64
|
+
const node = {
|
|
65
|
+
ticket,
|
|
66
|
+
branch,
|
|
67
|
+
baseRef: opts.baseRef,
|
|
68
|
+
baseBranch,
|
|
69
|
+
dependencies,
|
|
70
|
+
depth: depthOf(ticket.id),
|
|
71
|
+
};
|
|
72
|
+
nodeById.set(ticket.id, node);
|
|
73
|
+
}
|
|
74
|
+
return { baseRef: opts.baseRef, nodes: selected.map((ticket) => nodeById.get(ticket.id)), issues };
|
|
75
|
+
}
|
|
76
|
+
export function selectTicketsForBranchRun(tickets, states, steps) {
|
|
77
|
+
const remaining = tickets
|
|
78
|
+
.filter((ticket) => {
|
|
79
|
+
const status = states.get(ticket.id)?.status ?? "planned";
|
|
80
|
+
return status !== "done" && status !== "canceled";
|
|
81
|
+
})
|
|
82
|
+
.sort((a, b) => a.order - b.order);
|
|
83
|
+
const simulatedDone = new Set(Array.from(states.values())
|
|
84
|
+
.filter((state) => state.status === "done")
|
|
85
|
+
.map((state) => state.ticket_id));
|
|
86
|
+
const selected = [];
|
|
87
|
+
for (const ticket of remaining) {
|
|
88
|
+
if (selected.length >= steps)
|
|
89
|
+
break;
|
|
90
|
+
if (ticket.depends_on.every((dep) => simulatedDone.has(dep))) {
|
|
91
|
+
selected.push(ticket);
|
|
92
|
+
simulatedDone.add(ticket.id);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return selected;
|
|
96
|
+
}
|
|
97
|
+
export function parseAuditDependencies(text) {
|
|
98
|
+
const deps = [];
|
|
99
|
+
for (const line of text.split(/\r?\n/)) {
|
|
100
|
+
const match = line.match(/\b([A-Za-z][A-Za-z0-9-_]*)\s+(?:depends_on|depends on|after)\s+([A-Za-z][A-Za-z0-9-_]*)\b/i);
|
|
101
|
+
if (match)
|
|
102
|
+
deps.push({ ticket: match[1], dependsOn: match[2], reason: line.trim() });
|
|
103
|
+
}
|
|
104
|
+
return deps;
|
|
105
|
+
}
|
|
106
|
+
export function buildBranchAuditInstruction(tickets) {
|
|
107
|
+
const body = tickets.map((ticket) => [
|
|
108
|
+
`${ticket.id}: ${ticket.title}`,
|
|
109
|
+
`Summary: ${ticket.summary}`,
|
|
110
|
+
`Acceptance: ${ticket.acceptance.join("; ")}`,
|
|
111
|
+
`Likely files: ${ticket.likely_files.join(", ") || "unknown"}`,
|
|
112
|
+
`Declared depends_on: ${ticket.depends_on.join(", ") || "None"}`,
|
|
113
|
+
].join("\n")).join("\n\n");
|
|
114
|
+
return `Audit this selected ticket batch for undeclared code dependencies. Do not implement anything.
|
|
115
|
+
|
|
116
|
+
Return one line per required selected-ticket dependency using:
|
|
117
|
+
<ticket> depends_on <ticket> - <short reason>
|
|
118
|
+
|
|
119
|
+
Only name dependencies where both tickets are in this selected batch. If there are none, say "None".
|
|
120
|
+
|
|
121
|
+
${body}`;
|
|
122
|
+
}
|
|
123
|
+
function uniqueBranchName(prefix, ticket, used) {
|
|
124
|
+
const cleanPrefix = prefix.replace(/^\/+|\/+$/g, "") || "rafi";
|
|
125
|
+
const baseSlug = `${ticket.id}-${ticket.title}`
|
|
126
|
+
.toLowerCase()
|
|
127
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
128
|
+
.replace(/^-+|-+$/g, "")
|
|
129
|
+
.slice(0, 72);
|
|
130
|
+
let branch = `${cleanPrefix}/${baseSlug}`;
|
|
131
|
+
let i = 2;
|
|
132
|
+
while (used.has(branch)) {
|
|
133
|
+
branch = `${cleanPrefix}/${baseSlug}-${i}`;
|
|
134
|
+
i++;
|
|
135
|
+
}
|
|
136
|
+
used.add(branch);
|
|
137
|
+
return branch;
|
|
138
|
+
}
|
|
139
|
+
function detectSelectedCycles(tickets, deps) {
|
|
140
|
+
const selected = new Set(tickets.map((ticket) => ticket.id));
|
|
141
|
+
const visited = new Set();
|
|
142
|
+
const inStack = new Set();
|
|
143
|
+
const cycles = [];
|
|
144
|
+
function dfs(id, path) {
|
|
145
|
+
if (inStack.has(id)) {
|
|
146
|
+
const start = path.indexOf(id);
|
|
147
|
+
cycles.push([...path.slice(start), id].join(" -> "));
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
if (visited.has(id))
|
|
151
|
+
return;
|
|
152
|
+
visited.add(id);
|
|
153
|
+
inStack.add(id);
|
|
154
|
+
for (const dep of deps.get(id) ?? []) {
|
|
155
|
+
if (selected.has(dep))
|
|
156
|
+
dfs(dep, [...path, id]);
|
|
157
|
+
}
|
|
158
|
+
inStack.delete(id);
|
|
159
|
+
}
|
|
160
|
+
for (const ticket of tickets)
|
|
161
|
+
dfs(ticket.id, []);
|
|
162
|
+
return cycles;
|
|
163
|
+
}
|
|
164
|
+
function hasAncestorJoin(parents, deps) {
|
|
165
|
+
for (const left of parents) {
|
|
166
|
+
for (const right of parents) {
|
|
167
|
+
if (left !== right && isAncestor(left, right, deps))
|
|
168
|
+
return true;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
function isAncestor(ancestor, child, deps, visited = new Set()) {
|
|
174
|
+
if (visited.has(child))
|
|
175
|
+
return false;
|
|
176
|
+
visited.add(child);
|
|
177
|
+
for (const dep of deps.get(child) ?? []) {
|
|
178
|
+
if (dep === ancestor || isAncestor(ancestor, dep, deps, visited))
|
|
179
|
+
return true;
|
|
180
|
+
}
|
|
181
|
+
return false;
|
|
182
|
+
}
|
|
183
|
+
function chooseBaseDependency(dependencies, deps) {
|
|
184
|
+
if (dependencies.length === 0)
|
|
185
|
+
return undefined;
|
|
186
|
+
return [...dependencies].sort((a, b) => chainDepth(b, deps) - chainDepth(a, deps))[0];
|
|
187
|
+
}
|
|
188
|
+
function chainDepth(id, deps, visiting = new Set()) {
|
|
189
|
+
if (visiting.has(id))
|
|
190
|
+
return 0;
|
|
191
|
+
visiting.add(id);
|
|
192
|
+
const parents = deps.get(id) ?? [];
|
|
193
|
+
const depth = parents.length === 0 ? 1 : Math.max(...parents.map((dep) => chainDepth(dep, deps, visiting))) + 1;
|
|
194
|
+
visiting.delete(id);
|
|
195
|
+
return depth;
|
|
196
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { BranchRunSummary } from "./types.js";
|
|
2
|
+
export interface BranchResumeSession {
|
|
3
|
+
ticket: string;
|
|
4
|
+
branch: string;
|
|
5
|
+
base: string;
|
|
6
|
+
worktreePath: string;
|
|
7
|
+
sessionId: string;
|
|
8
|
+
logPath: string;
|
|
9
|
+
agent?: string;
|
|
10
|
+
model?: string;
|
|
11
|
+
effort?: string;
|
|
12
|
+
fast?: boolean;
|
|
13
|
+
qaEnabled?: boolean;
|
|
14
|
+
createPr?: boolean;
|
|
15
|
+
prReady?: boolean;
|
|
16
|
+
keepWorktrees?: boolean;
|
|
17
|
+
ts?: string;
|
|
18
|
+
}
|
|
19
|
+
export declare function findResumableBranchSessions(foremanDir: string): BranchResumeSession[];
|
|
20
|
+
export declare function formatBranchContinueCommand(projectDir: string, session: BranchResumeSession): string;
|
|
21
|
+
export declare function formatBranchSummaryFollowupCommands(projectDir: string, foremanDir: string, summaries: Pick<BranchRunSummary, "ticket" | "buildStatus">[]): string[];
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
export function findResumableBranchSessions(foremanDir) {
|
|
4
|
+
if (!existsSync(foremanDir))
|
|
5
|
+
return [];
|
|
6
|
+
const byTicket = new Map();
|
|
7
|
+
const logs = readdirSync(foremanDir)
|
|
8
|
+
.filter((file) => file.endsWith(".jsonl"))
|
|
9
|
+
.sort();
|
|
10
|
+
for (const file of logs) {
|
|
11
|
+
const logPath = join(foremanDir, file);
|
|
12
|
+
const lines = readFileSync(logPath, "utf8").split(/\r?\n/).filter(Boolean);
|
|
13
|
+
for (const line of lines) {
|
|
14
|
+
let record;
|
|
15
|
+
try {
|
|
16
|
+
record = JSON.parse(line);
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
if (record.event === "branch-session") {
|
|
22
|
+
const ticket = stringField(record.ticket);
|
|
23
|
+
const branch = stringField(record.branch);
|
|
24
|
+
const base = stringField(record.base);
|
|
25
|
+
const worktreePath = stringField(record.worktreePath);
|
|
26
|
+
const sessionId = stringField(record.sessionId);
|
|
27
|
+
if (!ticket || !branch || !base || !worktreePath || !sessionId)
|
|
28
|
+
continue;
|
|
29
|
+
byTicket.set(ticket, {
|
|
30
|
+
ticket,
|
|
31
|
+
branch,
|
|
32
|
+
base,
|
|
33
|
+
worktreePath,
|
|
34
|
+
sessionId,
|
|
35
|
+
logPath,
|
|
36
|
+
agent: stringField(record.agent),
|
|
37
|
+
model: stringField(record.model),
|
|
38
|
+
effort: stringField(record.effort),
|
|
39
|
+
fast: booleanField(record.fast),
|
|
40
|
+
qaEnabled: booleanField(record.qaEnabled),
|
|
41
|
+
createPr: booleanField(record.createPr),
|
|
42
|
+
prReady: booleanField(record.prReady),
|
|
43
|
+
keepWorktrees: booleanField(record.keepWorktrees),
|
|
44
|
+
ts: stringField(record.ts),
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
else if (record.event === "branch-complete") {
|
|
48
|
+
const ticket = stringField(record.ticket);
|
|
49
|
+
const status = stringField(record.status);
|
|
50
|
+
if (ticket && status === "done")
|
|
51
|
+
byTicket.delete(ticket);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return Array.from(byTicket.values())
|
|
56
|
+
.filter((session) => existsSync(session.worktreePath))
|
|
57
|
+
.sort((a, b) => a.ticket.localeCompare(b.ticket, undefined, { numeric: true }));
|
|
58
|
+
}
|
|
59
|
+
export function formatBranchContinueCommand(projectDir, session) {
|
|
60
|
+
const args = [
|
|
61
|
+
"ai-foreman",
|
|
62
|
+
"start",
|
|
63
|
+
shellQuote(projectDir),
|
|
64
|
+
"--steps",
|
|
65
|
+
"1",
|
|
66
|
+
session.createPr ? "--create-pr" : "--branch-per-ticket",
|
|
67
|
+
"--continue",
|
|
68
|
+
"--ticket",
|
|
69
|
+
shellQuote(session.ticket),
|
|
70
|
+
];
|
|
71
|
+
if (session.agent && session.agent !== "claude")
|
|
72
|
+
args.push("--agent", shellQuote(session.agent));
|
|
73
|
+
if (session.model)
|
|
74
|
+
args.push("--model", shellQuote(session.model));
|
|
75
|
+
if (session.effort)
|
|
76
|
+
args.push("--effort", shellQuote(session.effort));
|
|
77
|
+
if (session.fast)
|
|
78
|
+
args.push("--fast");
|
|
79
|
+
if (session.qaEnabled === false)
|
|
80
|
+
args.push("--no-qa");
|
|
81
|
+
if (session.prReady)
|
|
82
|
+
args.push("--pr-ready");
|
|
83
|
+
if (session.keepWorktrees)
|
|
84
|
+
args.push("--keep-worktrees");
|
|
85
|
+
return args.join(" ");
|
|
86
|
+
}
|
|
87
|
+
export function formatBranchSummaryFollowupCommands(projectDir, foremanDir, summaries) {
|
|
88
|
+
const sessions = findResumableBranchSessions(foremanDir);
|
|
89
|
+
const sessionByTicket = new Map(sessions.map((session) => [session.ticket, session]));
|
|
90
|
+
const seen = new Set();
|
|
91
|
+
const commands = [];
|
|
92
|
+
for (const summary of summaries) {
|
|
93
|
+
if (summary.buildStatus !== "blocked" && summary.buildStatus !== "needs-human")
|
|
94
|
+
continue;
|
|
95
|
+
if (summary.ticket === "plan" || seen.has(summary.ticket))
|
|
96
|
+
continue;
|
|
97
|
+
const session = sessionByTicket.get(summary.ticket);
|
|
98
|
+
if (!session)
|
|
99
|
+
continue;
|
|
100
|
+
seen.add(summary.ticket);
|
|
101
|
+
commands.push(formatBranchContinueCommand(projectDir, session));
|
|
102
|
+
}
|
|
103
|
+
return commands;
|
|
104
|
+
}
|
|
105
|
+
function stringField(value) {
|
|
106
|
+
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
107
|
+
}
|
|
108
|
+
function booleanField(value) {
|
|
109
|
+
return typeof value === "boolean" ? value : undefined;
|
|
110
|
+
}
|
|
111
|
+
function shellQuote(value) {
|
|
112
|
+
if (/^[A-Za-z0-9_./:@%+=,-]+$/.test(value))
|
|
113
|
+
return value;
|
|
114
|
+
return `'${value.replace(/'/g, "'\"'\"'")}'`;
|
|
115
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { BuilderAdapter, EffortLevel } from "../adapters/types.js";
|
|
2
|
+
import type { Log } from "../log.js";
|
|
3
|
+
import type { BranchPlan, BranchPlanNode, BranchRunSummary } from "./types.js";
|
|
4
|
+
export interface BranchRunnerOptions {
|
|
5
|
+
projectDir: string;
|
|
6
|
+
runId: string;
|
|
7
|
+
plan: BranchPlan;
|
|
8
|
+
log: Log;
|
|
9
|
+
agent?: string;
|
|
10
|
+
model?: string;
|
|
11
|
+
effort?: EffortLevel;
|
|
12
|
+
fast?: boolean;
|
|
13
|
+
notificationsEnabled: boolean;
|
|
14
|
+
qaEnabled: boolean;
|
|
15
|
+
createPr: boolean;
|
|
16
|
+
prReady: boolean;
|
|
17
|
+
keepWorktrees: boolean;
|
|
18
|
+
allowedBaseDirtyPaths?: string[];
|
|
19
|
+
resumeSessions?: Map<string, {
|
|
20
|
+
worktreePath: string;
|
|
21
|
+
sessionId: string;
|
|
22
|
+
}>;
|
|
23
|
+
createBuilder: (cwd: string, sessionId?: string) => Promise<BuilderAdapter>;
|
|
24
|
+
observeBuilder?: (builder: BuilderAdapter) => Promise<void>;
|
|
25
|
+
}
|
|
26
|
+
export declare function runBranchPlan(opts: BranchRunnerOptions): Promise<BranchRunSummary[]>;
|
|
27
|
+
export declare function buildBranchTicketInstruction(node: BranchPlanNode): string;
|
|
28
|
+
export declare function buildBranchTicketResumeInstruction(node: BranchPlanNode): string;
|