diff --git a/README.md b/README.md index 2286280..0293392 100644 --- a/README.md +++ b/README.md @@ -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:` for each entry in `.foreman/config.json`'s `models`. + `size:M`, `size:L`, `model:` 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 @@ -206,7 +208,8 @@ Every `pollSeconds` (default 300) the daemon wakes and runs one iteration: `feat/-`, 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 `, `--model ` and a `wallClockMinutes` timeout. + branch context, `--max-turns `, `--model `, `--effort ` (the issue's + `effort:` label, else `high`) and a `wallClockMinutes` timeout. It posts `session on role= attempt=` before each attempt and runs `after-session` when the child ends, however it ends. 8. **Outcome.** Parses the session's structured JSON outcome — @@ -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:` | Pins every session on that issue to one model, outranking the live override and `foreman.json`. | +| `effort:` | 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 @@ -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: `, 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: @@ -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 `/.worktrees/`, 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:` label. ### Reaching the page from outside this Mac @@ -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:` 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 diff --git a/src/board.test.ts b/src/board.test.ts index 3ea10cf..3c56879 100644 --- a/src/board.test.ts +++ b/src/board.test.ts @@ -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 }, ], }, ]); @@ -131,6 +131,7 @@ describe("overlayCurrent", () => { fixRound: 1, blocked: false, model: null, + effort: null, }, ], }; diff --git a/src/board.ts b/src/board.ts index ec0ee78..90b8ade 100644 --- a/src/board.ts +++ b/src/board.ts @@ -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, diff --git a/src/bootstrap/bootstrap.test.ts b/src/bootstrap/bootstrap.test.ts index 755bd29..d0f1134 100644 --- a/src/bootstrap/bootstrap.test.ts +++ b/src/bootstrap/bootstrap.test.ts @@ -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> = {}) { @@ -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([]); diff --git a/src/bootstrap/labels.ts b/src/bootstrap/labels.ts index 0343803..7a7032e 100644 --- a/src/bootstrap/labels.ts +++ b/src/bootstrap/labels.ts @@ -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; @@ -62,13 +63,25 @@ export function modelLabels(models: string[]): LabelSpec[] { })); } +/** + * One `effort:` 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, 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); diff --git a/src/cli/args.test.ts b/src/cli/args.test.ts index d151fb9..9f40941 100644 --- a/src/cli/args.test.ts +++ b/src/cli/args.test.ts @@ -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([ diff --git a/src/cli/args.ts b/src/cli/args.ts index 94c444e..e78337a 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -14,6 +14,7 @@ export const COMMANDS = [ "status", "next", "model", + "effort", "cap", "logs", "page", @@ -40,6 +41,7 @@ export const USAGE = `foreman — one daemon per repository, driven by claude -p foreman [-p ] start | stop | abort | go | restart foreman [-p ] status [--watch] [--json] | next foreman [-p ] model [|default] | cap [|default] + foreman [-p ] effort [|default] [--issue ] one task's effort; default high foreman [-p ] logs [-f] | page foreman [-p ] launchd install | uninstall | status foreman [-p ] hooks run @@ -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 { diff --git a/src/cli/ctl.ts b/src/cli/ctl.ts index 690d264..9ae4da4 100644 --- a/src/cli/ctl.ts +++ b/src/cli/ctl.ts @@ -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"; @@ -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 { @@ -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 { const uid = launchdUid(); const label = launchdLabel(instance.name); @@ -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)); diff --git a/src/cli/daemon.ts b/src/cli/daemon.ts index e8507ff..1a8e3b3 100644 --- a/src/cli/daemon.ts +++ b/src/cli/daemon.ts @@ -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"; @@ -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 diff --git a/src/cli/main.ts b/src/cli/main.ts index e5b74cc..372806e 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -136,11 +136,14 @@ async function main(): Promise { 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": { diff --git a/src/ctl.test.ts b/src/ctl.test.ts index 4bd5a5c..10d3d57 100644 --- a/src/ctl.test.ts +++ b/src/ctl.test.ts @@ -2,7 +2,15 @@ import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; -import { type CtlDeps, ctlCap, ctlGo, ctlModel, ctlStop } from "./ctl.ts"; +import { + type CtlDeps, + ctlCap, + ctlEffort, + ctlGo, + ctlModel, + ctlStop, + type EffortCtlDeps, +} from "./ctl.ts"; import { initialState, readState, writeState } from "./state-file.ts"; const current = { @@ -174,8 +182,22 @@ describe("ctlModel", () => { medianMergeMinutes: null, mergedTasks: 0, tasks: [ - { issue: 101, title: "One", status: "Ready", closed: false, model: null }, - { issue: 102, title: "Two", status: "Ready", closed: false, model: "sonnet" }, + { + issue: 101, + title: "One", + status: "Ready", + closed: false, + model: null, + effort: null, + }, + { + issue: 102, + title: "Two", + status: "Ready", + closed: false, + model: "sonnet", + effort: null, + }, ], }, ], @@ -279,3 +301,73 @@ describe("ctlGo", () => { expect(b.log.out.join("\n")).toContain("foreman -p widgets start"); }); }); + +describe("ctlEffort", () => { + function effortDeps(o: { alive?: boolean; labels?: string[]; state?: boolean } = {}) { + const dir = mkdtempSync(join(tmpdir(), "tt-ctl-effort-")); + const log = { out: [] as string[], gh: [] as string[] }; + if (o.state !== false) writeState(dir, base()); + const d: EffortCtlDeps = { + stateDir: dir, + pidAlive: () => o.alive ?? false, + out: (l) => { + log.out.push(l); + }, + postTaskEffort: async (issue, effort) => { + log.out.push(`posted #${issue} ${effort}`); + return `#${issue} runs at ${effort} effort from its next session`; + }, + issueLabels: async () => o.labels ?? [], + labels: { + addLabels: async (_k, n, l) => { + log.gh.push(`add ${n} ${l.join(",")}`); + }, + removeLabels: async (_k, n, l) => { + log.gh.push(`remove ${n} ${l.join(",")}`); + }, + }, + }; + return { d, log }; + } + + it("with no arguments names the default and the levels a task may pick", async () => { + const { d, log } = effortDeps(); + await ctlEffort(d, null, null); + expect(log.out[0]).toContain("effort high"); + expect(log.out.join("\n")).toContain("low, medium, high, xhigh, max"); + }); + it("refuses a level without --issue: other levels are chosen per task", async () => { + const { d, log } = effortDeps({ alive: true }); + await ctlEffort(d, "max", null); + expect(log.out.join("\n")).toContain("--issue"); + expect(log.out.join("\n")).not.toContain("posted"); + }); + it("refuses anything that is not a Claude Code level", async () => { + const { d, log } = effortDeps({ alive: true }); + await ctlEffort(d, "ultra", 12); + expect(log.out.join("\n")).toContain("not an effort level"); + expect(log.out.join("\n")).not.toContain("posted"); + }); + it("goes through the running daemon's page, so the board shows it at once", async () => { + const { d, log } = effortDeps({ alive: true }); + await ctlEffort(d, "xhigh", 12); + expect(log.out).toContain("posted #12 xhigh"); + expect(log.gh).toEqual([]); + }); + it("with no daemon swaps the label straight on GitHub, reading the issue's current one", async () => { + const { d, log } = effortDeps({ labels: ["phase:1", "effort:low"] }); + await ctlEffort(d, "max", 12); + expect(log.gh).toEqual(["remove 12 effort:low", "add 12 effort:max"]); + expect(log.out.join("\n")).toContain("#12 runs at max effort"); + }); + it("with no state file at all still works, since the label is the truth", async () => { + const { d, log } = effortDeps({ state: false, labels: ["effort:max"] }); + await ctlEffort(d, "default", 12); + expect(log.gh).toEqual(["remove 12 effort:max"]); + }); + it("--issue with no level prints the issue's effort", async () => { + const { d, log } = effortDeps({ labels: ["effort:medium"] }); + await ctlEffort(d, null, 12); + expect(log.out[0]).toContain("#12 runs at medium effort"); + }); +}); diff --git a/src/ctl.ts b/src/ctl.ts index 2ed14cc..51fffae 100644 --- a/src/ctl.ts +++ b/src/ctl.ts @@ -5,6 +5,11 @@ import { CAP_CHOICES, CapSchema, type CurrentSession, + EFFORT_DEFAULT_LEVEL, + EFFORT_LABEL_PREFIX, + EFFORT_LEVELS, + type EffortChoice, + EffortChoiceSchema, MODEL_CHOICES, MODEL_DEFAULT, ModelSchema, @@ -12,7 +17,8 @@ import { type StopMode, writeState, } from "./state-file.ts"; -import { phaseTasks } from "./task-model.ts"; +import { applyTaskEffort } from "./task-effort.ts"; +import { phaseTasks, type TaskModelGh } from "./task-model.ts"; export interface CtlDeps { stateDir: string; @@ -178,6 +184,71 @@ export async function ctlModel(d: CtlDeps, model: string | null): Promise ); } +/** What `ctl effort` needs: a way to the running daemon, and to GitHub when there is none. */ +export interface EffortCtlDeps extends Pick { + /** POSTs /api/task-effort to a running daemon, which also updates the board it serves. */ + postTaskEffort: (issue: number, effort: EffortChoice) => Promise; + /** The issue's labels as GitHub has them now. */ + issueLabels: (issue: number) => Promise; + labels: TaskModelGh; +} + +/** + * Reads or sets one task's effort. Every session runs at `EFFORT_DEFAULT_LEVEL`; there is no + * global override, so a level is only ever set with `--issue`. The label is the truth, so with no + * daemon running the swap goes straight to GitHub; with one, through its page, so the board it + * serves shows the change before the next tick. + */ +export async function ctlEffort( + d: EffortCtlDeps, + effort: string | null, + issue: number | null, +): Promise { + const levels = EFFORT_LEVELS.join(", "); + if (issue === null) { + if (effort !== null) { + d.out( + `effort is chosen per task: foreman effort ${effort} --issue . Every other session runs at ${EFFORT_DEFAULT_LEVEL}.`, + ); + return; + } + d.out(`effort ${EFFORT_DEFAULT_LEVEL} for every session, unless the task says otherwise`); + d.out(`levels: ${levels} (foreman effort |default --issue )`); + const set = phaseTasks(readState(d.stateDir)?.board ?? null).filter((t) => t.effort); + if (set.length) { + d.out("set by label (from the page, foreman effort --issue, or effort:):"); + for (const t of set) d.out(` #${t.issue} ${t.effort} ${t.title}`); + } + return; + } + if (!Number.isInteger(issue) || issue < 1) { + d.out(`--issue must be an issue number, got ${issue}`); + return; + } + // The level reaches `claude` as argv, so only Claude Code's own names get past here. + const parsed = effort === null ? null : EffortChoiceSchema.safeParse(effort); + if (parsed && !parsed.success) { + d.out(`${effort} is not an effort level; expected one of ${levels}, or default`); + return; + } + if (parsed === null) { + const label = (await d.issueLabels(issue)).find((l) => l.startsWith(EFFORT_LABEL_PREFIX)); + const level = EFFORT_LEVELS.find((e) => label === EFFORT_LABEL_PREFIX + e); + d.out( + `#${issue} runs at ${level ?? EFFORT_DEFAULT_LEVEL} effort${level ? " (effort label)" : " (the default)"}`, + ); + return; + } + const s = readState(d.stateDir); + if (s && d.pidAlive(s.pid)) { + d.out(await d.postTaskEffort(issue, parsed.data)); + return; + } + const label = (await d.issueLabels(issue)).find((l) => l.startsWith(EFFORT_LABEL_PREFIX)); + const current = label ? label.slice(EFFORT_LABEL_PREFIX.length) : null; + d.out(await applyTaskEffort(d.labels, { issue, current }, parsed.data)); +} + /** * Reads or sets the daily session cap. Same ownership rule as `ctlModel`: a live daemon owns * state.json, so a set goes through `/api/cap` while it is running and straight to the file only diff --git a/src/dispatch.test.ts b/src/dispatch.test.ts index d92b82b..c5d40b3 100644 --- a/src/dispatch.test.ts +++ b/src/dispatch.test.ts @@ -93,6 +93,10 @@ describe("buildArgs / buildPrompt", () => { expect(a.join(" ")).toContain("--model opus"); expect(buildArgs(req, { ...cfg, model: "sonnet" }).join(" ")).toContain("--model sonnet"); }); + it("always pins --effort: high unless the request names a task's own level", () => { + expect(buildArgs(req, cfg).join(" ")).toContain("--effort high"); + expect(buildArgs({ ...req, effort: "xhigh" }, cfg).join(" ")).toContain("--effort xhigh"); + }); 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"); diff --git a/src/dispatch.ts b/src/dispatch.ts index daf316c..a7f35cf 100644 --- a/src/dispatch.ts +++ b/src/dispatch.ts @@ -9,6 +9,7 @@ import type { FeedEntry } from "./feed.ts"; import { log } from "./log.ts"; import { FORBIDDEN_ENV } from "./preflight.ts"; import { defaultRepoConfig } from "./repo-config.ts"; +import { EFFORT_DEFAULT_LEVEL, type EffortLevel } from "./state-file.ts"; import { type Activity, emptyActivity, foldEvent } from "./stream.ts"; import type { Role } from "./types.ts"; @@ -60,6 +61,8 @@ export interface DispatchRequest { settingsPath: string; /** A builder round that only merges origin/main into the PR branch. */ rebase: boolean; + /** The issue's `effort:` label; absent means `EFFORT_DEFAULT_LEVEL`. */ + effort?: EffortLevel; } /** @@ -173,6 +176,10 @@ export function buildArgs(req: DispatchRequest, cfg: ForemanConfig): string[] { // default on this Mac. `runRole` passes the live override through here when one is set. "--model", cfg.model, + // Pinned for the same reason as `--model`: the interactive CLI's effort setting on this Mac + // must not leak in. A task's `effort:` label is the only way off the default. + "--effort", + req.effort ?? EFFORT_DEFAULT_LEVEL, ]; } diff --git a/src/github.test.ts b/src/github.test.ts index 4dc24a0..767f332 100644 --- a/src/github.test.ts +++ b/src/github.test.ts @@ -3,6 +3,7 @@ import { join } from "node:path"; import { describe, expect, it } from "vitest"; import type { Exec, ExecResult } from "./exec.ts"; import { + effortOf, GitHub, modelOf, parseChecks, @@ -174,6 +175,14 @@ describe("pure parsers", () => { expect(sizeOf(["size:M"])).toBe("M"); expect(sizeOf([])).toBeNull(); }); + it("effortOf reads an effort: label and ignores anything that is not a Claude Code level", () => { + expect(effortOf(["phase:1", "effort:high"])).toBe("high"); + expect(effortOf(["effort:max"])).toBe("max"); + expect(effortOf(["effort:ultra"])).toBeNull(); + expect(effortOf(["effort:--dangerously-skip-permissions"])).toBeNull(); + expect(effortOf(["effort:"])).toBeNull(); + expect(effortOf(["size:M"])).toBeNull(); + }); it("modelOf reads a model: label and ignores anything that is not a plain model name", () => { expect(modelOf(["phase:1", "model:sonnet"])).toBe("sonnet"); expect(modelOf(["model:claude-haiku-4-5-20251001"])).toBe("claude-haiku-4-5-20251001"); diff --git a/src/github.ts b/src/github.ts index 5564bf1..ab98c1f 100644 --- a/src/github.ts +++ b/src/github.ts @@ -1,6 +1,12 @@ import type { Exec } from "./exec.ts"; import { log } from "./log.ts"; -import { MODEL_LABEL_PREFIX, ModelSchema } from "./state-file.ts"; +import { + EFFORT_LABEL_PREFIX, + type EffortLevel, + EffortSchema, + MODEL_LABEL_PREFIX, + ModelSchema, +} from "./state-file.ts"; import type { CheckState, Comment, Issue, Mergeable, PullRequest, Size, Status } from "./types.ts"; export interface GhConfig { @@ -101,6 +107,16 @@ export function modelOf(labels: string[]): string | null { return name && ModelSchema.safeParse(name).success ? name : null; } +/** + * The effort an `effort:` label pins the issue's sessions to. Only Claude Code's own levels + * count; anything else on the label (a typo, a flag) reads as no label, since it reaches argv. + */ +export function effortOf(labels: string[]): EffortLevel | null { + const l = labels.find((x) => x.startsWith(EFFORT_LABEL_PREFIX)); + const r = EffortSchema.safeParse(l?.slice(EFFORT_LABEL_PREFIX.length)); + return r.success ? r.data : null; +} + function names(labels: Array<{ name: string }> | undefined): string[] { return (labels ?? []).map((l) => l.name); } diff --git a/src/loop.test.ts b/src/loop.test.ts index 82cad85..aed8f61 100644 --- a/src/loop.test.ts +++ b/src/loop.test.ts @@ -12,6 +12,7 @@ import { fmt } from "./ledger.ts"; import { ACTIVITY_FLUSH_MS, type Ctx, + effortFor, ensureLogin, execute, idleSeconds, @@ -997,6 +998,38 @@ describe("model", () => { }); }); +describe("effort", () => { + async function dispatchWith(labels: string[]): Promise { + const seen: string[][] = []; + const ok = okSpawn({ outcome: "pr_opened", pr: 5, notes: "" }); + const st = memState(); + const { gh } = fakeGh([issue({ number: 1, status: "In Progress", labels })]); + await execute( + { type: "claim", issue: 1, role: "builder", pr: null, round: 1 }, + ctx({ + gh, + state: st.store, + stateDir: mkdtempSync(join(tmpdir(), "tt-loop-effort-")), + spawn: async (...a) => { + seen.push(a[1]); + return ok(...a); + }, + }), + ); + return (seen[0] ?? []).join(" "); + } + + it("runs at high effort when the issue has no effort label", async () => { + expect(await dispatchWith([])).toContain("--effort high"); + }); + + it("runs at the issue's effort: label instead", async () => { + const argv = await dispatchWith(["phase:1", "effort:max"]); + expect(argv).toContain("--effort max"); + expect(argv).not.toContain("--effort high"); + }); +}); + describe("daily cap override", () => { // preflight only needs exec to succeed; this stdout keeps `claude auth status` happy. const okExec: Exec = async () => ({ @@ -1411,7 +1444,16 @@ describe("live activity", () => { sessions: 1, medianMergeMinutes: null, mergedTasks: 0, - tasks: [{ issue: 1, title: "Task 1", status: "In Review", closed: false, model: null }], + tasks: [ + { + issue: 1, + title: "Task 1", + status: "In Review", + closed: false, + model: null, + effort: null, + }, + ], }, ]); }); @@ -1778,6 +1820,15 @@ describe("notifications", () => { }); }); +describe("effortFor", () => { + it("is the issue's effort label, else high", () => { + expect(effortFor([])).toBe("high"); + expect(effortFor(["phase:1", "effort:low"])).toBe("low"); + // A label that is not a Claude Code level is no label. + expect(effortFor(["effort:ultra"])).toBe("high"); + }); +}); + describe("modelFor", () => { it("prefers the issue's model label to the live override, and the override to foreman.json", () => { const st = memState(); diff --git a/src/loop.ts b/src/loop.ts index 2e6ef40..48d268c 100644 --- a/src/loop.ts +++ b/src/loop.ts @@ -21,7 +21,7 @@ import { import type { Exec } from "./exec.ts"; import { realExec } from "./exec.ts"; import { appendFeed, type FeedEntry } from "./feed.ts"; -import { type GitHubApi, modelOf, phaseOf } from "./github.ts"; +import { effortOf, type GitHubApi, modelOf, phaseOf } from "./github.ts"; import type { HookName, HookResult } from "./hooks.ts"; import { fileHookLog, parseSessionEnv, runHook } from "./hooks.ts"; import { fmt, openClaim, parseClaim } from "./ledger.ts"; @@ -48,7 +48,13 @@ import { todayStats, } from "./sessions.ts"; import { plan } from "./state.ts"; -import type { CurrentSession, StateStore, StopMode } from "./state-file.ts"; +import { + type CurrentSession, + EFFORT_DEFAULT_LEVEL, + type EffortLevel, + type StateStore, + type StopMode, +} from "./state-file.ts"; import type { Activity } from "./stream.ts"; import type { Action, Epic, Issue, Role, Snapshot } from "./types.ts"; @@ -223,6 +229,15 @@ export function modelFor(ctx: Ctx, labels: string[]): string { return modelOf(labels) ?? ctx.state?.get().model ?? ctx.cfg.model; } +/** + * The effort a session on this issue runs at: the issue's `effort:` label, else the fixed + * default. Unlike the model there is no live override or foreman.json knob — any level other + * than the default is chosen for one task. + */ +export function effortFor(labels: string[]): EffortLevel { + return effortOf(labels) ?? EFFORT_DEFAULT_LEVEL; +} + /** The daily session cap this tick enforces: the owner's live override, else foreman.json's. */ export function capFor(ctx: Ctx): number { return ctx.state?.get().maxSessionsPerDay ?? ctx.cfg.maxSessionsPerDay; @@ -416,6 +431,8 @@ async function runRole(ctx: Ctx, r: RunRole): Promise { promptPath: files.promptPath, settingsPath: files.settingsPath, rebase: r.rebase === true, + // Read per dispatch, so a label set mid-run applies from the issue's next session. + effort: effortFor(r.issue.labels), }; const started = Date.now(); let current: CurrentSession = { diff --git a/src/needs-you.test.ts b/src/needs-you.test.ts index a2d4002..9373015 100644 --- a/src/needs-you.test.ts +++ b/src/needs-you.test.ts @@ -135,6 +135,7 @@ describe("applyUnblockToBoard", () => { fixRound: 1, blocked: true, model: null, + effort: null, }, ], }); diff --git a/src/phase-progress.test.ts b/src/phase-progress.test.ts index 1ae5ef1..9e2bafa 100644 --- a/src/phase-progress.test.ts +++ b/src/phase-progress.test.ts @@ -53,7 +53,7 @@ describe("phaseProgress", () => { number: 102, title: "Two", status: "In Progress", - labels: ["phase:1", "model:sonnet"], + labels: ["phase:1", "model:sonnet", "effort:high"], }), issue({ number: 101, title: "One", status: "Ready" }), // Names the epic in its body but is not a linked sub-issue: still a task. @@ -62,12 +62,19 @@ describe("phaseProgress", () => { }); const [p] = phaseProgress(s, [], []); expect(p?.tasks).toEqual([ - { issue: 101, title: "One", status: "Ready", closed: false, model: null }, - { issue: 102, title: "Two", status: "In Progress", closed: false, model: "sonnet" }, + { issue: 101, title: "One", status: "Ready", closed: false, model: null, effort: null }, + { + issue: 102, + title: "Two", + status: "In Progress", + closed: false, + model: "sonnet", + effort: "high", + }, // Closed tasks are gone from the snapshot, so only their number is known. - { issue: 103, title: "#103", status: null, closed: true, model: null }, - { issue: 104, title: "#104", status: null, closed: true, model: null }, - { issue: 105, title: "Five", status: "Backlog", closed: false, model: null }, + { issue: 103, title: "#103", status: null, closed: true, model: null, effort: null }, + { issue: 104, title: "#104", status: null, closed: true, model: null, effort: null }, + { issue: 105, title: "Five", status: "Backlog", closed: false, model: null, effort: null }, ]); expect(p).toMatchObject({ tasksTotal: 5, tasksDone: 2 }); }); diff --git a/src/phase-progress.ts b/src/phase-progress.ts index db55190..92eb168 100644 --- a/src/phase-progress.ts +++ b/src/phase-progress.ts @@ -1,4 +1,4 @@ -import { modelOf, parentEpicOf } from "./github.ts"; +import { effortOf, modelOf, parentEpicOf } from "./github.ts"; import { parseClaim } from "./ledger.ts"; import type { MergeRecord, SessionLogEntry } from "./sessions.ts"; import type { PhaseProgress, PhaseTask } from "./state-file.ts"; @@ -90,6 +90,13 @@ export function phaseProgress( function taskRow(n: number, issues: Issue[]): PhaseTask { const i = issues.find((x) => x.number === n); return i - ? { issue: n, title: i.title, status: i.status, closed: false, model: modelOf(i.labels) } - : { issue: n, title: `#${n}`, status: null, closed: true, model: null }; + ? { + issue: n, + title: i.title, + status: i.status, + closed: false, + model: modelOf(i.labels), + effort: effortOf(i.labels), + } + : { issue: n, title: `#${n}`, status: null, closed: true, model: null, effort: null }; } diff --git a/src/pipeline.ts b/src/pipeline.ts index 4064c1c..6ed3a68 100644 --- a/src/pipeline.ts +++ b/src/pipeline.ts @@ -1,4 +1,4 @@ -import { modelOf } from "./github.ts"; +import { effortOf, modelOf } from "./github.ts"; import { fixRound, openClaim } from "./ledger.ts"; import { mergeDecision } from "./merge.ts"; import { issuePhase, validatorRequired } from "./pick.ts"; @@ -56,6 +56,7 @@ export function describePipeline( fixRound: fixRound(i.comments), blocked: i.labels.includes("blocked"), model: modelOf(i.labels), + effort: effortOf(i.labels), }); } return rows.sort( diff --git a/src/state-file.test.ts b/src/state-file.test.ts index d22a48b..df7b92a 100644 --- a/src/state-file.test.ts +++ b/src/state-file.test.ts @@ -40,6 +40,55 @@ describe("state file", () => { writeFileSync(statePath(dir), JSON.stringify(old)); expect(readState(dir)?.model).toBeNull(); }); + it("a stored board whose rows predate per-task effort parses with effort null", () => { + const dir = mkdtempSync(join(tmpdir(), "tt-state-")); + const stages = { + build: "done", + review: "pending", + validate: "pending", + ci: "none", + merge: "pending", + }; + const board = { + at: "2026-09-03T12:00:00.000Z", + waiting: [], + pipeline: [ + { + issue: 1, + title: "t", + phase: 1, + status: null, + pr: null, + stages, + claim: null, + fixRound: 1, + blocked: false, + model: null, + }, + ], + phases: [ + { + epic: 9, + phase: 1, + title: "p", + tasksDone: 0, + tasksTotal: 1, + spendUsd: 0, + sessions: 0, + medianMergeMinutes: null, + mergedTasks: 0, + tasks: [{ issue: 1, title: "t", status: null, closed: false, model: null }], + }, + ], + explain: [], + prs: [], + }; + writeFileSync(statePath(dir), JSON.stringify({ ...base(), board })); + const s = readState(dir); + expect(s).not.toBeNull(); + expect(s?.board?.pipeline[0]?.effort).toBeNull(); + expect(s?.board?.phases[0]?.tasks[0]?.effort).toBeNull(); + }); it("StateStore.patch merges and persists", () => { const dir = mkdtempSync(join(tmpdir(), "tt-state-")); const store = new StateStore(dir, base()); diff --git a/src/state-file.ts b/src/state-file.ts index d923a04..94d58db 100644 --- a/src/state-file.ts +++ b/src/state-file.ts @@ -53,6 +53,8 @@ export const PipelineRowSchema = z.object({ blocked: z.boolean(), /** The issue's `model:` label, so the row can say which model its sessions run as. */ model: z.string().nullable().default(null), + /** The issue's `effort:` label; older state.json rows predate it, hence the default. */ + effort: z.string().nullable().default(null), }); export type PipelineRow = z.infer; @@ -95,6 +97,29 @@ export const MODEL_LABEL_PREFIX = "model:"; export const TaskModelChoiceSchema = z.union([ModelSchema, z.literal(MODEL_DEFAULT)]); export type TaskModelChoice = z.infer; +/** + * Claude Code's own effort levels, in order, exactly as `claude --effort` takes them. Unlike + * models this set is fixed by the CLI, so it is both the page's buttons and the allowlist: an + * effort reaches `claude` as argv, and nothing outside this list ever does. + */ +export const EFFORT_LEVELS = ["low", "medium", "high", "xhigh", "max"] as const; +export const EffortSchema = z.enum(EFFORT_LEVELS); +export type EffortLevel = z.infer; +/** + * The effort every session runs at unless its issue says otherwise. Fixed on purpose: there is + * no global override and no foreman.json knob, so any other level is a choice made for one task. + */ +export const EFFORT_DEFAULT_LEVEL: EffortLevel = "high"; +/** Removes the issue's effort label, putting it back on `EFFORT_DEFAULT_LEVEL`. */ +export const EFFORT_DEFAULT = "default"; +/** + * The label that runs one issue's sessions at another effort. `init` creates one per level + * other than `EFFORT_DEFAULT_LEVEL`, so the page and `gh issue edit` can both add it. + */ +export const EFFORT_LABEL_PREFIX = "effort:"; +export const EffortChoiceSchema = z.union([EffortSchema, z.literal(EFFORT_DEFAULT)]); +export type EffortChoice = z.infer; + /** * Daily session caps the page and `ctl cap` offer as one-click choices. As with * `MODEL_CHOICES`, any value passing `CapSchema` is accepted; these are only the shortcuts. @@ -151,6 +176,8 @@ export const PhaseTaskSchema = z.object({ closed: z.boolean(), /** The issue's `model:` label, or null when the global model applies. */ model: z.string().nullable(), + /** The issue's `effort:` label, or null when the global effort applies. */ + effort: z.string().nullable().default(null), }); export type PhaseTask = z.infer; diff --git a/src/status.test.ts b/src/status.test.ts index 47d68a7..20c0f20 100644 --- a/src/status.test.ts +++ b/src/status.test.ts @@ -87,6 +87,14 @@ describe("describeStatus", () => { expect(formatStatus(overridden)).toContain("model sonnet (override; foreman.json says opus)"); expect(formatStatus(fromConfig)).toContain("model opus"); }); + it("reports the fixed default effort and the levels a task may pick", () => { + const r = describeStatus(input()); + expect(r.effort).toEqual({ + default: "high", + levels: ["low", "medium", "high", "xhigh", "max"], + }); + expect(formatStatus(r)).toContain("effort high (a task's effort: label overrides)"); + }); it("reports nowPhase: session, between_ticks, ticking, idle", () => { const sleeping = { ...state(), diff --git a/src/status.ts b/src/status.ts index 1bdcaf9..ac36c05 100644 --- a/src/status.ts +++ b/src/status.ts @@ -6,6 +6,9 @@ import { type BudgetReport, CAP_CHOICES, type CurrentSession, + EFFORT_DEFAULT_LEVEL, + EFFORT_LEVELS, + type EffortLevel, type ForemanState, type StopMode, } from "./state-file.ts"; @@ -60,6 +63,8 @@ export interface StatusReport { source: "config" | "override"; choices: readonly string[]; }; + /** The effort every session runs at, and the levels a task's `effort:` label may pick. */ + effort: { default: EffortLevel; levels: readonly EffortLevel[] }; tick: { lastAt: string | null; nextAt: string | null; @@ -188,6 +193,7 @@ export function describeStatus(i: StatusInput): StatusReport { source: s?.model ? "override" : "config", choices: i.modelChoices, }, + effort: { default: EFFORT_DEFAULT_LEVEL, levels: EFFORT_LEVELS }, tick: s ? { lastAt: s.lastTickAt, @@ -322,6 +328,7 @@ export function formatStatus(r: StatusReport): string { r.model.source === "override" ? ` (override; foreman.json says ${r.model.configured})` : "" }`, ); + lines.push(`effort ${r.effort.default} (a task's effort: label overrides)`); lines.push(`today ${r.today.count} of ${r.today.cap} sessions, $${r.today.spendUsd.toFixed(2)}`); if (r.budget) lines.push(`budget ${formatBudget(r.budget)}`); return `${lines.join("\n")}\n`; diff --git a/src/task-effort.test.ts b/src/task-effort.test.ts new file mode 100644 index 0000000..ffe1398 --- /dev/null +++ b/src/task-effort.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from "vitest"; +import type { Board, PhaseProgress, PipelineRow } from "./state-file.ts"; +import { applyTaskEffort, applyTaskEffortToBoard } from "./task-effort.ts"; + +function fakeGh() { + const calls: string[] = []; + return { + calls, + gh: { + addLabels: async (kind: string, n: number, labels: string[]) => { + calls.push(`add ${kind} ${n} ${labels.join(",")}`); + }, + removeLabels: async (kind: string, n: number, labels: string[]) => { + calls.push(`remove ${kind} ${n} ${labels.join(",")}`); + }, + }, + }; +} + +const phase = (over: Partial = {}): PhaseProgress => ({ + epic: 10, + phase: 1, + title: "Phase 1", + tasksDone: 0, + tasksTotal: 2, + spendUsd: 0, + sessions: 0, + medianMergeMinutes: null, + mergedTasks: 0, + tasks: [ + { issue: 101, title: "One", status: "Ready", closed: false, model: null, effort: null }, + { issue: 102, title: "Two", status: "In Progress", closed: false, model: null, effort: "low" }, + ], + ...over, +}); + +const row = (over: Partial = {}): PipelineRow => ({ + issue: 102, + title: "Two", + phase: 1, + status: "In Progress", + pr: null, + stages: { build: "active", review: "pending", validate: "pending", ci: "none", merge: "pending" }, + claim: null, + fixRound: 1, + blocked: false, + model: null, + effort: "low", + ...over, +}); + +const board = (over: Partial = {}): Board => ({ + at: "2026-09-05T10:00:00Z", + waiting: [], + pipeline: [row()], + owner: [], + needsYou: [], + phases: [phase()], + explain: [], + prs: [], + ...over, +}); + +describe("applyTaskEffort", () => { + it("removes the label it is replacing before adding the new one", async () => { + const { gh, calls } = fakeGh(); + const msg = await applyTaskEffort(gh, { issue: 102, current: "low" }, "max"); + expect(calls).toEqual(["remove issue 102 effort:low", "add issue 102 effort:max"]); + expect(msg).toContain("#102"); + expect(msg).toContain("max"); + }); + it("only adds when there was no label", async () => { + const { gh, calls } = fakeGh(); + await applyTaskEffort(gh, { issue: 101, current: null }, "low"); + expect(calls).toEqual(["add issue 101 effort:low"]); + }); + it("choosing high removes the label, since no label means high", async () => { + const { gh, calls } = fakeGh(); + const msg = await applyTaskEffort(gh, { issue: 102, current: "max" }, "high"); + expect(calls).toEqual(["remove issue 102 effort:max"]); + expect(msg).toContain("default"); + }); + it("choosing high on an unlabelled task touches GitHub not at all", async () => { + const { gh, calls } = fakeGh(); + await applyTaskEffort(gh, { issue: 101, current: null }, "high"); + expect(calls).toEqual([]); + }); + it("setting the effort it already has touches GitHub not at all", async () => { + const { gh, calls } = fakeGh(); + await applyTaskEffort(gh, { issue: 102, current: "low" }, "low"); + expect(calls).toEqual([]); + }); + it("`default` only removes, and says the default effort is back in charge", async () => { + const { gh, calls } = fakeGh(); + const msg = await applyTaskEffort(gh, { issue: 102, current: "low" }, "default"); + expect(calls).toEqual(["remove issue 102 effort:low"]); + expect(msg).toContain("default high"); + }); + it("`default` with nothing to remove touches GitHub not at all", async () => { + const { gh, calls } = fakeGh(); + await applyTaskEffort(gh, { issue: 101, current: null }, "default"); + expect(calls).toEqual([]); + }); +}); + +describe("applyTaskEffortToBoard", () => { + it("updates the task row and the matching pipeline row so the page reflects the click now", () => { + const b = applyTaskEffortToBoard(board(), 102, "max"); + expect(b.phases[0]?.tasks.find((t) => t.issue === 102)?.effort).toBe("max"); + expect(b.pipeline[0]?.effort).toBe("max"); + expect(b.phases[0]?.tasks.find((t) => t.issue === 101)?.effort).toBeNull(); + }); + it("high clears the label too, since no label means high", () => { + const b = applyTaskEffortToBoard(board(), 102, "high"); + expect(b.phases[0]?.tasks.find((t) => t.issue === 102)?.effort).toBeNull(); + expect(b.pipeline[0]?.effort).toBeNull(); + }); + it("`default` clears the label", () => { + const b = applyTaskEffortToBoard(board(), 102, "default"); + expect(b.phases[0]?.tasks.find((t) => t.issue === 102)?.effort).toBeNull(); + expect(b.pipeline[0]?.effort).toBeNull(); + }); +}); diff --git a/src/task-effort.ts b/src/task-effort.ts new file mode 100644 index 0000000..c48128e --- /dev/null +++ b/src/task-effort.ts @@ -0,0 +1,43 @@ +import { + type Board, + EFFORT_DEFAULT, + EFFORT_DEFAULT_LEVEL, + EFFORT_LABEL_PREFIX, + type EffortChoice, +} from "./state-file.ts"; +import type { TaskModelGh } from "./task-model.ts"; + +/** + * Swaps the issue's `effort:` label, the same way `applyTaskModel` swaps `model:`: + * the old one goes first so the issue never carries two; `default` only removes. `current` is + * the label's level as last seen — the stored board for the page, a fresh read for `ctl`. + */ +export async function applyTaskEffort( + gh: TaskModelGh, + task: { issue: number; current: string | null }, + effort: EffortChoice, +): Promise { + // The default level is what no label means, so choosing it removes the label instead. + const level = effort === EFFORT_DEFAULT ? EFFORT_DEFAULT_LEVEL : effort; + if (level === (task.current ?? EFFORT_DEFAULT_LEVEL)) + return `#${task.issue} already runs at ${level} effort`; + if (task.current) + await gh.removeLabels("issue", task.issue, [EFFORT_LABEL_PREFIX + task.current]); + if (level === EFFORT_DEFAULT_LEVEL) + return `#${task.issue} back on the default ${level} effort${task.current ? ` (was ${task.current})` : ""}`; + await gh.addLabels("issue", task.issue, [EFFORT_LABEL_PREFIX + level]); + return `#${task.issue} runs at ${level} effort from its next session`; +} + +/** Reflects a just-applied label on the stored board, like `applyTaskModelToBoard`. */ +export function applyTaskEffortToBoard(board: Board, issue: number, effort: EffortChoice): Board { + const next = effort === EFFORT_DEFAULT || effort === EFFORT_DEFAULT_LEVEL ? null : effort; + return { + ...board, + phases: board.phases.map((p) => ({ + ...p, + tasks: p.tasks.map((t) => (t.issue === issue ? { ...t, effort: next } : t)), + })), + pipeline: board.pipeline.map((r) => (r.issue === issue ? { ...r, effort: next } : r)), + }; +} diff --git a/src/task-model.test.ts b/src/task-model.test.ts index 6c86271..405a7fe 100644 --- a/src/task-model.test.ts +++ b/src/task-model.test.ts @@ -28,9 +28,16 @@ const phase = (over: Partial = {}): PhaseProgress => ({ medianMergeMinutes: null, mergedTasks: 0, tasks: [ - { issue: 101, title: "One", status: "Ready", closed: false, model: null }, - { issue: 102, title: "Two", status: "In Progress", closed: false, model: "sonnet" }, - { issue: 103, title: "#103", status: null, closed: true, model: null }, + { issue: 101, title: "One", status: "Ready", closed: false, model: null, effort: null }, + { + issue: 102, + title: "Two", + status: "In Progress", + closed: false, + model: "sonnet", + effort: null, + }, + { issue: 103, title: "#103", status: null, closed: true, model: null, effort: null }, ], ...over, }); @@ -46,6 +53,7 @@ const row = (over: Partial = {}): PipelineRow => ({ fixRound: 1, blocked: false, model: "sonnet", + effort: null, ...over, }); diff --git a/src/web.html b/src/web.html index 67a0807..ccdefbf 100644 --- a/src/web.html +++ b/src/web.html @@ -225,7 +225,7 @@

foreman

function renderPipeline(rows) { const cell = (name, st) => `${name === "validate" ? "valid" : name}`; $("pipeline").innerHTML = rows.length - ? rows.map((p) => `${link("issues", p.issue)}${esc(p.title)}
${["build", "review", "validate", "ci", "merge"].map((s) => cell(s, p.stages[s])).join("")}
${p.claim ? `${esc(p.claim.host)} · ${esc(p.claim.role)} r${p.claim.round}` : "—"}${p.pr ? link("pull", p.pr) : "—"}${p.model ? `${esc(p.model)} ` : ""}${p.blocked ? 'blocked' : p.fixRound > 1 ? `fix round ${p.fixRound}` : p.stages.ci === "pending" ? 'ci pending' : p.stages.merge === "active" ? 'mergeable' : ""}`).join("") + ? rows.map((p) => `${link("issues", p.issue)}${esc(p.title)}
${["build", "review", "validate", "ci", "merge"].map((s) => cell(s, p.stages[s])).join("")}
${p.claim ? `${esc(p.claim.host)} · ${esc(p.claim.role)} r${p.claim.round}` : "—"}${p.pr ? link("pull", p.pr) : "—"}${p.model ? `${esc(p.model)} ` : ""}${p.effort ? `${esc(p.effort)} effort ` : ""}${p.blocked ? 'blocked' : p.fixRound > 1 ? `fix round ${p.fixRound}` : p.stages.ci === "pending" ? 'ci pending' : p.stages.merge === "active" ? 'mergeable' : ""}`).join("") : 'nothing in flight'; } @@ -306,6 +306,8 @@

foreman

const openPhases = new Set(); // The one-click model names, from the last status poll (`StatusReport.model.choices`). let modelChoices = []; + // Claude Code's effort levels and the fixed default, from the same poll (`StatusReport.effort`). + let effort = { default: "high", levels: [] }; function renderPhases(items) { $("phases").innerHTML = items.length ? items.map((p) => { @@ -313,10 +315,11 @@

foreman

const median = p.medianMergeMinutes === null ? "no merge yet" : `median ${mins(p.medianMergeMinutes)} claim→merge`; const open = openPhases.has(p.epic); const pinned = (p.tasks ?? []).filter((t) => t.model).length; + const efforts = (p.tasks ?? []).filter((t) => t.effort).length; return `
${link("issues", p.epic)} phase ${esc(p.phase)} ${esc(p.title)}${p.tasksDone}/${p.tasksTotal}
$${Number(p.spendUsd).toFixed(2)} · ${p.sessions} sessions${esc(median)}
-
${open ? "▾" : "▸"} tasks${pinned ? ` · ${pinned} pinned to a model` : ""}
+
${open ? "▾" : "▸"} tasks${pinned ? ` · ${pinned} pinned to a model` : ""}${efforts ? ` · ${efforts} off ${esc(effort.default)} effort` : ""}
${open ? renderTasks(p.tasks ?? []) : ""}
`; }).join("") : '
no approved phase in flight
'; @@ -326,15 +329,19 @@

