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
24 changes: 20 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,9 @@ another instance already holds.
2. 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`, and `model:<m>` for each entry in `.foreman/config.json`'s `models`.
`size:M`, `size:L`, `model:<m>` for each entry in `.foreman/config.json`'s `models`, and
`effort:low`, `effort:medium`, `effort:xhigh`, `effort:max`. A repository that ran `init`
before effort labels existed gets them by running `init` again; it only adds what is missing.
`phase:N` and `area:*` labels are created later, by `epic new` and by the planner.
3. Creates a Projects v2 board, links it to the repository, sets the `Status` single-select
options to exactly **Backlog, Ready, In Progress, In Review, Done**, and writes the project
Expand Down Expand Up @@ -206,7 +208,8 @@ Every `pollSeconds` (default 300) the daemon wakes and runs one iteration:
`feat/<issue>-<slug>`, runs the repository's `setup` command, composes the role prompt and the
headless settings from the worktree's `.foreman/` (§5), runs the `session-env` and
`before-session` hooks, and invokes `claude -p` with the role prompt, the issue/PR/worktree/
branch context, `--max-turns <maxTurns>`, `--model <model>` and a `wallClockMinutes` timeout.
branch context, `--max-turns <maxTurns>`, `--model <model>`, `--effort <level>` (the issue's
`effort:<level>` label, else `high`) and a `wallClockMinutes` timeout.
It posts `session <id> on <host> role=<role> attempt=<n>` before each attempt and runs
`after-session` when the child ends, however it ends.
8. **Outcome.** Parses the session's structured JSON outcome —
Expand Down Expand Up @@ -278,6 +281,7 @@ Fixed names; `foreman init` creates them all.
| `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`. |
| `effort:<level>` | Runs every session on that issue at `low`, `medium`, `xhigh` or `max` effort (Claude Code's levels). No label means `high`. |
| `sandbox` | A throwaway issue for exercising the pipeline. |

### Board statuses
Expand Down Expand Up @@ -386,8 +390,8 @@ Every key is optional; the file may be `{}`. These are the defaults:

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
lands in `logs/foreman.err.log`). `foreman status`, `stop`, `abort`, `go`, `next`, `model`,
`effort` 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:
Expand Down Expand Up @@ -583,6 +587,8 @@ An instance is a name. Its machine config and all of its state live under `~/.fo
`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.
Effort is pinned the same way, as `--effort high`, and has no `foreman.json` key: any other level
is chosen for one task, with its `effort:<level>` label.

### Reaching the page from outside this Mac

Expand Down Expand Up @@ -708,6 +714,16 @@ All of these work from any terminal on the Mac. Inside a configured `repoDir` yo
- **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 and
`foreman.json`. The label is the source of truth, so it works from `gh issue edit` too.
- **Change one task's effort:** every session runs at `high` effort. To run a task at a different
level, give it a label: `foreman -p widgets effort xhigh --issue 12`, or use the effort buttons
under the task on the page's **Phases** card. This covers builder, reviewer, validator and fix
rounds. The levels are Claude Code's own: `low`, `medium`, `high`, `xhigh`, `max`. Choosing
`high` or `default` removes the label. There is no global effort setting.
`foreman -p widgets effort` prints the default and every task with an effort label;
`effort --issue 12` prints that task's effort. With the daemon running, a change goes through
the page, which only accepts tasks listed on the board. With no daemon, it edits the label on
GitHub directly. A session already running keeps its effort; the issue's next session uses the
new one. `gh issue edit 12 --add-label effort:max` works the same way.
- **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
Expand Down
5 changes: 3 additions & 2 deletions src/board.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,8 +74,8 @@ describe("describeBoard", () => {
medianMergeMinutes: 120,
mergedTasks: 1,
tasks: [
{ issue: 1, title: "Task 1", status: "Ready", closed: false, model: null },
{ issue: 2, title: "#2", status: null, closed: true, model: null },
{ issue: 1, title: "Task 1", status: "Ready", closed: false, model: null, effort: null },
{ issue: 2, title: "#2", status: null, closed: true, model: null, effort: null },
],
},
]);
Expand Down Expand Up @@ -131,6 +131,7 @@ describe("overlayCurrent", () => {
fixRound: 1,
blocked: false,
model: null,
effort: null,
},
],
};
Expand Down
1 change: 1 addition & 0 deletions src/board.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ export function overlayCurrent(board: Board, current: CurrentSession, host: stri
fixRound: current.round,
blocked: false,
model: null,
effort: null,
};
const row: PipelineRow = {
...base,
Expand Down
14 changes: 11 additions & 3 deletions src/bootstrap/bootstrap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@ import { existsSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { EFFORT_LEVELS } from "../state-file.ts";
import { ensureProject, STATUS_OPTIONS } from "./board.ts";
import { ensureLabels, ensurePlanLabels, LABELS, modelLabels } from "./labels.ts";
import { effortLabels, ensureLabels, ensurePlanLabels, LABELS, modelLabels } from "./labels.ts";
import { ensureWorktreesIgnored, scaffoldRepoDir } from "./scaffold.ts";

function api(over: Partial<Record<string, unknown>> = {}) {
Expand Down Expand Up @@ -45,10 +46,17 @@ describe("labels", () => {
expect(calls).not.toContain("label epic");
expect(calls).toContain("label agent-ready");
expect(calls).toContain("label model:sonnet");
expect(r.created).toHaveLength(LABELS.length - 2 + 2);
expect(calls).toContain("label effort:xhigh");
// No label means high, so there is no effort:high to create.
expect(calls).not.toContain("label effort:high");
expect(r.created).toHaveLength(LABELS.length - 2 + 2 + EFFORT_LEVELS.length - 1);
});
it("is idempotent", async () => {
const all = [...LABELS.map((l) => l.name), ...modelLabels(["opus"]).map((l) => l.name)];
const all = [
...LABELS.map((l) => l.name),
...modelLabels(["opus"]).map((l) => l.name),
...effortLabels().map((l) => l.name),
];
const { gh, calls } = api({ listLabels: async () => all });
await ensureLabels(gh, ["opus"]);
expect(calls).toEqual([]);
Expand Down
15 changes: 14 additions & 1 deletion src/bootstrap/labels.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { BootstrapApi } from "../github.ts";
import { EFFORT_DEFAULT_LEVEL, EFFORT_LABEL_PREFIX, EFFORT_LEVELS } from "../state-file.ts";

export interface LabelSpec {
name: string;
Expand Down Expand Up @@ -62,13 +63,25 @@ export function modelLabels(models: string[]): LabelSpec[] {
}));
}

/**
* One `effort:<level>` per Claude Code level other than the default, which is what no label
* means. The set is Claude Code's, so it is not configurable.
*/
export function effortLabels(): LabelSpec[] {
return EFFORT_LEVELS.filter((e) => e !== EFFORT_DEFAULT_LEVEL).map((e) => ({
name: `${EFFORT_LABEL_PREFIX}${e}`,
color: "EDEDED",
description: `Run every session on this issue at ${e} effort`,
}));
}

export async function ensureLabels(
gh: Pick<BootstrapApi, "listLabels" | "createLabel">,
models: string[],
): Promise<{ created: string[] }> {
const have = new Set(await gh.listLabels());
const created: string[] = [];
for (const l of [...LABELS, ...modelLabels(models)]) {
for (const l of [...LABELS, ...modelLabels(models), ...effortLabels()]) {
if (have.has(l.name)) continue;
await gh.createLabel(l);
created.push(l.name);
Expand Down
7 changes: 7 additions & 0 deletions src/cli/args.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,13 @@ describe("parseCli", () => {
it("reads --instance after the subcommand too", () => {
expect(parseCli(["status", "--instance", "widgets"]).instance).toBe("widgets");
});
it("effort takes a level and --issue", () => {
expect(parseCli(["effort", "xhigh", "--issue", "12"])).toMatchObject({
cmd: "effort",
positionals: ["xhigh"],
args: { issue: "12" },
});
});
it("add takes name and flags", () => {
expect(
parseCli([
Expand Down
3 changes: 3 additions & 0 deletions src/cli/args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export const COMMANDS = [
"status",
"next",
"model",
"effort",
"cap",
"logs",
"page",
Expand All @@ -40,6 +41,7 @@ export const USAGE = `foreman — one daemon per repository, driven by claude -p
foreman [-p <name>] start | stop | abort | go | restart
foreman [-p <name>] status [--watch] [--json] | next
foreman [-p <name>] model [<name>|default] | cap [<n>|default]
foreman [-p <name>] effort [<level>|default] [--issue <n>] one task's effort; default high
foreman [-p <name>] logs [-f] | page
foreman [-p <name>] launchd install | uninstall | status
foreman [-p <name>] hooks run <preflight|session-env|before-session|after-session>
Expand All @@ -62,6 +64,7 @@ const OPTIONS = {
watch: { type: "boolean" },
json: { type: "boolean" },
f: { type: "boolean" },
issue: { type: "string" },
} as const;

