npm i -g @apptreesoftware/foremanThe foreman is a small TypeScript daemon that runs a GitHub issue pipeline unattended. It shells
out to the claude CLI (claude -p) to run planner, builder, reviewer, validator and
phase-closer sessions against issues on a GitHub Projects v2 board, and does the deterministic
bookkeeping — labels, board status, claims, PR merges — in between. All judgment happens inside
the claude -p sessions; the foreman itself never decides anything more interesting than "whose
turn is it" and "did CI pass".
One daemon runs one repository. Several daemons run on one Mac, one per repository, each with its
own state directory, its own daily budget and its own web page. Everything the daemon needs to
know about a repository — how to install it, what the checks are, what a validator boots, what a
reviewer must check — is committed in that repository under .foreman/, so the tool itself stays
generic and a change to the process ships as a pull request like anything else.
Run a foreman only on a repository whose merges you gate. Everything a session writes on its branch is what the next session on that branch reads — the House rules, the per-role rules, the extra permissions — so the branch is inside the trust boundary and a human merge is what closes it.
The pipeline is fixed and always on: epic → planner → tasks → builder → reviewer → validator → merge → phase-closer. The five roles are hardcoded, the label names and board statuses are 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.
On a Mac, with Homebrew:
brew install gh node@22
npm i -g @apptreesoftware/foreman
foreman helpNode 22 is required ("engines": { "node": ">=22.18 <23" }). launchd is macOS-only; foreman run works anywhere Node does.
Authenticate gh with the scopes the board needs:
gh auth login
gh auth refresh -s project,read:project
gh auth status # must list 'project' under "Token scopes"Install the claude CLI and log it in once, interactively, so that sessions run against a
Claude subscription rather than API billing:
claude # accept the trust dialog, then quit
claude auth status # must show "authMethod":"claude.ai"The daemon refuses to start a session if claude auth status is anything else, or if
ANTHROPIC_API_KEY, ANTHROPIC_AUTH_TOKEN, CLAUDE_CODE_USE_BEDROCK, CLAUDE_CODE_USE_VERTEX,
ANTHROPIC_FOUNDRY_API_KEY or ANTHROPIC_AWS_API_KEY is set in its environment. Check with:
env | grep -i -E 'anthropic|claude_code_use' # must print nothingVerify the billing once per Mac before you leave a daemon running unattended: run one session, then confirm https://platform.claude.com/settings/usage shows no new API usage and https://claude.ai/settings/usage shows the session that just ran.
Four commands take a repository from nothing to a daemon working its first phase.
git clone git@github.com:acme/widgets.git ~/Projects/widgets
foreman add widgets --repo acme/widgets --repo-dir ~/Projects/widgets
foreman -p widgets initadd writes ~/.foreman/widgets/foreman.json and picks the first free web port from 8090. host
defaults to this Mac's short hostname, lowercased — it is how ledger comments tell Macs apart, so
override it with --host if two Macs would collide. --web-port pins the port; add refuses one
another instance already holds.
init is idempotent and does four things, in order:
- Checks the
ghtoken has theprojectscope, and stops with thegh auth refreshline if not. - Creates any missing label:
epic,agent-ready,blocked,needs-owner,decision,plan-approved,signed-off,foreman:pause,sandbox,reviewer:approved,reviewer:changes,validator:passed,validator:failed,validator:skipped,size:S,size:M,size:L, andmodel:<m>for each entry in.foreman/config.json'smodels.phase:Nandarea:*labels are created later, byepic newand by the planner. - Creates a Projects v2 board, links it to the repository, sets the
Statussingle-select options to exactly Backlog, Ready, In Progress, In Review, Done, and writes the project number back intoforeman.json. Ifprojectis already set it verifies the field and reports drift instead of editing. - Scaffolds
.foreman/in the clone if it is absent:config.jsonwith the defaults, an emptyrules.md,settings.jsonwith emptyallow/deny, and an emptyhooks/. It also adds.worktrees/to the repository's.gitignore, because worktrees live inside the clone by default and a role session's owngit add -Awould otherwise stage them.
The repository needs CI. The merge sweep merges only on checks success, so a repository with
no workflow at all never merges anything: its PRs sit approved and validated for ever, and the log
reads idle(nothing eligible). Any pull_request workflow will do — it is the gate the whole
pipeline hangs on.
Nothing in step 4 is committed. Review it, fill in rules.md, and commit it — the daemon reads
rules.md, roles/<role>.md and settings.json from the worktree at dispatch time, so a PR
that changes them applies to the sessions that run on its own branch. config.json and hooks/
come from the clone at repo-dir instead; section 5 has the whole of it:
cd ~/Projects/widgets
$EDITOR .foreman/config.json .foreman/rules.md
git add .foreman && git commit -m "chore: foreman config" && git pushOpen the phase epic — the one human-made issue per phase, and the only entry point:
foreman -p widgets epic new --title "Phase 1: accounts" --phase 1 --spec docs/specs/accounts.md --agent-readyThe spec path must exist on the default branch and match plans.specGlob. The issue is created
with labels epic, phase:1 and (with --agent-ready) agent-ready, body ## Spec\n<path>, and
added to the board at Backlog. Without --agent-ready the planner will not touch it until you
add the label by hand — that is the go-ahead.
Dry-run one iteration, which reads GitHub and writes nothing:
foreman -p widgets run --once --dry-runExpect a preflight ok line and a plan line. Then run it for real:
foreman -p widgets run --once # one iteration in the foreground
foreman -p widgets start # detach, log to ~/.foreman/widgets/logs/foreman.log
foreman -p widgets status --watch
foreman -p widgets page # opens http://127.0.0.1:8090Once you trust it, hand it to launchd so it starts at login and comes back after a crash:
foreman -p widgets launchd install
foreman -p widgets launchd status
foreman -p widgets launchd uninstalllaunchd install writes ~/Library/LaunchAgents/com.apptreesoftware.foreman.widgets.plist with
concrete paths and bootstraps it. The plist sets KeepAlive, RunAtLoad and ThrottleInterval
60 s. launchd starts the daemon from a clean environment — only the HOME and PATH the plist
names — so the API-billing variables never reach it; and if one is set anyway, the daemon refuses
to start and exits 2. The PATH includes the directory of the node that ran launchd install,
so role sessions on an nvm-only Mac still find node and npx.
Every pollSeconds (default 300) the daemon wakes and runs one iteration:
- Preflight. Refuses to run if an API-billing environment variable is set, if
~/.foreman/<name>/STOPexists, ifclaude auth statusis not aclaude.ailogin, ifgh auth statusfails or its token lacks theprojectscope, if GitHub reports fewer thanminGraphqlPointsGraphQL points left, or if today's session count has reachedmaxSessionsPerDay. Then it runs the repository'spreflighthook (§5). - Resume check. If an issue on the board is In Progress and claimed by this host, that session is resumed instead of anything new being picked.
- Merge sweep. For every open, non-draft PR whose issue is In Review: if CI is green, the PR
carries
reviewer:approved, and it carriesvalidator:passedorvalidator:skipped, the foreman squash-merges, closes the issue, sets Status Done and commentsmerged by foreman@<host>. - CI rerun. For every in-review PR whose checks are
failure, with no open claim, noblockedlabel and no paused epic, the foreman reruns the failed jobs of the newest workflow run for the PR's head commit and commentsci rerun by foreman@<host> for <sha>on the issue.limits.ciReruns(1) per head sha, counted from that ledger comment, so a push starts the budget over and a daemon restart does not forget. This is the cheap half of red-CI recovery: most failures are flakes, and a rerun costs runner time and no model tokens. The expensive half is in the pick — a red PR whose rerun is spent gets a builder fix round, ahead of review and validation, bounded bylimits.fixRoundslike any other, andblockedafter that. - Pick. Candidates are issues with Status Ready, label
agent-ready, aParent epic: #Nline in the body, no open claim, every "Depends on" issue closed, and a phase epic that is notforeman:paused. Open PRs needing review or validation are candidates too, and so is an approved PR whose branch GitHub reports asCONFLICTING: that gets a rebase round — a builder session that only merges the default branch, runs the checks and pushes — at the current fix-round number, so it never counts towardlimits.fixRounds. Priority: lowest phase number, then rebase, review and validate jobs before fix rounds, then new builds; among builds, one whose Touches section overlaps an issue that already has an open PR sorts after one that does not, thensize:SbeforeMbeforeL. A phase withlimits.blockedPerPhase(2) or more openblockedtasks starts no new builds until the owner unblocks one — review, fix, validate and rebase jobs still run there — because every blocked task is owner work and stacking more branches on the same files only breeds conflicts. - Claim. The foreman comments
claimed by <host> at <iso> role=<role> round=<n>, assigns itself, and (for a first-round build) sets Status In Progress. If another host's claim comment landed in the same window, the alphabetically first host keeps it; the others commentreleased by <host>: conflictand unassign. The ledger — issue comments, not the GitHub assignee — is authoritative, so two Macs can share one GitHub login. - Dispatch. Creates or reuses a git worktree at
<workDir>/<issue>— by default<repoDir>/.worktrees/<issue>, so worktrees stay inside the clone — on branchfeat/<issue>-<slug>, runs the repository'ssetupcommand, composes the role prompt and the headless settings from the worktree's.foreman/(§5), runs thesession-envandbefore-sessionhooks, and invokesclaude -pwith the role prompt, the issue/PR/worktree/ branch context,--max-turns <maxTurns>,--model <model>and awallClockMinutestimeout. It postssession <id> on <host> role=<role> attempt=<n>before each attempt and runsafter-sessionwhen the child ends, however it ends. - Outcome. Parses the session's structured JSON outcome —
{"outcome": ..., "pr": <number|null>, "notes": "..."}— and applies the labels and Status in the table below. On a validatorpassed/failedit first copies<worktree>/.validation-artifacts/<pr>/to~/.foreman/<name>/artifacts/<pr>/, because a headless session cannot write outside its worktree and the worktree is removed at merge. A session that ends without a valid outcome is retried up tolimits.attempts(3) times; if all fail, the issue is labelledblockedwith a log excerpt and unassigned. - Phase check. If every task under a phase epic is closed, the phase-closer is dispatched.
Then the planner, which is gated three ways: the epic must carry
agent-ready(the owner's go-ahead — without it the foreman never plans anything), no approved phase may still be building, and no other epic's drafted plan may be waiting on the owner. When it dispatches, the epic is assigned and set In Progress so the hour-long session is visible on the board;plan_draftedthen sets the epic In Review, addsneeds-owner, removesagent-readyand unassigns. Labelling the epicplan-approvedapplies the plan, clearsneeds-ownerand puts the epic back In Progress. Re-addingagent-readyasks for a re-plan. Applying a plan reads<plans.dir>/<date>-phase-NN-plan.issues.jsonfrom the default branch onorigin, so merging the plan PR is enough — the clone never has to be pulled by hand. If the file is not there yet, the foreman logs and retries next tick rather than recording the plan as applied.
These run in order and stop after the first action that would start a claude -p session, so at
most one session runs per iteration per Mac.
An iteration that did work does not then sleep pollSeconds. Work is anything that changed
the board: a session started, a PR merged, a plan applied, a claim released, an issue blocked. A
CI rerun is deliberately not work — its checks stay pending for minutes. The next tick runs
immediately and the log says ticking again immediately with the action that earned it. An
iteration that found nothing eligible sleeps the full interval; a failed one backs off, doubling
per consecutive failure up to 15 minutes.
Applying a plan is idempotent: a task without a number is created only if no open issue already
carries its title and a Parent epic: #<epic> line, so a tick that failed halfway finishes the
plan on retry instead of duplicating it.
| Role outcome | Labels / Status set by the foreman |
|---|---|
builder pr_opened |
issue → In Review |
builder blocked |
issue blocked, unassigned |
reviewer approved / changes_requested |
PR reviewer:approved / reviewer:changes |
validator passed / failed |
PR validator:passed / validator:failed |
(foreman) issue carries a validator.skipLabels label |
PR validator:skipped |
| (foreman) green CI + approved + validated | squash merge, issue closed → Done, merged by foreman@<host> |
| third change request | issue blocked |
(foreman) approved PR CONFLICTING on GitHub |
builder rebase round (merges the default branch, no fix-round bump) |
| (foreman) red CI, rerun budget unspent | rerun the failed jobs, ci rerun by foreman@<host> for <sha> (no session) |
| (foreman) red CI, rerun spent | builder fix round; blocked once limits.fixRounds are gone |
phase-closer phase_closed |
epic → In Review, needs-owner |
planner plan_drafted |
epic → In Review, needs-owner, agent-ready removed, unassigned; nothing else until plan-approved, which applies the plan (tasks agent-ready + Ready), clears needs-owner and returns the epic to In Progress |
Fixed names; foreman init creates them all.
| Label | Meaning |
|---|---|
epic |
A phase's parent issue. |
agent-ready |
On an epic: plan it. On a task: the foreman may claim it. Only the planner's own tasks are ever claimed. |
phase:N |
Which phase an epic or task belongs to. Created by epic new and by the planner. |
area:*, size:S / size:M / size:L |
Set by the planner; size orders the pick, area can skip validation. |
blocked |
Cannot proceed; the reason is a comment. Owner work. |
needs-owner |
Waiting on the owner. |
decision |
A question a role session could not answer. |
plan-approved |
The owner approved the drafted plan; the next tick applies it. |
signed-off |
The owner signed the phase off. |
foreman:pause |
On an epic: no new claims under that phase, on any Mac. |
reviewer:approved / reviewer:changes |
The reviewer's verdict, on the PR. |
validator:passed / validator:failed / validator:skipped |
The validator's verdict, on the PR. |
model:<name> |
Pins every session on that issue to one model, outranking the live override and foreman.json. |
sandbox |
A throwaway issue for exercising the pipeline. |
Exactly five, in this order: Backlog (an epic before planning) → Ready (a task the picker may claim) → In Progress (claimed, a session running) → In Review (a PR is open, or an epic is waiting on the owner) → Done (merged and closed).
The planner writes every task issue in this shape, and the picker depends on it:
## Goal
One paragraph.
## Acceptance criteria
- [ ] Each one is something a validator can observe.
## Touches
src/thing.ts, docs/specs/accounts.md
## Depends on
#12, #13 (or None)
## Spec
docs/specs/accounts.md § 3
Parent epic: #7Depends on gates the pick until those issues are closed. Touches orders the pick away from
conflicts. Parent epic: #N is required — an agent-ready issue without it is never claimed,
and foreman status reports it as waiting on a human. A hand-made issue is not a way into the
pipeline; foreman epic new is.
Issue comments are the source of truth for who is doing what, so two Macs sharing one GitHub login never collide and a restarted daemon picks up where it left off.
claimed by <host> at <iso> role=<role> round=<n>
session <id> on <host> role=<role> attempt=<n>
session <id> finished on <host>: outcome=<o> turns=<n> cost=$<n> duration=<n>m
session <id> interrupted on <host>: stopped by operator after <n>m
released by <host>: <reason>
released by <host>: aborted by operator
reclaimed from <host> by <host> at <iso>
merged by foreman@<host>
ci rerun by foreman@<host> for <sha>
unblocked by owner via foreman@<host>
session … finished, released by, reclaimed from and merged by are terminal: they close an
open claim.
Committed in the repository the foreman works on. Two halves of it are read from two different places, and the difference matters:
.foreman/
config.json process knobs read from the clone, at daemon start
rules.md appended to every role prompt as "House rules" read from the worktree, at every dispatch
roles/<role>.md optional, appended after rules.md for that role read from the worktree, at every dispatch
settings.json { "allow": [...], "deny": [...] } merged in read from the worktree, at every dispatch
hooks/ preflight, session-env, before-session, after-session run from the clone, at every run
rules.md, roles/<role>.md and settings.json are read from the worktree, freshly for each
dispatch, so a pull request can change the process for the sessions that run on its own branch.
config.json and hooks/ are read from the clone at repo-dir: the config once, when the
daemon starts, and a hook every time it runs. A merged pull request that changes either therefore
takes effect only once that clone has been pulled and the daemon restarted. The daemon pulls
(git pull --ff-only, best-effort, never fatal) before its first tick, so in practice
foreman -p widgets restart is enough; a clone that cannot fast-forward logs a warning and the
daemon carries on with the working tree as it stands.
Every key is optional; the file may be {}. These are the defaults:
{
"setup": "pnpm install",
"checks": ["pnpm lint", "pnpm typecheck", "pnpm test"],
"plans": { "dir": "docs/superpowers/plans", "specGlob": "docs/**/*.md" },
"validator": { "skipLabels": [] },
"limits": { "fixRounds": 2, "ciReruns": 1, "blockedPerPhase": 2, "staleHours": 2, "attempts": 3 },
"models": ["opus", "sonnet", "haiku", "fable"]
}| Key | Default | What it does |
|---|---|---|
setup |
"pnpm install" |
Run once when a worktree is created. null skips it. |
checks |
pnpm lint/typecheck/test |
What a rebase round runs, and what every role prompt means by "the checks". |
plans.dir |
docs/superpowers/plans |
Where the planner writes <date>-phase-NN-plan.md and .issues.json, and where apply_plan looks on the default branch. |
plans.specGlob |
docs/**/*.md |
What a ## Spec path must match. ** spans path segments, * stays inside one. |
validator.skipLabels |
[] |
A PR whose issue carries one of these gets validator:skipped instead of a validator session. Use it for work with nothing to drive — infrastructure, schema, docs. |
limits.fixRounds |
2 | Change requests before the issue is blocked. |
limits.ciReruns |
1 | Failed-job reruns per head sha before a fix round. |
limits.blockedPerPhase |
2 | Open blocked tasks in a phase before new builds stop. |
limits.staleHours |
2 | Silence on an In Progress claim before another Mac may reclaim it. |
limits.attempts |
3 | Dispatch attempts for one session before the issue is blocked. |
models |
opus, sonnet, haiku, fable | The page's model buttons, the model:<name> labels init creates, and the names foreman model accepts. |
A strict schema: an unknown key is an error, not a warning. The daemon refuses to start on a bad
file and names it on stderr (foreman: /path/.foreman/config.json: <what is wrong>, exit 2, so it
lands in logs/foreman.err.log). foreman status, stop, abort, go, next, model and
cap still work: they print that same line as a warning and carry on with the defaults, because
the controls are most needed when something is wrong.
A repository with no application to install or run, for instance a docs repository:
{ "setup": null, "checks": ["true"], "validator": { "skipLabels": ["area:docs"] } }Appended to every role prompt under a ## House rules heading, after the base prompt and before
any per-role section. The base prompts carry the pipeline's own contract — never cd out of the
worktree, never touch the default branch, never merge, idempotent GitHub writes, the ledger and
Progress comment formats, the fix-round rules, the PR body template, the outcome JSON. rules.md
carries what is true of this repository and nothing else:
Run `pnpm install` once per worktree. The checks are `pnpm lint`, `pnpm typecheck`, `pnpm test`.
Bring the app up with `pnpm --filter @acme/serve start`; it serves http://localhost:8182 and the
API on http://localhost:3105. Seeded logins are in `docs/seed.md`. Stop it with `serve stop`
before you return, even on a failure.
Never run `docker stop` or touch a container this repository did not start.Sessions also read the repository's CLAUDE.md as any Claude Code session does, so rules.md is
for what the roles need beyond that.
Optional, one per role (builder, reviewer, validator, planner, phase-closer), appended
after rules.md under ## <role> rules. A repository appends; it never replaces a base prompt.
Use it for a reviewer checklist that is specific to the stack, or the exact boot sequence a
validator follows.
Merged into the base Claude Code headless settings that ship with the package:
{
"allow": ["Bash(pnpm *)", "Bash(docker *)", "Bash(npx playwright *)", "mcp__playwright__*"],
"deny": ["Bash(docker stop*)", "Bash(orb *)"]
}Base and repository allow lists are concatenated and deduplicated, and so are the deny lists;
then anything on the combined deny list is removed from the allow list. A repository rule can
add to either list but can never remove a base deny. The base file denies gh pr merge, gh pr close, gh api, pushes to the default branch, force pushes, git worktree remove, sudo, rm -rf of home, and reads of ~/.ssh, ~/.aws and ~/.claude*. Anything stack-specific — pnpm,
docker, a browser MCP — is not in the base file; add it here.
The foreman also appends four denies of its own, per dispatch, for the session's own worktree:
Edit/Write of <worktree>/.foreman/** and <worktree>/.claude/**. A session cannot widen the
next session's permissions, rewrite the House rules the reviewer reads, or add a Claude Code hook
on its own branch. Those files are changed the way everything else is: by a human, in a PR.
Four optional executables in .foreman/hooks/, any language, no rebuild. A missing hook is a
no-op; one that exists but is not executable is a logged no-op (chmod +x it). Each gets a
10-minute timeout, and its stdout and stderr are appended to
~/.foreman/<name>/logs/hooks.log — except session-env's stdout, which is the secrets it exists
to inject and is never logged.
| Variable | Set for |
|---|---|
FOREMAN_INSTANCE, FOREMAN_STATE_DIR, FOREMAN_REPO_DIR |
all hooks |
FOREMAN_ROLE, FOREMAN_ISSUE, FOREMAN_PR, FOREMAN_WORKTREE, FOREMAN_ROUND |
session hooks (FOREMAN_PR is empty when there is none) |
| Hook | Runs | Contract |
|---|---|---|
preflight |
every tick, from repoDir, after the built-in checks |
Exit 0: ok. Non-zero: the daemon parks and the last non-empty stderr line is the reason. Stdout lines starting warn: become preflight warnings. |
session-env |
once per dispatch, around every attempt of that session, from the worktree | Stdout KEY=VALUE lines enter the child's environment. Stdout lines starting prompt: are appended, in order, to the session prompt. A non-zero exit blocks the issue with the hook's stderr; the operator fixes the hook and unblocks. |
before-session |
after session-env, once per dispatch, around every attempt of that session, from the worktree |
A non-zero exit blocks the issue with the hook's stderr; the operator fixes the hook and unblocks. |
after-session |
once per dispatch, from the worktree: after the last attempt exits, is killed, or the daemon is interrupted; and again before the next dispatch on the same worktree | The exit code is logged and never fatal. |
Debug one by hand against the instance's repoDir:
foreman -p widgets hooks run preflight.foreman/hooks/preflight — park the daemon when the database it needs is down, and warn (but
carry on) when the browser the validator wants is missing:
#!/usr/bin/env bash
set -euo pipefail
if ! docker info >/dev/null 2>&1; then
echo "Docker is not responding; start OrbStack" >&2
exit 1
fi
if ! docker compose -f "$FOREMAN_REPO_DIR/docker-compose.yml" up -d db >/dev/null 2>&1; then
echo "could not start the db container" >&2
exit 1
fi
if [ ! -d "$HOME/Library/Caches/ms-playwright" ]; then
echo "warn: no Playwright browser installed; validator sessions will fail"
fi.foreman/hooks/session-env — give the three roles that run the app their own ports, and tell the
session where to find it. Everything on stdout that is not a prompt: line must be KEY=VALUE:
#!/usr/bin/env bash
set -euo pipefail
case "${FOREMAN_ROLE:-}" in
builder|reviewer|validator) ;;
*) exit 0 ;;
esac
echo "WEB_PORT=8182"
echo "API_PORT=3105"
echo "DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:55621/postgres"
echo "prompt: This session runs against an isolated stack: web http://localhost:8182, api http://localhost:3105."
echo "prompt: Never start the developer stack on 8082/3005; the owner is using it.".foreman/hooks/before-session — rewrite a config file the session must not commit, and hide it
from git add -A:
#!/usr/bin/env bash
set -euo pipefail
[ "${FOREMAN_ROLE:-}" = "validator" ] || exit 0
"$FOREMAN_WORKTREE/scripts/isolate-ports.sh" "$FOREMAN_WORKTREE/config/app.toml"
git -C "$FOREMAN_WORKTREE" update-index --skip-worktree config/app.toml.foreman/hooks/after-session — undo it, whatever happened to the child. This one must be
idempotent and must not fail the tick, hence the || true:
#!/usr/bin/env bash
set -uo pipefail
[ -n "${FOREMAN_WORKTREE:-}" ] || exit 0
git -C "$FOREMAN_WORKTREE" update-index --no-skip-worktree config/app.toml 2>/dev/null || true
git -C "$FOREMAN_WORKTREE" checkout -- config/app.toml 2>/dev/null || trueAn instance is a name. Its machine config and all of its state live under ~/.foreman/<name>/:
~/.foreman/widgets/
foreman.json machine config (below)
state.json daemon state: the pid, the current session, the next tick
sessions.log one JSON line per finished session
merges.log one JSON line per merge
notify.json notification bookkeeping
STOP kill switch, present or absent
logs/ foreman.log, hooks.log
activity/ <sessionId>.jsonl live feeds
artifacts/<pr>/ validator screenshots copied out of the worktree
settings.json generated per dispatch: base headless settings + .foreman/settings.json
roles/<role>.md generated per dispatch: base prompt + rules.md + roles/<role>.md
foreman.json, written by add and completed by init:
| Key | Required | Default |
|---|---|---|
repo |
yes | — (owner/name) |
project |
written by init |
— |
host |
yes | this Mac's short hostname |
repoDir |
yes | — |
workDir |
no | <repoDir>/.worktrees |
model |
no | opus |
pollSeconds |
no | 300 |
maxSessionsPerDay |
no | 20 |
maxTurns |
no | 200 |
wallClockMinutes |
no | 90 |
stallMinutes |
no | 5 |
webPort |
no | first free port from 8090, chosen by add |
minGraphqlPoints |
no | 500 |
notify |
no | none (§7) |
webAuth |
no | none: the page is localhost-only |
webHosts |
no | none: the page is localhost-only |
workDir absent means worktrees live at <repoDir>/.worktrees/<issue>, so a checkout is
self-contained; add .worktrees/ to the repository's .gitignore. model is always passed as
--model, so a session never inherits whatever the interactive claude default happens to be.
The page listens on 127.0.0.1 and, by default, refuses any request whose Host is not
localhost. To reach it through a tunnel, set HTTP Basic credentials:
"webAuth": { "user": "me", "password": "at least eight characters" }With webAuth set, every route (the page itself included) demands those credentials, the Host
check is dropped, and foreman stop/go/model/cap send them too. Then
ngrok http 8090 (or any tunnel that terminates TLS) exposes the page; the browser prompts once.
The password is a credential like the Slack webhook: it is never logged or served. Restart the
daemon after changing it.
To reach the page over a private network without credentials, list the names this Mac answers to on that network:
"webHosts": ["mini", "100.84.252.56"]Each entry is a hostname or an IP literal. The page accepts every entry as a Host at
webPort, so http://mini:8090 and http://100.84.252.56:8090 both load it; an IPv6 literal is
written bare in the list and sent in brackets by the browser (http://[fd7a::1]:8090). IP literals
are also listened on, next to 127.0.0.1, which is always bound. Hostnames are only matched against
Host; the daemon never resolves or binds them, so list the IP the name resolves to as well. An
address this Mac does not hold when the daemon starts (the network interface is down, say) logs a
warning and the page stays up on the others; restart the daemon once the interface is back. Each
bound address logs a web page listening line with its URL.
Anyone who can reach a listed address gets unauthenticated control of the page: stop, go, model,
cap, unblock and the owner actions. List only addresses on a private network you trust. With
webAuth also set, credentials replace the Host check as above, and webHosts only adds the
listen addresses. Restart the daemon after changing it.
Every command that needs an instance resolves it in this order and stops at the first hit:
-p <name>/--instance <name>.$FOREMAN_INSTANCE.- The current directory is inside a configured
repoDir(or one of its worktrees) — so inside~/Projects/widgetsyou can just typeforeman status. - Exactly one instance exists under
~/.foreman/. - Otherwise it errors and lists the instances it found.
$FOREMAN_CONFIG is an escape hatch that points straight at a foreman.json outside the layout.
foreman list
# widgets acme/widgets running :8090
# sprockets acme/sprockets stopped :8091Nothing is shared between instances: not STOP, not the daily cap, not state.json, not the
board cache. Two daemons against two repositories run side by side as long as their webPorts
differ — add picks the next free port from 8090 and refuses a port another instance already
holds. Give each one its own launchd agent (foreman -p <name> launchd install); the labels are
com.apptreesoftware.foreman.<name>, so they do not collide either.
They do share the Claude subscription and the GitHub GraphQL budget, so halve maxSessionsPerDay
and raise pollSeconds if you run several.
All of these work from any terminal on the Mac. Inside a configured repoDir you can drop -p.
- See what it is doing:
foreman -p widgets status(add--watchfor a live view,--jsonfor the raw report). It readsstate.json, which the daemon writes every tick, whenever aclaude -pchild starts or ends, and (throttled to every 2 s) as the child's stream-json output arrives: thedoingline shows the last tool call and how long ago, turn and token counts, and the last thing the session said.STALLEDappears afterstallMinutes(default 5) without output — a long tool call counts as silence, so it can appear briefly on a healthy session. With no session running, thenowline distinguishes four things:parked · <reason>(preflight fails every tick, so nothing happens until the condition clears),between ticks · next <time>,ticking, andidle— reserved for a tick that found nothing eligible. Thewaitingline lists what blocks a new claim — CI on a PR, a human label, aDepends onissue, another Mac's claim, theSTOPfile, the daily cap — ranked so a live blocker comes first. Thephaseline gives one line per open approved epic: tasks done of total, the spend and session count, and the median claim→merge time of the ones that merged. - Ask what the next tick would do:
foreman -p widgets next. The only subcommand that calls GitHub; it explains why each open PR is or is not mergeable, and it ignores theSTOPfile. - The page:
foreman -p widgets pageopenshttp://127.0.0.1:<webPort>, served by the daemon while it runs and bound to localhost only. It carries the same view plus a pipeline table (build → review → validate → CI → merge per in-flight issue), a Phase card with a progress bar, a Needs you card, an Owner card of label gates, and a live event feed per session. Feeds live in~/.foreman/<name>/activity/<sessionId>.jsonland hold tool names and one-line summaries only, never tool inputs or thinking. - Show the board as it is now: the page's Refresh project button. It drops the cached
board and wakes the loop, so a Status you set in the GitHub UI shows up within a tick rather
than at the end of the poll interval.
foreman godoes the waking half only. - Stop now, resume later:
foreman -p widgets stop. TouchesSTOP, thenSIGTERMs the daemon, which kills theclaude -pchild, postssession <id> interrupted on <host>: stopped by operatorand exits. The claim stays open, so the next start resumes that session with--resume.launchctl bootoutand Ctrl-C do the same. - Stop now and discard the session:
foreman -p widgets abort. The daemon postsreleased by <host>: aborted by operator, unassigns itself and moves a round-1 builder issue back to Ready. The worktree is left for reuse. If the daemon is already dead andstate.jsonshows an unfinished claim,abortposts the release itself. - Start again:
foreman -p widgets goremovesSTOPand wakes the daemon, orlaunchctl kickstarts it, or prints the start command.foreman -p widgets restartstops and starts, clearing theSTOPthatstopleft. - This Mac, next poll only:
touch ~/.foreman/widgets/STOP. Preflight fails on the next iteration and the daemon parks until the file is gone. - This phase, every Mac:
gh issue edit <epic> --add-label foreman:pause. No new claims are picked under that phase anywhere; in-flight sessions finish normally. - The planning backstop, every Mac: the planner never runs on an epic without
agent-ready, and never on a second epic while a drafted plan is waiting on the owner. Starting a phase is therefore always an explicitgh issue edit <epic> --add-label agent-ready. - Raise the daily cap:
foreman -p widgets capprints the cap the next tick will enforce and where it came from;foreman -p widgets cap 50raises it;cap defaulthands control back toforeman.json. The cap is read per tick, so raising it un-parks a capped daemon immediately. The override survives a restart. The Tick card offers the same, confirm-first. - Change the model:
foreman -p widgets modelprints what the next session will run as;foreman -p widgets model sonnetchanges it. With the daemon running the change goes through the page, which ownsstate.json, and applies at the next dispatch — the session running now keeps its model, and no restart is needed.model defaultclears the override. Any model name is accepted, including a dated id;modelsinconfig.jsonis just the buttons. - Pin one task to a model: a
model:<name>label on the issue runs every session on it — builder, reviewer, validator, fix rounds — as that model, outranking both the live override andforeman.json. The label is the source of truth, so it works fromgh issue edittoo. - Owner gates, from the page: the Owner card lists every open epic with the label gates
only a human can open — Sign off, Approve plan, Start planning, Pause/Resume.
Each button confirms the exact label change and wakes the daemon. The same gates are just
labels, so
gh issue edit <epic> --add-label <label>is identical. - Issues waiting on you: the Needs you card lists every open non-epic issue labelled
needs-owner,decisionorblocked, oldest first.foreman statusprints the same list. Ablockedrow has an Unblock button: it removesblocked, sets Status Ready and commentsunblocked by owner via foreman@<host>— fix whatever caused the block first, because nothing else about the issue changes. - Stop the launchd agent entirely:
launchctl bootout gui/$(id -u)/com.apptreesoftware.foreman.widgets, orforeman -p widgets launchd uninstall.
foreman start refuses when the daemon recorded in state.json is still alive, and status warns
when the web port is held by a different process — the symptom of a stale daemon serving a frozen
page while a newer one does the work. A killed daemon (kill -9) does no bookkeeping: status
then shows CRASHED and, if the child is still alive, ORPHAN child <pid>; stop or abort
kills it.
Optional and off by default. Add a notify block to ~/.foreman/<name>/foreman.json; both
channels are independent, so set either, both or neither.
{
"notify": {
"slackWebhookUrl": "https://hooks.slack.com/services/T000/B000/xxxxxxxx",
"macos": true
}
}slackWebhookUrl— a Slack incoming webhook. Create one at https://api.slack.com/apps → your app (or Create New App → From scratch) → Incoming Webhooks → toggle on → Add New Webhook to Workspace → choose the channel or your own DM. That URL is a credential:foreman.jsonlives outside the repository, and the foreman never logs it, never puts it in a role prompt and never serves it on the page. Rotate it from the same Slack page if it leaks.macos—trueshows a Notification Center banner viaosascript, on whichever Mac the daemon runs on. The first banner may need permission for whatever runs the daemon (Terminal, or System Settings → Notifications → Script Editor forlaunchd).
Seven events, one line each, with the GitHub link:
| Event | Line |
|---|---|
| PR merged | merged #<pr> → closes #<issue>: <title> |
| Issue blocked | blocked #<issue> <title> — <first line of the reason> |
| Decision issue opened | decision needed #<issue>: <title> |
| Phase closed | phase <epic> closed: <title> — review #<n> |
| Plan drafted | plan drafted for #<epic>: <title> |
| Daemon parked | foreman parked on <host>: <reason> |
| Daemon resumed | foreman resumed on <host> (was: <reason>) |
A notification carries issue and PR numbers, titles and the foreman's own reason strings — never
tool inputs, session transcripts or the stderr excerpt that goes into a blocked by foreman@…
comment. Parked fires once per parked spell, not once per tick; decision issues are announced once
each, and enabling notifications on a repository that already has open ones seeds them silently
rather than replaying the backlog. That bookkeeping is ~/.foreman/<name>/notify.json — delete it
to re-announce everything. A send that fails logs a warn line and is dropped; it never fails a
tick and is never retried. --dry-run notifies nothing.
Knobs in ~/.foreman/<name>/foreman.json:
maxSessionsPerDay— preflight refuses a new session once today's count, read fromsessions.log, reaches this. "Today" is the local date, so the count clears at local midnight, and interrupted or aborted sessions still count. When the cap is what is holding the Mac,statusand the page sayparked · daily session cap reached (N/N)and the cap is changeable from there (§7).maxTurns— passed toclaude -p --max-turns; a session that hits it without returning an outcome counts as a failed attempt and is retried, up tolimits.attempts.wallClockMinutes— the dispatcher kills the child (SIGTERM, thenSIGKILLafter 30 s) if it runs longer than this.stallMinutes— minutes without a stream-json event before the page andstatusflag the session as stalled. Visibility only; nothing is killed.minGraphqlPoints— preflight refuses to start a tick when GitHub reports fewer GraphQL points left than this (default 500).
There is a second budget besides money: GitHub gives 5000 GraphQL points an hour, and gh issue list, gh pr list and gh project item-list all spend it. The foreman stays inside it by reading
one issue at a time (gh issue view) instead of re-listing the repository, caching the board for
the length of a tick, reusing the tick's snapshot, and polling every pollSeconds. If it still
runs out, preflight parks the daemon with GitHub GraphQL budget low: <n> points left, resets <time> until the window rolls over. Each tick samples the budget three times — the rateLimit
query is free — and breaks the spend down three ways: reads is the loop's own snapshot,
session is what the claude -p session it dispatched spent, and elsewhere is what was gone
before the tick started (another Mac, a second daemon, or a human at a terminal). Attribute before
optimising.
Per-session cost is posted in the session … finished issue comment and appended as one JSON line
per session to ~/.foreman/<name>/sessions.log, which is also what the daily cap reads. Each line
carries t, host, role, issue, sessionId, attempt, costUsd, outcome, model,
turns, durationMinutes, denials and subtype, so models can be compared on cost, turns and
wall-clock rather than on cost alone:
jq -r '[.t, .role, .issue, .model, .costUsd, .durationMinutes] | @tsv' ~/.foreman/widgets/sessions.log
jq -s 'map(.costUsd) | add' ~/.foreman/widgets/sessions.logA merge appends one line to merges.log (issue, pr, host, claimedAt, mergedAt). The
merged issue drops out of every later snapshot, so this is what keeps the phase card's cycle time
computable. Both logs are local caches; losing them only empties those numbers.
All durable state lives in GitHub. Local disk — the worktree, the transcript, sessions.log — is a
cache the foreman can lose without losing work, because every role checkpoints by pushing its
branch.
- Same Mac, same session: the foreman finds its own open claim and, if the local Claude Code
transcript for that session id still exists, resumes with
--resume <id>and a note to continue from the last Progress comment. - Same Mac, transcript gone: a fresh session id is used instead, with a note to read the
issue's Progress comments and
git log origin/<default>..HEADbefore continuing. - Different Mac, or after a crash: an In Progress issue with no comment and no branch commit
for
limits.staleHours(2) may be reclaimed by any other foreman. It commentsreclaimed from <host> by <host> at <iso>and starts a fresh session with the same instruction. - Two Macs claim the same issue in the same window: both post a
claimed by …comment before either sees the other's. Once both are visible the alphabetically first host keeps it and the others commentreleased by <host>: conflictand unassign — so pickhostvalues with that in mind if it matters which Mac wins. Work is never lost, only not duplicated. - Change requests: a reviewer or validator failure cycles a builder back for a fix round; after
limits.fixRounds(2) failed rounds — a third change request — the issue isblockedinstead of retried. Two things keep that cap from being hit for the wrong reasons: a fix round is scoped (round 2 and later may block only on prior findings still open, regressions in the delta, or a real safety issue; anything else becomes a follow-up issue), and a merge conflict is never a change request — an approved PR that conflicts gets a rebase round that does not count. - To unblock a task by hand: fix or rebase the branch, push, remove
reviewer:changesfrom the PR andblockedfrom the issue (or use the page's Unblock button). The fix-round counter stays where it was, so the next reviewer verdict must be an approval or the task is blocked again.
preflight failed: gh token lacks the project scope. The daemon parks every tick until the token can read and write the board. Fix it withgh auth refresh -s project,read:project, thenforeman -p <name> go.foreman initprints the same line rather than half-creating a board.- A hook is not doing what you expect. Run it by hand against the instance's
repoDir:foreman -p <name> hooks run preflight. Its stdout, its stderr and the hook's exit code are printed straight to your terminal, and the command exits with the hook's own code. When the daemon runs it instead, the same lines land in~/.foreman/<name>/logs/hooks.log. Check the hook is executable — one that exists withoutchmod +xis a logged no-op, not an error. "Ignoring N permissions.allow entries"in a session log. The worktree was not marked trusted in~/.claude.json. The foreman does that itself after creating a worktree, so this usually means the worktree was created or moved outside the normal flow — check~/.claude.jsonhas aprojectsentry for the worktree path with"hasTrustDialogAccepted": true.error_max_turnsas the session subtype. The role hit--max-turnsbefore returning an outcome. RaisemaxTurnsif the role's work is legitimately long, or check for a loop in the session's feed on the page.- The daemon is idle and nothing is claimed.
foreman -p <name> nextsays why. The common causes are an epic with noagent-ready, a drafted plan with noplan-approved, a task whose body has noParent epic: #Nline (a hand-made issue is never claimed — useforeman epic new), aDepends onissue still open, or a phase held bylimits.blockedPerPhase. - An approved, validated PR is never merged and
nextsayschecks none. The repository has no workflow that runs onpull_request, so there is no green check to merge on and nothing will ever produce one. Add any CI workflow.checks noneis notchecks failure: it earns no rerun and no fix round either, so the PR simply sits there. approved plan file not on origin/main; retrying next tick, for ever. The plan PR really is not merged yet, or the plan file'sNNdoes not match the epic'sphase:Nlabel. The foreman looks for<plans.dir>/<date>-phase-NN-plan.issues.jsonwhereNNis that label, zero-padded; the planner is told the same rule, so a mismatch means the plan was hand-edited.~/.foreman/<name>/artifacts/<pr>/is empty after a validation run. The foreman only copies what the validator left in<worktree>/.validation-artifacts/<pr>/, and only on apassed/failedoutcome — a session that died without an outcome archives nothing. Look for thearchived validation artifactsline inlogs/foreman.log. Role sessions cannot write to the artifacts directory themselves: in--permission-mode dontAska Bash command that creates a file outside the worktree is denied even thoughmkdirandmvare allowlisted.- A session's
ghwrite was denied.gh apiis denied to role sessions by the base settings, as is a multi-line--body; the prompts tell every role to write the text to.gh-body.mdand pass--body-file. If a role reports a denial in its notes, that is usually why. - Two daemons fighting over a port.
foreman listshows each instance's port and whether its daemon is running;foreman statuswarns when the port is held by a different process. Pick a free port withforeman add --web-port, or stop the stale daemon. - Branch protection. A private repository on GitHub's free plan cannot have branch protection
rules, so the guards against an accidental direct push are the base deny rules (
gh pr merge*,git push origin <default>*) applied to every headless session, plus the rule that only the foreman itself callsgh pr merge.
The manual test that a release works end to end, run against a throwaway repository. It exercises
add, init, epic new, the planner, approval, and one task from claim to merge.
gh repo create acme/foreman-sandbox --private --clone --add-readme
cd foreman-sandbox
mkdir -p docs && $EDITOR docs/sandbox.md # two paragraphs describing something tiny
git add -A && git commit -m "docs: sandbox spec" && git push
foreman add sandbox --repo acme/foreman-sandbox --repo-dir "$PWD"
foreman -p sandbox initA repository with no application needs a .foreman/config.json that says so, and rules.md that
tells the roles there is nothing to run, so the planner labels tasks area:docs and the validator
is skipped by label:
cat > .foreman/config.json <<'JSON'
{ "setup": null, "checks": ["true"], "validator": { "skipLabels": ["area:docs"] } }
JSON
cat > .foreman/rules.md <<'MD'
This is a sandbox repository with no application. There is nothing to run or serve; a validator
should confirm the acceptance criteria by reading the merged files. Label every task `area:docs`.
MDIt also needs a check to merge on, because nothing merges on checks none:
mkdir -p .github/workflows
cat > .github/workflows/ci.yml <<'YML'
name: ci
on:
push:
branches: [main]
pull_request:
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: test -f docs/sandbox.md
YML
git add -A && git commit -m "chore: foreman config and a check to merge on" && git pushThen run the pipeline:
foreman -p sandbox epic new --title "Phase 1: sandbox" --phase 1 --spec docs/sandbox.md --agent-ready
foreman -p sandbox status
foreman -p sandbox run --once # the planner claims the epic and drafts a plan PRExpect claimed by <host> … role=planner, then a plan PR against the sandbox and the epic at
In Review with needs-owner. Merge the plan PR, approve the plan, and let the loop apply it:
gh pr merge <plan pr> --squash --repo acme/foreman-sandbox
gh issue edit 1 --repo acme/foreman-sandbox --add-label plan-approved
foreman -p sandbox run --once # applies the plan: task issues at Ready, agent-ready
foreman -p sandbox run --once # claims the first task for a builder
foreman -p sandbox start
foreman -p sandbox status --watch # or: foreman -p sandbox logs -fWatch one task go build → review (→ validate, unless skipped by label) → merge, then stop:
foreman -p sandbox stop
jq -r '[.role, .issue, .outcome, .costUsd] | @tsv' ~/.foreman/sandbox/sessions.logA pass is: the task's PR merged, its issue closed at Done, a merged by foreman@<host> comment
on the issue, and a line in sessions.log per session. Letting it run on to the end of the phase
adds the phase-closer and its review issue, which is the last stage the pipeline has.
For scale: the check for 0.1.0 ran a three-task phase from agent-ready to a phase review issue
in 26 minutes of wall-clock over 8 sessions and $6.69 — planner $1.35, then a builder and a
reviewer per task, then the phase-closer, with the validator skipped by area:docs. Median
claim→merge was 12 minutes. The daily cap (20) is plenty.
git clone git@github.com:apptreesoftware/foreman.git
cd foreman
pnpm install
pnpm link --global # `foreman` now runs this checkout's build
pnpm build && foreman helppnpm dev help runs the CLI from source with tsx, without a build. The checks are the same ones
CI runs:
pnpm lint # biome check .
pnpm typecheck # tsc -p tsconfig.json
pnpm test # vitest run
pnpm build # tsc -p tsconfig.build.json → dist/Every test is a unit test against sanitised fixtures (acme/widgets) and a fixture repository at
test/fixtures/repo/.foreman/; nothing in the suite touches the network, GitHub or a real
claude. pnpm test -- src/hooks.test.ts runs one file.
The package ships dist/, roles/ and settings/; roles/ and settings/ are read at runtime
from one level above the running file, so they resolve the same from src/ and from dist/.
A v* tag publishes. .github/workflows/release.yml installs, runs the four checks, asserts the
tag matches package.json's version, and runs npm publish:
# bump "version" in package.json, commit, push, then:
git tag v0.1.1 && git push --tags
gh release create v0.1.1 --title "0.1.1" --notes "…"There is no NPM_TOKEN secret. The workflow authenticates through npm trusted publishing:
npm publish exchanges the job's GitHub OIDC token for a short-lived publish credential, which is
why the job needs id-token: write. That needs npm 11.5 or newer, and setup-node installs
whatever npm Node 22 bundles (10.x), so the workflow runs npm i -g npm@11 first.
Provenance attestations are off because npm only generates them for a public or internal
repository and this one is private; set "provenance": true in publishConfig if the repository
is ever made public.
Trusted publishing is configured per package on npmjs.com, and the package has to exist there first, so the very first version is published by hand from a logged-in Mac:
pnpm lint && pnpm typecheck && pnpm test && pnpm build
npm publish --access public --otp=<code from your authenticator>--otp because npm requires a second factor for a publish from a laptop.
Owner action, once, after that first publish: on
https://www.npmjs.com/package/@apptreesoftware/foreman/access → Trusted publishing → add a
GitHub Actions publisher with organization apptreesoftware, repository foreman, workflow
release.yml, environment blank. Until that is done the release job fails at npm publish, and
every version has to go out by hand.
MIT.