foreman

el.onkeydown = (ev) => { if (ev.key === "Enter" || ev.key === " ") { ev.preventDefault(); flip(); } }; } for (const b of $("phases").querySelectorAll("button[data-task-model]")) b.onclick = () => setTaskModel(Number(b.dataset.issue), b.dataset.taskModel, b.dataset.current || null); + for (const b of $("phases").querySelectorAll("button[data-task-effort]")) b.onclick = () => setTaskEffort(Number(b.dataset.issue), b.dataset.taskEffort, b.dataset.current || null); } // One row per task of the phase. A `model:` label on the issue pins every foreman session // on it to that model, outranking the Tick card's choice; `default` removes the label. Closed // tasks have left the snapshot, so they show as done and take no button. + // The second row is the task's effort: every session runs at the default unless an + // `effort:` label says otherwise, and choosing the default removes the label. function renderTasks(tasks) { if (!tasks.length) return '
no tasks yet
'; const choices = [...modelChoices, "default"]; return `
${tasks.map((t) => { - const acts = t.closed ? "" : `${choices.map((c) => ``).join("")}`; + const efforts = `${effort.levels.map((c) => ``).join("")}`; + const acts = t.closed ? "" : `${choices.map((c) => ``).join("")}${efforts}`; return `
${link("issues", t.issue)} ${esc(t.title)}${t.closed ? "done" : esc(t.status ?? "off board")}${acts}
`; }).join("")}
`; } @@ -347,6 +354,15 @@

foreman

refresh(); } + async function setTaskEffort(issue, level, current) { + const what = level === effort.default ? `the default ${level} effort${current ? ` (drops ${current})` : ""}` : `${level} effort`; + if (!window.confirm(`Run every foreman session on #${issue} at ${what}?\n\nBuilder, reviewer and validator alike. A session running now keeps the effort it started with; the issue's next session uses this.`)) return; + $("msg").textContent = `#${issue} effort ${level}…`; + const j = await (await fetch("/api/task-effort", { method: "POST", body: JSON.stringify({ issue, effort: level }) })).json(); + $("msg").textContent = j.ok ? j.message : `error: ${j.error}`; + refresh(); + } + function renderRecent(list) { const cls = (o) => (/^(pr_opened|approved|passed|phase_closed|plan_drafted)$/.test(o) ? "ok" : /^(blocked|failed|aborted|error_max_turns|interrupted)$/.test(o) ? "bad" : "warn"); $("recent").innerHTML = list.length @@ -443,6 +459,7 @@