export function parseCli(argv: string[]): Cli {
Expand Down
28 changes: 25 additions & 3 deletions src/cli/ctl.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { existsSync, unlinkSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { loadConfig } from "../config.ts";
import { ctlCap, ctlGo, ctlModel, ctlStop } from "../ctl.ts";
import { ctlCap, ctlEffort, ctlGo, ctlModel, ctlStop } from "../ctl.ts";
import { realExec } from "../exec.ts";
import { GitHub } from "../github.ts";
import type { Instance } from "../instance.ts";
Expand All @@ -17,7 +17,7 @@ import { describeStatus, formatStatus } from "../status.ts";
import { basicAuthorization } from "../web.ts";
import { launchdLabel } from "./launchd.ts";

export type CtlCommand = "status" | "next" | "stop" | "abort" | "go" | "model" | "cap";
export type CtlCommand = "status" | "next" | "stop" | "abort" | "go" | "model" | "effort" | "cap";

function pidAlive(pid: number): boolean {
try {
Expand All @@ -31,7 +31,7 @@ function pidAlive(pid: number): boolean {
export async function runCtl(
instance: Instance,
cmd: CtlCommand,
o: { watch: boolean; json: boolean; value: string | null },
o: { watch: boolean; json: boolean; value: string | null; issue?: number | null },
): Promise<void> {
const uid = launchdUid();
const label = launchdLabel(instance.name);
Expand Down Expand Up @@ -173,6 +173,28 @@ export async function runCtl(
case "model":
await ctlModel(deps, o.value);
break;
case "effort": {
// Built lazily: only the no-daemon path talks to GitHub, and only with --issue.
const gh = () =>
new GitHub({ repo: cfg.repo, owner: cfg.owner, project: cfg.project }, realExec, false);
await ctlEffort(
{
stateDir: STATE_DIR,
pidAlive,
out: deps.out,
postTaskEffort: (issue, effort) =>
postToDaemon("task-effort", { issue, effort }, `#${issue} effort ${effort}`),
issueLabels: async (issue) => (await gh().getIssue(issue)).labels,
labels: {
addLabels: (kind, n, labels) => gh().addLabels(kind, n, labels),
removeLabels: (kind, n, labels) => gh().removeLabels(kind, n, labels),
},
},
o.value,
o.issue ?? null,
);
break;
}
case "cap":
// A non-numeric value other than `default` becomes NaN, which ctlCap refuses by name.
await ctlCap(deps, o.value === null || o.value === "default" ? o.value : Number(o.value));
Expand Down
11 changes: 11 additions & 0 deletions src/cli/daemon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { loadRepoConfigSafe } from "../repo-config.ts";
import { readSessions } from "../sessions.ts";
import { initialState, MODEL_DEFAULT, readState, StateStore } from "../state-file.ts";
import { describeStatus } from "../status.ts";
import { applyTaskEffort, applyTaskEffortToBoard } from "../task-effort.ts";
import { applyTaskModel, applyTaskModelToBoard, phaseTasks } from "../task-model.ts";
import { startWebServer } from "../web.ts";
import { launchdLabel } from "./launchd.ts";
Expand Down Expand Up @@ -247,6 +248,16 @@ export async function runDaemon(
return message;
},
modelChoices: () => repo.models,
setTaskEffort: async (issue, effort) => {
// Same shape as setTaskModel: the stored row names the label on the issue now, and
// runRole's effortFor() reads the label per dispatch, so a running session keeps its effort.
const current = phaseTasks(store.get().board).find((t) => t.issue === issue)?.effort ?? null;
const message = await applyTaskEffort(gh, { issue, current }, effort);
log("info", "task effort set", { issue, effort, was: current });
const board = store.get().board;
if (board) store.patch({ board: applyTaskEffortToBoard(board, issue, effort) });
return message;
},
needsYouItems: () => store.get().board?.needsYou ?? [],
unblock: async (issue) => {
// The stored row carries the project item id the tick already read, so the gate costs one
Expand Down
3 changes: 3 additions & 0 deletions src/cli/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,11 +136,14 @@ async function main(): Promise<number> {
case "status":
case "next":
case "model":
case "effort":
case "cap":
await runCtl(instance, cli.cmd, {
watch: b("watch"),
json: b("json"),
value: cli.positionals[0] ?? null,
// A non-numeric --issue becomes NaN, which ctlEffort refuses by name.
issue: typeof cli.args.issue === "string" ? Number(cli.args.issue) : null,
});
return 0;
case "logs": {
Expand Down
Loading
Loading