Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ Guidance for Claude Code sessions working in this repository.

`@apptreesoftware/foreman`: a TypeScript daemon that runs a GitHub issue pipeline unattended. It shells out to `claude -p` for five fixed roles (planner, builder, reviewer, validator, phase-closer) against issues on a GitHub Projects v2 board and does the deterministic bookkeeping between them: labels, board Status, claims, CI reruns, squash merges. One daemon per repository; several per Mac. Everything repository-specific lives in the served repository's `.foreman/` directory, never here.

Read `README.md` first. Design: `docs/2026-09-18-foreman-extraction-design.md` (spec, binding); `docs/2026-09-18-foreman-extraction.md` (the plan that built it).
Read `README.md` first.

## Layout

Expand Down
2 changes: 0 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,6 @@ merge → phase-closer**. The five roles are hardcoded, the label names and boar
hardcoded, and the foreman only works tasks that its own planner created. Section 4 is the whole
process; section 5 is everything a repository gets to change about it.

Design: [`docs/2026-09-18-foreman-extraction-design.md`](docs/2026-09-18-foreman-extraction-design.md).

## 2. Install

On a Mac, with [Homebrew](https://brew.sh):
Expand Down
286 changes: 0 additions & 286 deletions docs/2026-09-18-foreman-extraction-design.md

This file was deleted.

2,561 changes: 0 additions & 2,561 deletions docs/2026-09-18-foreman-extraction.md

This file was deleted.

2 changes: 1 addition & 1 deletion src/budget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export interface BudgetSample {

/**
* Three free `rateLimit` reads per tick turn the hourly GraphQL budget into an attributed
* number (#198): what the loop's own snapshot cost, what the `claude -p` session it dispatched
* number: what the loop's own snapshot cost, what the `claude -p` session it dispatched
* spent, and what was gone before the tick even started — a role session on another issue, a
* second daemon, or a human at a terminal.
*/
Expand Down
4 changes: 2 additions & 2 deletions src/ci.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ const SHA = "1111111111111111111111111111111111111111";
const OTHER_SHA = "2222222222222222222222222222222222222222";
const approved = ["reviewer:approved", "validator:passed"];

/** The shape that stalled #344: approved, validated, in review, and CI red. */
/** The shape that stalls a PR: approved, validated, in review, and CI red. */
function stalled(over: { issueOver?: object; prOver?: object } = {}) {
const i = issue({
number: 20,
Expand Down Expand Up @@ -77,7 +77,7 @@ describe("ciActions", () => {
});
});

describe("jobCandidates on red CI (#362)", () => {
describe("jobCandidates on red CI", () => {
it("queues nothing while the rerun is still owed — that is the cheaper answer", () => {
expect(jobCandidates(stalled())).toEqual([]);
});
Expand Down
4 changes: 2 additions & 2 deletions src/ci.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,12 @@ import { defaultRepoConfig, type RepoConfig } from "./repo-config.ts";
import type { Action, Snapshot } from "./types.ts";

/**
* The free half of the red-CI recovery (#362).
* The free half of the red-CI recovery.
*
* A failed check on an in-review PR used to produce nothing at all: `mergeDecision` refused the
* merge on `checks failure` and `jobCandidates` had no branch for it, so an approved and
* validated PR sat at `idle(nothing eligible)` until someone read the log. Most of those
* failures are flakes — #344 was one — so the first answer is a rerun of the failed jobs, which
* failures are flakes, so the first answer is a rerun of the failed jobs, which
* costs runner time and no model tokens. `pick.ts` handles the other half: when the rerun budget
* for this head commit is spent and CI is still red, the PR gets a builder fix round.
*
Expand Down
14 changes: 7 additions & 7 deletions src/cli/daemon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ export async function runDaemon(
configPath: instance.configPath,
dryRun,
startedAt: new Date().toISOString(),
// A restart must not silently revert the owner's overrides back to foreman.json (#210, #213).
// A restart must not silently revert the owner's overrides back to foreman.json.
model: previous?.model ?? null,
maxSessionsPerDay: previous?.maxSessionsPerDay ?? null,
}),
Expand Down Expand Up @@ -210,22 +210,22 @@ export async function runDaemon(
feed: (session, limit) => readFeed(STATE_DIR, session, limit),
setModel: async (model) => {
// Read per dispatch by runRole's modelFor(), so a session already running keeps the model it
// started with and the next one picks this up — no restart (#210).
// started with and the next one picks this up — no restart.
const next = model === MODEL_DEFAULT ? null : model;
store.patch({ model: next });
log("info", "model set", { model: next, configured: cfg.model });
return `next session runs ${next ?? cfg.model}`;
},
setCap: async (maxSessionsPerDay) => {
// Read per tick by runOnce's capFor(), so raising it while parked on the cap lets the very
// next tick run — no restart, and the sleep is cut short so it happens now (#213).
// next tick run — no restart, and the sleep is cut short so it happens now.
store.patch({ maxSessionsPerDay });
log("info", "cap set", { maxSessionsPerDay, configured: cfg.maxSessionsPerDay });
controller.wake();
return `cap is now ${maxSessionsPerDay ?? `the configured ${cfg.maxSessionsPerDay}`}`;
},
refresh: async () => {
// Every read carries its own board Status, so there is no cache left to drop (#387); the
// Every read carries its own board Status, so there is no cache left to drop; the
// button's remaining job is to make the tick happen now rather than at the end of the poll
// interval — including a poll the idle backoff has stretched.
controller.wake();
Expand All @@ -237,7 +237,7 @@ export async function runDaemon(
setTaskModel: async (issue, model) => {
// The stored task row says which label is on the issue now, so the swap costs at most two
// label edits and no GitHub read. Read per dispatch by runRole's modelFor(), so it applies
// to the issue's next session and never to one already running (#259).
// to the issue's next session and never to one already running.
const current = phaseTasks(store.get().board).find((t) => t.issue === issue)?.model ?? null;
const message = await applyTaskModel(gh, { issue, current }, model);
log("info", "task model set", { issue, model, was: current });
Expand All @@ -257,7 +257,7 @@ export async function runDaemon(
host: cfg.host,
});
log("info", "unblocked", { issue });
// Same reason as the owner gate: reflect it locally so the button goes now (#204), and wake
// Same reason as the owner gate: reflect it locally so the button goes now, and wake
// the loop so the next tick can actually pick the issue up.
const board = store.get().board;
if (board) store.patch({ board: applyUnblockToBoard(board, issue) });
Expand All @@ -269,7 +269,7 @@ export async function runDaemon(
log("info", "owner action", { epic, action });
// The board is only rebuilt by a tick, and a tick that dispatched a session does not return
// for up to wallClockMinutes, so reflect the gate locally instead of leaving the page
// offering a button that has already been pressed (#204).
// offering a button that has already been pressed.
const board = store.get().board;
if (board) store.patch({ board: applyOwnerActionToBoard(board, epic, action) });
// The board is rebuilt per tick, so wake the loop instead of leaving the page stale for a
Expand Down
6 changes: 3 additions & 3 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,15 +23,15 @@ export const ConfigSchema = z.object({
wallClockMinutes: z.number().int().min(1).default(90),
/**
* Passed to every `claude -p`. Defaulted rather than optional so a session never silently
* inherits whatever the interactive CLI default happens to be on this Mac (#210).
* inherits whatever the interactive CLI default happens to be on this Mac.
*/
model: z.string().min(1).default("opus"),
webPort: z.number().int().min(1).max(65535).default(8090),
stallMinutes: z.number().int().min(1).default(5),
/** Preflight refuses to start a tick with fewer GitHub GraphQL points left than this (#192). */
/** Preflight refuses to start a tick with fewer GitHub GraphQL points left than this. */
minGraphqlPoints: z.number().int().min(0).default(500),
/**
* Push notifications for the events that change what the owner has to do (#225). Absent, or
* Push notifications for the events that change what the owner has to do. Absent, or
* with neither channel set, the foreman notifies nothing — the page stays the only view.
* `slackWebhookUrl` is a credential: it never leaves this process (no log line, no role prompt,
* nothing on the page), so keep `foreman.json` out of the repo as it already is.
Expand Down
2 changes: 1 addition & 1 deletion src/ctl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ export async function ctlModel(d: CtlDeps, model: string | null): Promise<void>
d.out(
`choices: ${MODEL_CHOICES.join(", ")}, ${MODEL_DEFAULT} (any model name is accepted; ${MODEL_DEFAULT} clears an override)`,
);
// Tasks whose `model:<name>` label outranks the line above, as of the last tick (#259).
// Tasks whose `model:<name>` label outranks the line above, as of the last tick.
const pinned = phaseTasks(s?.board ?? null).filter((t) => t.model);
if (pinned.length) {
d.out("pinned by label (set from the page, or gh issue edit --add-label model:<name>):");
Expand Down
4 changes: 2 additions & 2 deletions src/dispatch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,11 +89,11 @@ describe("buildArgs / buildPrompt", () => {
expect(a.join(" ")).toContain("--session-id 33333333-3333-3333-3333-333333333333");
expect(a.join(" ")).toContain("--append-system-prompt-file /s/roles/builder.md");
expect(a.join(" ")).not.toContain("dangerously");
// Always pinned, never inherited from the interactive CLI default (#210).
// Always pinned, never inherited from the interactive CLI default.
expect(a.join(" ")).toContain("--model opus");
expect(buildArgs(req, { ...cfg, model: "sonnet" }).join(" ")).toContain("--model sonnet");
});
it("a rebase round says so instead of announcing a fix round (#237)", () => {
it("a rebase round says so instead of announcing a fix round", () => {
const p = buildPrompt({ ...req, round: 2, rebase: true, notes: "merge main" }, cfg, CHECKS);
expect(p).toContain("rebase round");
expect(p).toContain("git merge origin/main");
Expand Down
4 changes: 2 additions & 2 deletions src/dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ export interface DispatchRequest {
/** The composed role prompt and settings for this dispatch, written under the instance state dir. */
promptPath: string;
settingsPath: string;
/** A builder round that only merges origin/main into the PR branch (#237). */
/** A builder round that only merges origin/main into the PR branch. */
rebase: boolean;
}

Expand Down Expand Up @@ -170,7 +170,7 @@ export function buildArgs(req: DispatchRequest, cfg: ForemanConfig): string[] {
req.promptPath,
...(req.resume ? ["--resume", req.sessionId] : ["--session-id", req.sessionId]),
// Always pinned: `cfg.model` is defaulted, so a session never inherits the interactive CLI
// default on this Mac. `runRole` passes the live override through here when one is set (#210).
// default on this Mac. `runRole` passes the live override through here when one is set.
"--model",
cfg.model,
];
Expand Down
10 changes: 5 additions & 5 deletions src/github.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,7 @@ describe("GitHub reads", () => {
expect(calls).toHaveLength(1);
expect(calls[0]?.slice(0, 3)).toEqual(["gh", "api", "graphql"]);
});
it("never reads the whole project board (#387)", async () => {
it("never reads the whole project board", async () => {
const calls: string[][] = [];
const gh = new GitHub(cfg, fakeExec(calls), false);
await gh.listIssues();
Expand Down Expand Up @@ -256,9 +256,9 @@ describe("GitHub reads", () => {
expect(["success", "pending", "failure", "none"]).toContain(pr.checks);
expect(pr.issue === null || Number.isInteger(pr.issue)).toBe(true);
}
// GitHub's own verdict, so a conflicting branch gets a rebase job instead of a merge (#237).
// GitHub's own verdict, so a conflicting branch gets a rebase job instead of a merge.
expect(prs[0]?.mergeable).toBe("CONFLICTING");
// The head commit: a CI rerun is budgeted per sha (#362).
// The head commit: a CI rerun is budgeted per sha.
expect(prs[0]?.headSha).toMatch(/^[0-9a-f]{40}$/);
});
it("resolves status option ids from field-list", async () => {
Expand Down Expand Up @@ -289,7 +289,7 @@ describe("GitHub reads", () => {
});
await expect(new GitHub(cfg, exec, false).getIssue(4242)).rejects.toThrow("#4242");
});
it("every read is live, so a hand board edit is seen by the next one (#249)", async () => {
it("every read is live, so a hand board edit is seen by the next one", async () => {
const statuses = ["Backlog", "Ready"];
let n = 0;
const exec: Exec = async () => ({
Expand Down Expand Up @@ -337,7 +337,7 @@ describe("GitHub writes", () => {
"--delete-branch",
]);
});
it("rerunFailedChecks posts to the newest run for the sha (#362)", async () => {
it("rerunFailedChecks posts to the newest run for the sha", async () => {
const calls: string[][] = [];
const gh = new GitHub(cfg, fakeExec(calls), false);
const sha = "aaaa1111bbbb2222cccc3333dddd4444eeee5555";
Expand Down
12 changes: 6 additions & 6 deletions src/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ export function sizeOf(labels: string[]): Size | null {
}

/**
* The model a `model:<name>` label pins the issue's sessions to (#259). The name reaches `claude`
* The model a `model:<name>` label pins the issue's sessions to. The name reaches `claude`
* as argv, so one that fails `ModelSchema` — a flag, a path, an empty string — reads as no label.
*/
export function modelOf(labels: string[]): string | null {
Expand All @@ -108,7 +108,7 @@ function names(labels: Array<{ name: string }> | undefined): string[] {
/**
* Everything the foreman knows about an issue, including where it sits on the board. The page
* sizes are the ones `gh issue list --json` uses itself, so the ledger window is unchanged
* (#387) — `planApplied` and `ciRerunCount` read comments an arbitrary distance back.
* — `planApplied` and `ciRerunCount` read comments an arbitrary distance back.
*/
const ISSUE_NODE = `
number
Expand All @@ -133,7 +133,7 @@ const ISSUE_NODE = `
* The board Status and item id come from the issue's own `projectItems` rather than from a
* separate `gh project item-list`, which cost 306 of the tick's 310 GraphQL points: it paged
* every field value of every item on the board — closed issues included — to read three fields
* off the handful the foreman had just fetched (#387). This query costs 5.
* off the handful the foreman had just fetched. This query costs 5.
*
* `states` is baked into the string rather than passed as a variable because `gh api graphql`
* has no way to send a list argument.
Expand Down Expand Up @@ -261,7 +261,7 @@ export class GitHub {
/**
* The tick's one full read: issues, their comments and their board Status, in a single query.
* There is no board cache to go stale — every issue carries its own live Status, so a Status
* edit made by hand is seen by the next tick (#249) without a second read (#387).
* edit made by hand is seen by the next tick without a second read.
*/
async listIssues(state: "open" | "all" = "open"): Promise<Issue[]> {
const query = issuesQuery(state);
Expand Down Expand Up @@ -291,7 +291,7 @@ export class GitHub {
/**
* One issue, one query. This used to call `listIssues("all")` — every claim, merge, resume
* and plan re-downloaded every issue in the repo with all of its comments, which is what
* spent the hourly GraphQL budget (#192).
* spent the hourly GraphQL budget.
*/
async getIssue(n: number): Promise<Issue> {
const raw = (
Expand Down Expand Up @@ -347,7 +347,7 @@ export class GitHub {
/**
* Reruns the failed jobs of the newest workflow run for `sha`, and answers with its run id (or
* null when GitHub has no run for that commit). REST rather than GraphQL on purpose: the
* tick's GraphQL budget is for the board reads, and this only fires on a red PR (#362).
* tick's GraphQL budget is for the board reads, and this only fires on a red PR.
*/
async rerunFailedChecks(sha: string): Promise<number | null> {
const found = (
Expand Down
2 changes: 1 addition & 1 deletion src/ledger.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ describe("operator interrupt comments", () => {
});
});

describe("ci rerun ledger (#362)", () => {
describe("ci rerun ledger", () => {
const sha = "1111111111111111111111111111111111111111";
const other = "2222222222222222222222222222222222222222";

Expand Down
2 changes: 1 addition & 1 deletion src/ledger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ export function fixRound(comments: Comment[]): number {

/**
* How many times the foreman has already rerun CI for this head commit. The ledger is the only
* record — the daemon restarts, and a rerun that is forgotten is a rerun that repeats (#362).
* record — the daemon restarts, and a rerun that is forgotten is a rerun that repeats.
*/
export function ciRerunCount(comments: Comment[], sha: string): number {
if (!sha) return 0;
Expand Down
Loading
Loading