foreman

$("hmeta").textContent = r.daemon === "UNKNOWN" ? "no state.json: restart the daemon" : ""; renderNow(r); modelChoices = r.model?.choices ?? []; + effort = r.effort ?? effort; renderPipeline(r.board?.pipeline ?? []); renderPhases(r.board?.phases ?? []); renderOwner(r.board?.owner ?? []); diff --git a/src/web.test.ts b/src/web.test.ts index 38ef7f1..b73a751 100644 --- a/src/web.test.ts +++ b/src/web.test.ts @@ -5,7 +5,7 @@ import { join } from "node:path"; import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import type { FeedEntry } from "./feed.ts"; import type { NextReport } from "./next.ts"; -import { CAP_CHOICES, MODEL_CHOICES } from "./state-file.ts"; +import { CAP_CHOICES, EFFORT_LEVELS, MODEL_CHOICES } from "./state-file.ts"; import type { StatusReport } from "./status.ts"; import { basicAuthorization, @@ -28,6 +28,7 @@ const report: StatusReport = { nowPhase: "idle", model: { current: "opus", configured: "opus", source: "config", choices: MODEL_CHOICES }, cap: { current: 20, configured: 20, source: "config", choices: CAP_CHOICES }, + effort: { default: "high", levels: EFFORT_LEVELS }, tick: null, current: null, orphan: null, @@ -53,6 +54,7 @@ describe("web server", () => { const caps: (number | null)[] = []; const unblocked: number[] = []; const taskModels: string[] = []; + const taskEfforts: string[] = []; let refreshes = 0; const sid = "144ba520-6c0f-4195-ae18-c67de1443b31"; const entries: FeedEntry[] = Array.from({ length: 5 }, (_, i) => ({ @@ -111,14 +113,25 @@ describe("web server", () => { return "project re-read; tick requested"; }, phaseTasks: () => [ - { issue: 101, title: "One", status: "Ready", closed: false, model: null }, - { issue: 102, title: "Two", status: "In Progress", closed: false, model: "sonnet" }, + { issue: 101, title: "One", status: "Ready", closed: false, model: null, effort: null }, + { + issue: 102, + title: "Two", + status: "In Progress", + closed: false, + model: "sonnet", + effort: null, + }, ], setTaskModel: async (issue, model) => { taskModels.push(`${issue} ${model}`); return `#${issue} runs as ${model}`; }, modelChoices: () => [...MODEL_CHOICES], + setTaskEffort: async (issue, effort) => { + taskEfforts.push(`${issue} ${effort}`); + return `#${issue} runs at ${effort} effort`; + }, html: "Foreman", }); let base = ""; @@ -261,6 +274,22 @@ describe("web server", () => { expect((await fetch(`${base}/api/task-model`)).status).toBe(405); expect(taskModels).toEqual(["101 sonnet", "102 default"]); }); + it("sets a task's effort when the board lists the task, and only Claude Code's levels", async () => { + const post = (body: unknown) => + fetch(`${base}/api/task-effort`, { method: "POST", body: JSON.stringify(body) }); + const ok = await post({ issue: 101, effort: "xhigh" }); + expect(ok.status).toBe(200); + expect(await ok.json()).toEqual({ ok: true, message: "#101 runs at xhigh effort" }); + expect((await post({ issue: 102, effort: "default" })).status).toBe(200); + // The effort reaches `claude` as argv: anything but a level is refused. + expect((await post({ issue: 101, effort: "ultra" })).status).toBe(400); + expect((await post({ issue: 101, effort: "--dangerously-skip-permissions" })).status).toBe(400); + // Not a task of any phase on the board: refused before it reaches GitHub. + expect((await post({ issue: 999, effort: "low" })).status).toBe(400); + expect((await post({ effort: "low" })).status).toBe(400); + expect((await fetch(`${base}/api/task-effort`)).status).toBe(405); + expect(taskEfforts).toEqual(["101 xhigh", "102 default"]); + }); it("unblocks an issue the board lists as blocked", async () => { const r = await fetch(`${base}/api/unblock`, { method: "POST", @@ -310,6 +339,7 @@ describe("web server with webAuth", () => { phaseTasks: () => [], setTaskModel: async () => "", modelChoices: () => [], + setTaskEffort: async () => "", html: "Foreman", }); let base = ""; @@ -454,6 +484,7 @@ describe("web server with webHosts", () => { phaseTasks: () => [], setTaskModel: async () => "", modelChoices: () => [], + setTaskEffort: async () => "", html: "Foreman", }; @@ -560,6 +591,13 @@ describe("web.html", () => { // Pipeline rows show the override when one is set. expect(html).toContain("p.model ?"); }); + it("lists each phase's tasks with effort buttons and confirms before POSTing one", () => { + const html = readFileSync(join(import.meta.dirname, "web.html"), "utf8"); + expect(html).toContain('data-task-effort="'); + expect(html).toContain("A session running now keeps the effort it started with"); + expect(html).toContain('fetch("/api/task-effort"'); + expect(html).toContain("p.effort ?"); + }); it("renders the Now card from nowPhase, not from the absence of a session", () => { const html = readFileSync(join(import.meta.dirname, "web.html"), "utf8"); expect(html).toContain('r.nowPhase === "between_ticks"'); diff --git a/src/web.ts b/src/web.ts index e6a5a16..3e6318f 100644 --- a/src/web.ts +++ b/src/web.ts @@ -10,6 +10,8 @@ import { log } from "./log.ts"; import type { NextReport } from "./next.ts"; import { CapSchema, + type EffortChoice, + EffortChoiceSchema, MODEL_DEFAULT, ModelSchema, type NeedsYouItem, @@ -60,6 +62,8 @@ export interface WebDeps { setTaskModel: (issue: number, model: TaskModelChoice) => Promise; /** The repo's configured `models`; the task-model allowlist beyond `MODEL_DEFAULT`. */ modelChoices: () => string[]; + /** Swaps the issue's `effort:` label; only called for a task `phaseTasks` lists. */ + setTaskEffort: (issue: number, effort: EffortChoice) => Promise; /** Page HTML; defaults to src/web.html. Injectable for tests. */ html?: string; } @@ -119,6 +123,12 @@ const TaskModelRequestSchema = z.object({ model: TaskModelChoiceSchema, }); +// Claude Code's levels are the whole allowlist, so no per-repo list is needed here. +const TaskEffortRequestSchema = z.object({ + issue: z.number().int().positive(), + effort: EffortChoiceSchema, +}); + // `null` is the clear-the-override case; everything else must be a sane session count, so a // fat-fingered 100000 cannot quietly uncap the Mac's daily spend. const CapRequestSchema = z.object({ maxSessionsPerDay: CapSchema.nullable() }); @@ -225,6 +235,16 @@ export function createWebServer(d: WebDeps): http.Server { return json(res, 400, { ok: false, error: "model not offered" }); return json(res, 200, { ok: true, message: await d.setTaskModel(issue, model) }); } + if (url === "/api/task-effort") { + if (req.method !== "POST") return json(res, 405, { ok: false, error: "POST only" }); + const parsed = TaskEffortRequestSchema.safeParse(await readJson(req)); + // An effort reaches `claude` as argv, so only Claude Code's own levels get past here. + if (!parsed.success) return json(res, 400, { ok: false, error: "bad request" }); + const { issue, effort } = parsed.data; + if (!d.phaseTasks().some((t) => t.issue === issue)) + return json(res, 400, { ok: false, error: `#${issue} is not a task on the board` }); + return json(res, 200, { ok: true, message: await d.setTaskEffort(issue, effort) }); + } if (url === "/api/cap") { if (req.method !== "POST") return json(res, 405, { ok: false, error: "POST only" }); const parsed = CapRequestSchema.safeParse(await readJson(req));