diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..5dd2df5 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,449 @@ +# Task Workflow + +Tasks move through a kanban of directories. The directory a file sits in **is** +its status — there is no other source of truth. + +``` +.task-manager/ +├── AGENTS.md ← This file (core-owned; replaced by updates) +├── CLAUDE.md ← Compatibility pointer to AGENTS.md — nothing else +├── install.py ← Wires the project via the agent adapter (see below) +├── start.sh ← One-command start: install + port handling + board +├── stop.sh ← Safe stop: refuses while agents run (--force overrides) +├── update.sh ← Replace core/ from the published release; local/ survives +├── tasks/ ← Task state, nothing else. Works as a plain folder +│ ├── backlog/…done/ ← kanban even if manager/ is deleted or ignored. +│ └── archive/ ← Archived cards: out of the flow, never deleted +├── plans/ ← Claude Code plan files (via plansDirectory setting) +├── reference/ ← Supporting documents referenced by tasks +└── manager/ + ├── core/ ← The tool. Replaced WHOLESALE by update.sh — never + │ │ put anything project-specific here. + │ ├── VERSION, board.py, config.py … httpd.py, board.html + │ ├── prompts/ ← Default agent prompt templates + │ ├── adapters/ ← Agent-vendor integrations (claude/ and + │ │ opencode/ ship; contract in README) + │ └── driver.example/ + └── local/ ← This project's half. Updates never touch it. + ├── .env ← Settings (gitignored; defaults in core/.env.example) + ├── AGENTS.md ← Project-specific workflow notes — read it too + ├── CLAUDE.md ← Compatibility pointer, as at the root + ├── driver/start ← How THIS project's app launches from a worktree + ├── commands/ ← Project chores run against a task's worktree + ├── prompts/ ← Prompt overrides (same filename beats the default) + ├── adapters/ ← Adapter overrides/additions + └── state/ ← Runtime data: sessions, agent logs, drives (gitignored) +``` + +Three layers, one law: **core knows about tasks, worktrees, PRs and events — +it knows nothing about any particular app, agent vendor, or project.** +Drivers know apps, adapters know vendors, `local/` knows this project. +`tasks/` holds only state and works as a plain folder kanban even if +`manager/` is deleted; the board narrates hand-moves when it happens to run. + +Module map for `manager/core/` (dependencies flow strictly left to right): + +``` +config → state → taskfiles → events / github / drive → agents → watch / httpd → board.py +``` + +- `config.py` — paths, stages, settings, prompt/adapter/driver resolution +- `state.py` — shared registries, event log persistence, SSE fan-out +- `taskfiles.py` — reading and moving task files; the only code touching tasks/ +- `events.py` — ingests NORMALIZED events (the adapter contract), session registry +- `github.py` — PR opening, Copilot requests, review/CI polling +- `drive.py` — runs the project driver, tracks the one live drive +- `agents.py` — headless work/review jobs, launched through the adapter +- `watch.py` — 2s disk poller narrating moves made outside the API +- `httpd.py` — HTTP routes, the SSE stream, serving the page +- `board.py` — argparse + startup wiring only + +## Agent adapters + +Headless jobs run through an adapter (`BOARD_AGENT_ADAPTER`, default +`claude`), so the manager works with other coding agents too. An adapter is +a directory with `run` (execute one job: `AGENT_PROMPT` + `AGENT_MODE` +work|act-pr|review + `AGENT_COMMANDS` in, stdout = the log, markers parsed +from it) and `wire` (idempotently give the host project live-session +visibility). Headless jobs answer no permission prompts, so each intent is +granted exactly the side effects its prompt demands — commit and test for +work, push for act-pr, posting PR verdicts for review — with the project's +own runnable commands coming from `BOARD_AGENT_COMMANDS` as neutral +prefixes each adapter renders in its vendor's rule syntax. Adapters +translate their vendor's events into the board's normalized schema at the +edge — core never sees vendor payloads. The full contract, including the +event schema, lives in `core/adapters/README.md`. + +## Drives + +The **⛭ drive** chip on a review card launches the app locally *from that +task's worktree*, so you can click around the actual feature before +merging. How an app starts is project knowledge, so it lives in the +project's driver — `local/driver/start`, an executable the board runs and +owns: refuse fast with a printed reason, print `DRIVE URL: ` when up, +run until parked (SIGTERM). No driver → the chip says so and the tooltip +explains what to create; `core/driver.example/` documents the contract. +One drive at a time; **park** takes it down. + +## The activity bar and the archive + +The bottom bar is one place: what happened, and where things go. **Activity** +expands the full event log (filters, the plans/ and reference/ listings, a +resize grip); collapsed, the latest event ticks along the bar. The +**Archive** tray anchors the right end — drag a card from `backlog/`, +`to-do/` or `done/` anywhere onto the bar and it moves to `tasks/archive/`: +out of every column, never deleted, Status set to `Archived`. The toast says +⌘Z brings it back, and it does — nothing in this system removes work +without an undo in the same breath. Cards in the working stages +(in-progress, review) cannot be archived; finish or walk them back first. + +## Local commands + +Projects grow chores that belong to a specific checkout — applying a +branch's DB migrations, reseeding, rebuilding assets. Those are +**local commands**: executables in `manager/local/commands/`, surfaced as +`$`-glyph chips on cards that have a branch (in progress and review) and +run against that task's worktree (recreated from the branch if needed). +The contract mirrors the driver's: env in (`CMD_WORKTREE`, `CMD_BRANCH`, +`CMD_TASK`, `CMD_REPO`), output to a log under `local/state/commands/`, +and the ticker narrates the ending either way with the log's last line. +A `# help:` line near the top of the script becomes the chip's tooltip. +Commands arm on first click and run on the second. + +## Updating + +`./.task-manager/update.sh` downloads the latest GitHub Release of the +distribution repo (`BENCH_REF=v3` pins a tag; the source is stamped into +the script at build time, `BENCH_SOURCE` in `local/.env` overrides it), +replaces `manager/core/` wholesale plus the top-level files named in the +artifact's `manager/core/release-manifest`, and touches nothing else — +tasks, driver, prompt overrides, `.env` and state all survive. No release +published → it says so and changes nothing; developers working on bench +itself update their clone with git instead. Then re-run `install.py` +(idempotent re-wire) and restart the board. Releases are cut from the +bench repo with `release.sh` (never shipped in the artifact): it builds +the tarball from the manifest, tags `v` and publishes via `gh`. + +## Installing into a project + +```bash +python3 .task-manager/install.py # idempotent; --dry-run to preview +``` + +Checks that the containing directory is a `.claude`-initialised project and +delegates to the configured agent adapter's `wire` — for Claude that means +`.claude/settings.json`: `plansDirectory` and the five event hooks running +the adapter's `emit.py`. Fully present → reports "ok" and touches nothing; +partial, stale (old `.tasks/` paths) or duplicated → repaired in place. Other +hooks and settings are never touched, so it is safe to run any time — e.g. +after dropping `.task-manager/` into a new repo. + +## Seeing the board + +```bash +./.task-manager/start.sh # the usual way: install + port + board +python3 .task-manager/manager/board.py # or run the server directly +``` + +`start.sh` runs `install.py` (idempotent), then sorts out the port before +serving in the foreground (Ctrl-C stops it). Three cases: + +- this project's board already answers on the port → just reopens the browser +- the port is free → starts on it +- something else occupies it → takes the next free port **and persists it to + `manager/.env`**, so the hooks and agents — which read the same file — + follow the board rather than reporting to a port it no longer serves. + +Extra arguments pass through to `board.py` (e.g. `./start.sh --no-open`). + +`stop.sh` is the counterpart. It identifies the board by asking the port's +API for its tasks root, so it never kills a foreign process squatting there. +While agents are running it refuses — stopping the board loses their endings +(auto-move, PR opening, decline handling) even though the agent processes +themselves survive — and names who is working on what; `--force` overrides. + +Port **26071 is pinned** so the URL is always the same one to bookmark. Running +the command again while it is already up just reopens that tab rather than +failing on a port clash. + +All settings live in `manager/core/.env.example` with their defaults documented — +the port, the binaries agents launch with, the commands agents may run, +the worktrees directory, the watch interval and the in-memory caps. Copy it +to `manager/local/.env` (gitignored) to override locally; real environment +variables beat `.env`, which beats the defaults. The hook bridge reads the +same `.env`, so changing `BOARD_PORT` moves the board, the agents and the +hooks together. + +Stdlib only, no install. It reads the directories on every request, so refreshing +the page shows current disk state. Dragging a card between columns does both +steps of a move for you — it renames the file and rewrites its **Status:** line. +Cards whose Status line disagrees with the directory they sit in are flagged +`status drift`. + +## Live view + +The UI follows the **Bench** design system — cool sea neutrals, IBM Plex Sans +for anything a person wrote and Plex Mono for anything a machine produced, +and colour that only ever means state: `--accent` (surf) an agent alive, +`--calm` (pine) settled or passed, `--alarm` (terracotta) blocked, failed or +HIGH, `--idle` (driftwood) done. The one looping animation ("breathe") means +an agent is working; a blinking caret means output is still arriving. Night +theme by default; the header button switches to Daylight. Tokens live at the +top of `manager/board.html`. + +The board has three views (header switcher): + +- **Board** — the kanban, live. Active Claude Code sessions appear as chips in + the header; a card an agent is working on carries a live activity line; the + bottom ticker narrates the latest events and every move is attributed + (`you` / `agent` / `disk`). +- **Sessions** — a flight recorder per session: a chronological timeline of + reads, edits, test runs, commits and card moves, with filters and expandable + output. Sessions persist to `.sessions/*.jsonl`, so past ones can be replayed. +- **Focus** — a heads-up display for one session: the task it holds, its live + TodoWrite plan, the project's configured definition-of-done checks (a + `checks` file in `manager/local/` overriding the shipped default in + `core/`), and per-file diff stats from its worktree. + +Liveness comes from Claude Code hooks configured in `.claude/settings.json`: +every session in this repo POSTs normalized events to the board via the +claude adapter's `emit.py` (fails silently in under a second when the board isn't +running). A watcher thread also polls the stage directories every 2s, so moves +made by hand still show up. The browser gets everything over SSE — no refresh +needed. Hooks are snapshotted at session start, so a session already open when +the hooks were added won't report until restarted. + +Agent prompts ship in `manager/core/prompts/` and can be overridden per +project by placing a file of the same name in `manager/local/prompts/` +(the override wins). They are plain +markdown with `{branch}` / `{stage}` / `{filename}` / `{body}` placeholders, +filled via `str.format` — so literal braces elsewhere in a prompt would break +it. They are read fresh on every agent launch; edits apply without restarting +the board. + +## Agents working the board + +Card actions appear on hover, taking over the status pill's slot (never +stacking on top of it) — at most two per state, only things you'd actually +do without opening the card: **▸ start work** on in-progress cards, +**‖ hold** while an agent runs, **↩ back** on cards waiting on you, +**↺ reopen** on done cards, and **◔ still true?** everywhere. Actions that +cost tokens or stop work arm on first click and fire on the second. + +Each launched agent wears a short name for its lifetime (Wren, Juno, +Basil, …) — picked per launch, never shared by two running agents, shown as +`Wren · #09` on cards, in the sessions list and throughout the ticker. Names +are held in memory, so a restarted board falls back to plain "Agent" for +sessions that predate it. + +**▸ start work** launches a headless `claude -p` on the task. It exists only +on `in-progress/` cards: moving a card to in-progress is the commitment, and +only then does work start — the server refuses launches from anywhere else. + +1. The board creates a git worktree at `.worktrees//` on a new + branch `task/` from the newest main it can see: with an + `origin` remote it fetches `origin/main` first (bounded by + `BOARD_FETCH_TIMEOUT`) and branches from that; no remote, a failed + fetch or a timeout fall back to current HEAD, so launching never + waits on the network. The main checkout itself is never touched, and + the ticker names the branch point whenever it isn't just HEAD. (The + agent is told not to touch the task file — worktree moves would be + invisible to the main checkout anyway.) +2. The agent works in the worktree: implements, tests, commits. Its hook + events stream to the board like any session. +3. On clean exit with commits on the branch the board moves the card to + `review/`; on failure it stays in `in-progress/` and the exit is narrated + in the ticker. A clean exit that committed *nothing* also stays in + `in-progress/` and is called out loudly — an empty branch reaching + review/ is how a broken launch hides. Stdout is kept in `.agent/logs/`. + +## Pull requests + +A card entering `review/` with a `task/` branch gets a PR opened for +it automatically — mechanically, by the board, not by an agent: it pushes +the branch to the repo's remote and runs `gh pr create` with the task title +and the agent's closing summary as the body. The PR url is written into the +task file as a `**PR:** ` line, so the file stays the source of truth +and the card grows a `PR ↗` chip. Cards without a branch pass through +quietly. One guard is loud: if local `main` is ahead of the remote, the PR +would drag those commits into its diff, so the board refuses and tells you +to push main first (then move the card out and back, or wait for the next +entry into review/). + +Review-stage cards with a PR carry two actions: + +- **◔ review PR** — a read-only agent reads the full diff in context, + checks it against the task and AGENTS.md, posts its verdict to GitHub + (`gh pr review --approve` / `--request-changes`) and appends a + `## PR review` section to the task file ending in + `PR REVIEW: APPROVE | REQUEST CHANGES`. +- **⚑ copilot** — requests a GitHub Copilot review via the API. Works iff + Copilot code review is enabled for the repo; the error is relayed to the + toast if not. The card tracks the whole arc with a `⚑` chip: `⚑ ◌` asked, + `⚑ ✓` approved, `⚑ ✕` changes requested, `⚑ ·` commented — "asked" + becomes a verdict when the pending request turns into an authored review. + +Once any review is in (a verdict from either agent kind, Copilot, or a +human), the card's actions shift to the loop that matters then: +**↻ act on PR** replaces the copilot button — an agent re-enters the task's +worktree (recreated from the branch if it was cleaned up), reads every +review and line comment, addresses each point or says why not, commits, +pushes so the PR updates, and appends a `## PR update` section to the task +file. Then **◔ review PR** again, until it settles. + +The board polls open PRs of review-stage cards (reviews + CI checks + +GitHub's mergeable state, every 60s — a plain thread in board.py, no agent +involved, silent when review/ is empty) and folds everything into one +verdict — any changes-requested review or failing check wins over any +approval. A PR GitHub cannot merge cleanly wears an alarm-coloured +`conflicts` chip and counts as changes-needed-by-you (not a CI failure); +**↻ act on PR** resolves mechanical conflicts by merging main into the +branch — additively, never rebasing or force-pushing — in a dedicated +resolution commit, and refuses semantic ones, naming the collision for a +human to settle. GitHub computes mergeability lazily, so an UNKNOWN +reading keeps the chip's last state rather than flapping. +Tool chips (CI, copilot, PR, drive) +are destinations, not statuses: they live in the card's footer row, never +squeezed into the author row — `CI ✓` (pine), `CI ✕` (terracotta), `◌` +while in flight, with hover actions staying in the status pill's slot. The card wears it in the design +system's state colours: approved → pine (`--calm`) border and an +`approved` pill; changes asked → terracotta (`--alarm`) and a +`changes asked` pill; otherwise it stays the neutral `waiting on you`. +Merging remains yours — the board never merges. + +The agent's first duty is to judge whether the task is actionable. If the +task still has open questions — unresolved decisions only its author can +settle — the agent does no work and exits with a `NOT READY: ` +marker. The board then moves the card back (to `to-do/`, or `backlog/` if it +started there), records the reason in the ticker, and deletes the untouched +worktree and branch so the task can be refined and relaunched cleanly. + +**◔ still true?** (every stage, done included) fires a read-only relevance +agent instead: no worktree, edit tools disallowed, running in the main +checkout. It checks the task against the actual codebase — already done? +assumptions stale? still worth doing as written? — and its report is +appended to the task file under a `## Relevance review — ` heading, +with the verdict (`Still relevant | Partly done | Already done | Needs +rewrite`) in the ticker. The card does not move; deciding what to do with +the verdict is yours. One agent per task at a time applies across both +kinds. + +Dragging a card with work attached (branch or PR) to `done/` opens a +three-way choice instead of just moving: **keep it where it is** (nothing +changes), **just move the card** (branch, PR and worktree stay), or +**merge & clean up** — park the drive if it is this task's, merge the +branch into main, push (which marks the PR merged) and delete the remote +branch, remove the worktree and local branch, then move the card. Every +step narrates in the ticker; a merge conflict aborts cleanly and the card +stays put. Cards without work move silently, and hand-moves on disk are +never intercepted — the board only asks when you act through it. +One agent per task at a time; a work agent's worktree must not already +exist when starting. + + +The flow is linear: + +``` +backlog → to-do → in-progress → review → done +``` + +## Stages + +### backlog/ +Where new tasks are written and where they wait. A backlog task may be rough, +incomplete, or fully specified — what it has in common with its neighbours is +that nobody is working on it. Most tasks live here for most of their life. + +### to-do/ +Picked up and queued to work on next. Moving a task from `backlog/` to `to-do/` +is a commitment to do it soon, so keep this directory short — a long `to-do/` is +just a second backlog. + +### in-progress/ +Actively being worked on right now. Anything here should have someone (or an +agent session) attached to it. If work stalls, move it back to `to-do/` or +`backlog/` rather than leaving it parked — a stale `in-progress/` makes the board +lie about what is happening. + +Implementation plans (created via Claude Code's plan mode) are stored in `plans/` +and can be referenced from the task file. + +### review/ +The work is built and awaits judgment: tests written and passing, a PR open +(see "Pull requests"), behaviour checked in the running app, edge cases +probed. A task sitting here has code but not yet confidence. If review turns +up problems, move it back to `in-progress/`. + +### done/ +Finished and merged. Completed task files are kept as a record of what was built +and why — they are the closest thing we have to design history, so don't delete +or trim them. + +### reference/ (beside tasks/, not a stage) +Supporting documents that tasks can link to — external specs, API documentation, +research notes, screenshots, competitive analysis, regulatory references, etc. +These don't move through the workflow; they're stable resources. Reference them +from task files using relative links — two levels up from a stage directory +(e.g. `[IVASS spec](../../reference/ivass-document-requirements.md)`). + +## Moving a task + +When moving a task between stages: +1. Update the **Status** field in the task file header +2. Move the file to the new directory +3. Add any notes about why it's moving (e.g. "approach agreed, starting build") + +Moves are not always forward. Going back a stage is normal and expected — +verification failing, or an approach not surviving contact with the code, should +move the task backwards rather than being worked around in place. + +## Task file format + +Each task is a markdown file with a descriptive filename +(e.g. `01-document-handling-review.md`). Numbers are allocated in creation order +and stay with the file for life — they do not renumber when a task moves stage. + +Start new tasks from `tasks/task-template.md` — copy it into `backlog/` and +fill it in. Its sections earn their keep: Context and What-to-build are what +a work agent gets as its brief, Acceptance is what reviews judge against, +and a non-empty Open-questions section makes an agent refuse the task +(`NOT READY`) rather than guess. The template itself is never listed on the +board (only stage directories are read). + +The file should have at minimum: + +```markdown +# Task title + +**Status:** Backlog | To Do | In Progress | Review | Done +**Priority:** High | Medium | Low +``` + +Use those exact status values — nothing else (not "Not started", "WIP", etc.) — +and keep the status in step with the directory the file sits in. Priority may +carry a short justification after the level +(e.g. `Medium — foundational for any real environment`). + +An optional **Type** line can record what kind of work the task is, when that +isn't obvious from the title: + +```markdown +**Type:** Discovery | Bug | Feature | Refactor | Chore +``` + +Type is orthogonal to status. A discovery task — research, scoping, spiking an +approach — moves through the same five stages as everything else; "discovery" +describes the work, not where it sits on the board. + +An optional **Depends on** line can name what must land first — task numbers +or external preconditions — so sequencing lives in the header instead of +prose asides: + +```markdown +**Depends on:** 03, 05 +``` + +The board does not enforce it; it informs whoever picks the next card. + +The rest of the file is freeform — description, research findings, approach, +open questions, whatever is relevant to the current stage. diff --git a/CLAUDE.md b/CLAUDE.md index 28a4481..e137ce3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,434 +1,5 @@ -# Task Workflow + -Tasks move through a kanban of directories. The directory a file sits in **is** -its status — there is no other source of truth. - -``` -.task-manager/ -├── CLAUDE.md ← This file (core-owned; replaced by updates) -├── install.py ← Wires the project via the agent adapter (see below) -├── start.sh ← One-command start: install + port handling + board -├── stop.sh ← Safe stop: refuses while agents run (--force overrides) -├── update.sh ← Replace core/ from the published release; local/ survives -├── tasks/ ← Task state, nothing else. Works as a plain folder -│ ├── backlog/…done/ ← kanban even if manager/ is deleted or ignored. -│ └── archive/ ← Archived cards: out of the flow, never deleted -├── plans/ ← Claude Code plan files (via plansDirectory setting) -├── reference/ ← Supporting documents referenced by tasks -└── manager/ - ├── core/ ← The tool. Replaced WHOLESALE by update.sh — never - │ │ put anything project-specific here. - │ ├── VERSION, board.py, config.py … httpd.py, board.html - │ ├── prompts/ ← Default agent prompt templates - │ ├── adapters/ ← Agent-vendor integrations (claude/ and - │ │ opencode/ ship; contract in README) - │ └── driver.example/ - └── local/ ← This project's half. Updates never touch it. - ├── .env ← Settings (gitignored; defaults in core/.env.example) - ├── CLAUDE.md ← Project-specific workflow notes — read it too - ├── driver/start ← How THIS project's app launches from a worktree - ├── commands/ ← Project chores run against a task's worktree - ├── prompts/ ← Prompt overrides (same filename beats the default) - ├── adapters/ ← Adapter overrides/additions - └── state/ ← Runtime data: sessions, agent logs, drives (gitignored) -``` - -Three layers, one law: **core knows about tasks, worktrees, PRs and events — -it knows nothing about any particular app, agent vendor, or project.** -Drivers know apps, adapters know vendors, `local/` knows this project. -`tasks/` holds only state and works as a plain folder kanban even if -`manager/` is deleted; the board narrates hand-moves when it happens to run. - -Module map for `manager/core/` (dependencies flow strictly left to right): - -``` -config → state → taskfiles → events / github / drive → agents → watch / httpd → board.py -``` - -- `config.py` — paths, stages, settings, prompt/adapter/driver resolution -- `state.py` — shared registries, event log persistence, SSE fan-out -- `taskfiles.py` — reading and moving task files; the only code touching tasks/ -- `events.py` — ingests NORMALIZED events (the adapter contract), session registry -- `github.py` — PR opening, Copilot requests, review/CI polling -- `drive.py` — runs the project driver, tracks the one live drive -- `agents.py` — headless work/review jobs, launched through the adapter -- `watch.py` — 2s disk poller narrating moves made outside the API -- `httpd.py` — HTTP routes, the SSE stream, serving the page -- `board.py` — argparse + startup wiring only - -## Agent adapters - -Headless jobs run through an adapter (`BOARD_AGENT_ADAPTER`, default -`claude`), so the manager works with other coding agents too. An adapter is -a directory with `run` (execute one job: `AGENT_PROMPT` + `AGENT_MODE` -work|act-pr|review + `AGENT_COMMANDS` in, stdout = the log, markers parsed -from it) and `wire` (idempotently give the host project live-session -visibility). Headless jobs answer no permission prompts, so each intent is -granted exactly the side effects its prompt demands — commit and test for -work, push for act-pr, posting PR verdicts for review — with the project's -own runnable commands coming from `BOARD_AGENT_COMMANDS` as neutral -prefixes each adapter renders in its vendor's rule syntax. Adapters -translate their vendor's events into the board's normalized schema at the -edge — core never sees vendor payloads. The full contract, including the -event schema, lives in `core/adapters/README.md`. - -## Drives - -The **⛭ drive** chip on a review card launches the app locally *from that -task's worktree*, so you can click around the actual feature before -merging. How an app starts is project knowledge, so it lives in the -project's driver — `local/driver/start`, an executable the board runs and -owns: refuse fast with a printed reason, print `DRIVE URL: ` when up, -run until parked (SIGTERM). No driver → the chip says so and the tooltip -explains what to create; `core/driver.example/` documents the contract. -One drive at a time; **park** takes it down. - -## The activity bar and the archive - -The bottom bar is one place: what happened, and where things go. **Activity** -expands the full event log (filters, the plans/ and reference/ listings, a -resize grip); collapsed, the latest event ticks along the bar. The -**Archive** tray anchors the right end — drag a card from `backlog/`, -`to-do/` or `done/` anywhere onto the bar and it moves to `tasks/archive/`: -out of every column, never deleted, Status set to `Archived`. The toast says -⌘Z brings it back, and it does — nothing in this system removes work -without an undo in the same breath. Cards in the working stages -(in-progress, review) cannot be archived; finish or walk them back first. - -## Local commands - -Projects grow chores that belong to a specific checkout — applying a -branch's DB migrations, reseeding, rebuilding assets. Those are -**local commands**: executables in `manager/local/commands/`, surfaced as -`$`-glyph chips on cards that have a branch (in progress and review) and -run against that task's worktree (recreated from the branch if needed). -The contract mirrors the driver's: env in (`CMD_WORKTREE`, `CMD_BRANCH`, -`CMD_TASK`, `CMD_REPO`), output to a log under `local/state/commands/`, -and the ticker narrates the ending either way with the log's last line. -A `# help:` line near the top of the script becomes the chip's tooltip. -Commands arm on first click and run on the second. - -## Updating - -`./.task-manager/update.sh` downloads the latest GitHub Release of the -distribution repo (`BENCH_REF=v3` pins a tag; the source is stamped into -the script at build time, `BENCH_SOURCE` in `local/.env` overrides it), -replaces `manager/core/` wholesale plus the top-level files named in the -artifact's `manager/core/release-manifest`, and touches nothing else — -tasks, driver, prompt overrides, `.env` and state all survive. No release -published → it says so and changes nothing; developers working on bench -itself update their clone with git instead. Then re-run `install.py` -(idempotent re-wire) and restart the board. Releases are cut from the -bench repo with `release.sh` (never shipped in the artifact): it builds -the tarball from the manifest, tags `v` and publishes via `gh`. - -## Installing into a project - -```bash -python3 .task-manager/install.py # idempotent; --dry-run to preview -``` - -Checks that the containing directory is a `.claude`-initialised project and -delegates to the configured agent adapter's `wire` — for Claude that means -`.claude/settings.json`: `plansDirectory` and the five event hooks running -the adapter's `emit.py`. Fully present → reports "ok" and touches nothing; -partial, stale (old `.tasks/` paths) or duplicated → repaired in place. Other -hooks and settings are never touched, so it is safe to run any time — e.g. -after dropping `.task-manager/` into a new repo. - -## Seeing the board - -```bash -./.task-manager/start.sh # the usual way: install + port + board -python3 .task-manager/manager/board.py # or run the server directly -``` - -`start.sh` runs `install.py` (idempotent), then sorts out the port before -serving in the foreground (Ctrl-C stops it). Three cases: - -- this project's board already answers on the port → just reopens the browser -- the port is free → starts on it -- something else occupies it → takes the next free port **and persists it to - `manager/.env`**, so the hooks and agents — which read the same file — - follow the board rather than reporting to a port it no longer serves. - -Extra arguments pass through to `board.py` (e.g. `./start.sh --no-open`). - -`stop.sh` is the counterpart. It identifies the board by asking the port's -API for its tasks root, so it never kills a foreign process squatting there. -While agents are running it refuses — stopping the board loses their endings -(auto-move, PR opening, decline handling) even though the agent processes -themselves survive — and names who is working on what; `--force` overrides. - -Port **26071 is pinned** so the URL is always the same one to bookmark. Running -the command again while it is already up just reopens that tab rather than -failing on a port clash. - -All settings live in `manager/core/.env.example` with their defaults documented — -the port, the binaries agents launch with, the commands agents may run, -the worktrees directory, the watch interval and the in-memory caps. Copy it -to `manager/local/.env` (gitignored) to override locally; real environment -variables beat `.env`, which beats the defaults. The hook bridge reads the -same `.env`, so changing `BOARD_PORT` moves the board, the agents and the -hooks together. - -Stdlib only, no install. It reads the directories on every request, so refreshing -the page shows current disk state. Dragging a card between columns does both -steps of a move for you — it renames the file and rewrites its **Status:** line. -Cards whose Status line disagrees with the directory they sit in are flagged -`status drift`. - -## Live view - -The UI follows the **Bench** design system — cool sea neutrals, IBM Plex Sans -for anything a person wrote and Plex Mono for anything a machine produced, -and colour that only ever means state: `--accent` (surf) an agent alive, -`--calm` (pine) settled or passed, `--alarm` (terracotta) blocked, failed or -HIGH, `--idle` (driftwood) done. The one looping animation ("breathe") means -an agent is working; a blinking caret means output is still arriving. Night -theme by default; the header button switches to Daylight. Tokens live at the -top of `manager/board.html`. - -The board has three views (header switcher): - -- **Board** — the kanban, live. Active Claude Code sessions appear as chips in - the header; a card an agent is working on carries a live activity line; the - bottom ticker narrates the latest events and every move is attributed - (`you` / `agent` / `disk`). -- **Sessions** — a flight recorder per session: a chronological timeline of - reads, edits, test runs, commits and card moves, with filters and expandable - output. Sessions persist to `.sessions/*.jsonl`, so past ones can be replayed. -- **Focus** — a heads-up display for one session: the task it holds, its live - TodoWrite plan, the project's configured definition-of-done checks (a - `checks` file in `manager/local/` overriding the shipped default in - `core/`), and per-file diff stats from its worktree. - -Liveness comes from Claude Code hooks configured in `.claude/settings.json`: -every session in this repo POSTs normalized events to the board via the -claude adapter's `emit.py` (fails silently in under a second when the board isn't -running). A watcher thread also polls the stage directories every 2s, so moves -made by hand still show up. The browser gets everything over SSE — no refresh -needed. Hooks are snapshotted at session start, so a session already open when -the hooks were added won't report until restarted. - -Agent prompts ship in `manager/core/prompts/` and can be overridden per -project by placing a file of the same name in `manager/local/prompts/` -(the override wins). They are plain -markdown with `{branch}` / `{stage}` / `{filename}` / `{body}` placeholders, -filled via `str.format` — so literal braces elsewhere in a prompt would break -it. They are read fresh on every agent launch; edits apply without restarting -the board. - -## Agents working the board - -Card actions appear on hover, taking over the status pill's slot (never -stacking on top of it) — at most two per state, only things you'd actually -do without opening the card: **▸ start work** on in-progress cards, -**‖ hold** while an agent runs, **↩ back** on cards waiting on you, -**↺ reopen** on done cards, and **◔ still true?** everywhere. Actions that -cost tokens or stop work arm on first click and fire on the second. - -Each launched agent wears a short name for its lifetime (Wren, Juno, -Basil, …) — picked per launch, never shared by two running agents, shown as -`Wren · #09` on cards, in the sessions list and throughout the ticker. Names -are held in memory, so a restarted board falls back to plain "Agent" for -sessions that predate it. - -**▸ start work** launches a headless `claude -p` on the task. It exists only -on `in-progress/` cards: moving a card to in-progress is the commitment, and -only then does work start — the server refuses launches from anywhere else. - -1. The board creates a git worktree at `.worktrees//` on a new - branch `task/` from current HEAD. (The agent is told not to - touch the task file — worktree moves would be invisible to the main - checkout anyway.) -2. The agent works in the worktree: implements, tests, commits. Its hook - events stream to the board like any session. -3. On clean exit with commits on the branch the board moves the card to - `review/`; on failure it stays in `in-progress/` and the exit is narrated - in the ticker. A clean exit that committed *nothing* also stays in - `in-progress/` and is called out loudly — an empty branch reaching - review/ is how a broken launch hides. Stdout is kept in `.agent/logs/`. - -## Pull requests - -A card entering `review/` with a `task/` branch gets a PR opened for -it automatically — mechanically, by the board, not by an agent: it pushes -the branch to the repo's remote and runs `gh pr create` with the task title -and the agent's closing summary as the body. The PR url is written into the -task file as a `**PR:** ` line, so the file stays the source of truth -and the card grows a `PR ↗` chip. Cards without a branch pass through -quietly. One guard is loud: if local `main` is ahead of the remote, the PR -would drag those commits into its diff, so the board refuses and tells you -to push main first (then move the card out and back, or wait for the next -entry into review/). - -Review-stage cards with a PR carry two actions: - -- **◔ review PR** — a read-only agent reads the full diff in context, - checks it against the task and CLAUDE.md, posts its verdict to GitHub - (`gh pr review --approve` / `--request-changes`) and appends a - `## PR review` section to the task file ending in - `PR REVIEW: APPROVE | REQUEST CHANGES`. -- **⚑ copilot** — requests a GitHub Copilot review via the API. Works iff - Copilot code review is enabled for the repo; the error is relayed to the - toast if not. The card tracks the whole arc with a `⚑` chip: `⚑ ◌` asked, - `⚑ ✓` approved, `⚑ ✕` changes requested, `⚑ ·` commented — "asked" - becomes a verdict when the pending request turns into an authored review. - -Once any review is in (a verdict from either agent kind, Copilot, or a -human), the card's actions shift to the loop that matters then: -**↻ act on PR** replaces the copilot button — an agent re-enters the task's -worktree (recreated from the branch if it was cleaned up), reads every -review and line comment, addresses each point or says why not, commits, -pushes so the PR updates, and appends a `## PR update` section to the task -file. Then **◔ review PR** again, until it settles. - -The board polls open PRs of review-stage cards (reviews + CI checks, every -60s — a plain thread in board.py, no agent involved, silent when review/ is -empty) and folds everything into one verdict — any changes-requested review -or failing check wins over any approval. Tool chips (CI, copilot, PR, drive) -are destinations, not statuses: they live in the card's footer row, never -squeezed into the author row — `CI ✓` (pine), `CI ✕` (terracotta), `◌` -while in flight, with hover actions staying in the status pill's slot. The card wears it in the design -system's state colours: approved → pine (`--calm`) border and an -`approved` pill; changes asked → terracotta (`--alarm`) and a -`changes asked` pill; otherwise it stays the neutral `waiting on you`. -Merging remains yours — the board never merges. - -The agent's first duty is to judge whether the task is actionable. If the -task still has open questions — unresolved decisions only its author can -settle — the agent does no work and exits with a `NOT READY: ` -marker. The board then moves the card back (to `to-do/`, or `backlog/` if it -started there), records the reason in the ticker, and deletes the untouched -worktree and branch so the task can be refined and relaunched cleanly. - -**◔ still true?** (every stage, done included) fires a read-only relevance -agent instead: no worktree, edit tools disallowed, running in the main -checkout. It checks the task against the actual codebase — already done? -assumptions stale? still worth doing as written? — and its report is -appended to the task file under a `## Relevance review — ` heading, -with the verdict (`Still relevant | Partly done | Already done | Needs -rewrite`) in the ticker. The card does not move; deciding what to do with -the verdict is yours. One agent per task at a time applies across both -kinds. - -Dragging a card with work attached (branch or PR) to `done/` opens a -three-way choice instead of just moving: **keep it where it is** (nothing -changes), **just move the card** (branch, PR and worktree stay), or -**merge & clean up** — park the drive if it is this task's, merge the -branch into main, push (which marks the PR merged) and delete the remote -branch, remove the worktree and local branch, then move the card. Every -step narrates in the ticker; a merge conflict aborts cleanly and the card -stays put. Cards without work move silently, and hand-moves on disk are -never intercepted — the board only asks when you act through it. -One agent per task at a time; a work agent's worktree must not already -exist when starting. - - -The flow is linear: - -``` -backlog → to-do → in-progress → review → done -``` - -## Stages - -### backlog/ -Where new tasks are written and where they wait. A backlog task may be rough, -incomplete, or fully specified — what it has in common with its neighbours is -that nobody is working on it. Most tasks live here for most of their life. - -### to-do/ -Picked up and queued to work on next. Moving a task from `backlog/` to `to-do/` -is a commitment to do it soon, so keep this directory short — a long `to-do/` is -just a second backlog. - -### in-progress/ -Actively being worked on right now. Anything here should have someone (or an -agent session) attached to it. If work stalls, move it back to `to-do/` or -`backlog/` rather than leaving it parked — a stale `in-progress/` makes the board -lie about what is happening. - -Implementation plans (created via Claude Code's plan mode) are stored in `plans/` -and can be referenced from the task file. - -### review/ -The work is built and awaits judgment: tests written and passing, a PR open -(see "Pull requests"), behaviour checked in the running app, edge cases -probed. A task sitting here has code but not yet confidence. If review turns -up problems, move it back to `in-progress/`. - -### done/ -Finished and merged. Completed task files are kept as a record of what was built -and why — they are the closest thing we have to design history, so don't delete -or trim them. - -### reference/ (beside tasks/, not a stage) -Supporting documents that tasks can link to — external specs, API documentation, -research notes, screenshots, competitive analysis, regulatory references, etc. -These don't move through the workflow; they're stable resources. Reference them -from task files using relative links — two levels up from a stage directory -(e.g. `[IVASS spec](../../reference/ivass-document-requirements.md)`). - -## Moving a task - -When moving a task between stages: -1. Update the **Status** field in the task file header -2. Move the file to the new directory -3. Add any notes about why it's moving (e.g. "approach agreed, starting build") - -Moves are not always forward. Going back a stage is normal and expected — -verification failing, or an approach not surviving contact with the code, should -move the task backwards rather than being worked around in place. - -## Task file format - -Each task is a markdown file with a descriptive filename -(e.g. `01-document-handling-review.md`). Numbers are allocated in creation order -and stay with the file for life — they do not renumber when a task moves stage. - -Start new tasks from `tasks/task-template.md` — copy it into `backlog/` and -fill it in. Its sections earn their keep: Context and What-to-build are what -a work agent gets as its brief, Acceptance is what reviews judge against, -and a non-empty Open-questions section makes an agent refuse the task -(`NOT READY`) rather than guess. The template itself is never listed on the -board (only stage directories are read). - -The file should have at minimum: - -```markdown -# Task title - -**Status:** Backlog | To Do | In Progress | Review | Done -**Priority:** High | Medium | Low -``` - -Use those exact status values — nothing else (not "Not started", "WIP", etc.) — -and keep the status in step with the directory the file sits in. Priority may -carry a short justification after the level -(e.g. `Medium — foundational for any real environment`). - -An optional **Type** line can record what kind of work the task is, when that -isn't obvious from the title: - -```markdown -**Type:** Discovery | Bug | Feature | Refactor | Chore -``` - -Type is orthogonal to status. A discovery task — research, scoping, spiking an -approach — moves through the same five stages as everything else; "discovery" -describes the work, not where it sits on the board. - -An optional **Depends on** line can name what must land first — task numbers -or external preconditions — so sequencing lives in the header instead of -prose asides: - -```markdown -**Depends on:** 03, 05 -``` - -The board does not enforce it; it informs whoever picks the next card. - -The rest of the file is freeform — description, research findings, approach, -open questions, whatever is relevant to the current stage. +@AGENTS.md diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..699ceba --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 12vectors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 62b3e7a..f6dbd5c 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,11 @@ bench's own cards or settings, so the board starts empty by construction. Commit `.task-manager/` into the host repo — core is vendored on purpose, so clones work offline and updates show up in the host's own diffs. +The workflow brief ships as `.task-manager/AGENTS.md` — the cross-vendor +name coding agents read natively — with `CLAUDE.md` beside it as a one-line +compatibility pointer. Both live inside `.task-manager/`, so a host repo's +own root `AGENTS.md` is never touched. + ## Update ```bash @@ -57,4 +62,8 @@ install one-liner above depends on. Core knows about tasks, worktrees, PRs and events. It knows nothing about any particular app (drivers do: `local/driver/start`), agent vendor (adapters do: `core/adapters/`), or project (`local/` does). Full docs in -CLAUDE.md; the adapter contract in `manager/core/adapters/README.md`. +AGENTS.md; the adapter contract in `manager/core/adapters/README.md`. + +## License + +[MIT](LICENSE). diff --git a/manager/core/.env.example b/manager/core/.env.example index 1fe11ac..e7a3012 100644 --- a/manager/core/.env.example +++ b/manager/core/.env.example @@ -15,6 +15,19 @@ BOARD_AGENT_ADAPTER=claude BOARD_CLAUDE_BIN=claude BOARD_OPENCODE_BIN=opencode +# The model headless agents run on — an opaque vendor-native name the +# adapter passes through untranslated (claude: a model name or alias for +# --model; opencode: the "provider/model-id" config key). Empty = inherit +# the vendor's own default, i.e. whatever the CLI on this machine would +# pick anyway. The per-intent settings beat the general one for their +# intent only; _REVIEW covers both PR reviews and relevance checks. Work +# agents write code; reviews just read and judge — they can ride a +# cheaper, faster model. +BOARD_AGENT_MODEL= +BOARD_AGENT_MODEL_WORK= +BOARD_AGENT_MODEL_ACT_PR= +BOARD_AGENT_MODEL_REVIEW= + # Command prefixes headless agents may run in their worktree — the # project's test/check commands, comma-separated, in plain neutral form # (each adapter renders them into its vendor's permission rules; the @@ -38,6 +51,12 @@ BOARD_GH_BIN=gh BOARD_GIT_REMOTE= BOARD_PR_POLL_INTERVAL=60 +# Seconds a work-agent launch waits for `git fetch origin main` before +# giving up and branching from local HEAD. Fresh launches branch from +# origin/main when the fetch succeeds; no remote or a dead network just +# means today's behaviour, never a blocked launch. +BOARD_FETCH_TIMEOUT=10 + # Seconds between disk polls of the stage directories. BOARD_WATCH_INTERVAL=2 diff --git a/manager/core/adapters/README.md b/manager/core/adapters/README.md index 4a4dc0c..e461823 100644 --- a/manager/core/adapters/README.md +++ b/manager/core/adapters/README.md @@ -15,14 +15,19 @@ An adapter is a directory with two executables: - env in: `AGENT_PROMPT` (the full prompt), `AGENT_MODE` (the launch intent, below), `AGENT_COMMANDS` (the project's allowed command - prefixes, below), `AGENT_CWD`, and the `BOARD_*` passthrough - (`BOARD_AGENT_ID`, `BOARD_TASK`, `BOARD_PORT`) which your event bridge - must forward with every event. + prefixes, below), `AGENT_MODEL` (optional, below), `AGENT_CWD`, and + the `BOARD_*` passthrough (`BOARD_AGENT_ID`, `BOARD_TASK`, + `BOARD_PORT`) which your event bridge must forward with every event. - stdout is captured by the board as the job log. The prompts instruct the agent to end with marker lines (`NOT READY:`, `RELEVANCE REVIEW:`, `PR REVIEW:`, `ADDRESSED:`) — the board parses them from this output, so the agent's final text must reach stdout. - exit 0 = completed; anything else = failed. +- The workflow brief the prompts point agents at is `AGENTS.md` at the + repo root (`CLAUDE.md` beside it is only a compatibility pointer). + Vendors that read `AGENTS.md` from the working directory's tree + natively — opencode does, as does current Claude Code — pick it up in + every worktree with no adapter work; `run` never needs to inject it. ### Launch intents (`AGENT_MODE`) @@ -35,9 +40,13 @@ side effects its prompt demands, and never a blanket allow-everything - `work` — implement, test, commit in an isolated worktree. May edit files, run local git bookkeeping (`git add/commit/status/diff`) and the project's `AGENT_COMMANDS`. No push. -- `act-pr` — the work stance, plus `git push` (the PR must update) and +- `act-pr` — the work stance, plus `git push` (the PR must update), reading the PR's reviews and line comments (`gh pr view`, `gh pr - diff`, `gh api`). + diff`, `gh api`), and `git fetch`/`git merge` so a conflicted PR can + be resolved by merging main into the branch. Resolution is additive + only — the branch is public — so `git rebase` and the force-push + spellings must be denied, not merely unlisted (a plain `git push` + allow would otherwise cover them). - `review` — read-only on the working tree: no edit tools, no commits. May read a PR (`gh pr view`, `gh pr diff`, read-only git) and post the verdict (`gh pr review`, `gh pr comment`). @@ -57,6 +66,17 @@ prefix-pattern based, so the translation is mechanical: "allow"}}` in a generated config, wildcard rules, last match wins (`opencode/permission_config.py`) +### The model (`AGENT_MODEL`) — optional + +Absent = the vendor's own default: launch without any model argument and +let your CLI resolve it however it normally would. When set, it is an +opaque vendor-native model name — a claude alias, an opencode +`provider/model-id` — that core never validates or interprets; pass it +through untranslated (claude → `--model "$AGENT_MODEL"`, opencode → the +generated config's `model` key). Never send your vendor an empty value: +the board only sets the variable when a model is actually configured +(`BOARD_AGENT_MODEL` and its per-intent overrides in `local/.env`). + ### `wire` — wire live-session visibility into the host project Called by `install.py` with the project root as argv[1] (plus `--dry-run`). diff --git a/manager/core/adapters/claude/hook_settings.py b/manager/core/adapters/claude/hook_settings.py index 503c5d2..96a607d 100644 --- a/manager/core/adapters/claude/hook_settings.py +++ b/manager/core/adapters/claude/hook_settings.py @@ -13,7 +13,12 @@ is isolated, the shell is not. (add/commit/status/diff) + the project's test/check commands. No push. act-pr the work stance + `git push` (the PR must update) + reading - the PR's reviews and line comments through gh. + the PR's reviews and line comments through gh + `git fetch` + and `git merge` so a conflicted PR can be resolved by merging + main into the branch. The branch is public, so resolution is + additive only: rebase and the force-push spellings are denied + outright (deny beats allow, catching what the plain + `git push` prefix would otherwise cover). review read-only (edit tools disallowed in `run`) + reading the PR it judges + posting the verdict with gh pr review/comment. @@ -35,10 +40,19 @@ from pathlib import Path MODE_PREFIXES = { "work": ["git add", "git commit", "git status", "git diff"], "act-pr": ["git add", "git commit", "git status", "git diff", + "git fetch", "git merge", "git push", "gh pr view", "gh pr diff", "gh api"], "review": ["git status", "git diff", "git log", "git show", "gh pr view", "gh pr diff", "gh pr review", "gh pr comment"], } +# History must never rewrite under a public PR: deny the canonical force +# and rebase spellings even though nothing allows them — `git push` alone +# would otherwise cover `git push --force` by prefix. (Prefix rules can't +# catch a flag placed after the refspec; the prompt and the review loop +# guard the exotic spellings.) +MODE_DENY_PREFIXES = { + "act-pr": ["git push --force", "git push -f", "git rebase"], +} # Which intents run the project's own test/check commands. MODES_WITH_PROJECT_COMMANDS = {"work", "act-pr"} @@ -60,6 +74,14 @@ def allow_rules(mode: str, commands: list[str]) -> list[str]: return rules +def deny_rules(mode: str) -> list[str]: + """Bash() deny-rules for one intent — deny beats allow.""" + rules = [] + for prefix in MODE_DENY_PREFIXES.get(mode, []): + rules += [f"Bash({prefix})", f"Bash({prefix}:*)"] + return rules + + def settings(mode: str, commands: list[str]) -> dict: emit = Path(__file__).resolve().parent / "emit.py" hook = {"type": "command", "command": f'python3 "{emit}"', "timeout": 5} @@ -71,9 +93,15 @@ def settings(mode: str, commands: list[str]) -> dict: "PreToolUse": [{"matcher": "Bash", "hooks": [hook]}], "PostToolUse": [{"matcher": "*", "hooks": [hook]}], }} + perms = {} rules = allow_rules(mode, commands) if rules: - out["permissions"] = {"allow": rules} + perms["allow"] = rules + denies = deny_rules(mode) + if denies: + perms["deny"] = denies + if perms: + out["permissions"] = perms return out diff --git a/manager/core/adapters/claude/run b/manager/core/adapters/claude/run index b447b80..af06ee7 100755 --- a/manager/core/adapters/claude/run +++ b/manager/core/adapters/claude/run @@ -7,6 +7,9 @@ # (see core/adapters/README.md) # AGENT_COMMANDS comma-separated neutral command prefixes the # project lets agents run (tests/checks) +# AGENT_MODEL optional; a claude model name/alias passed +# through as --model. Absent = the CLI's own +# resolution (user settings), untouched. # AGENT_CWD working directory (already set as cwd by the board) # BOARD_* passthrough for the event bridge # stdout: captured by the board as the job log; the closing report's @@ -25,12 +28,20 @@ BIN="${BOARD_CLAUDE_BIN:-claude}" MODE="${AGENT_MODE:-work}" SETTINGS="$(python3 "$HERE/hook_settings.py" "$MODE")" +# The ${arr[@]+...} expansion keeps set -u happy on bash 3.2 when unset. +MODEL_ARGS=() +if [ -n "${AGENT_MODEL:-}" ]; then + MODEL_ARGS=(--model "$AGENT_MODEL") +fi + if [ "$MODE" = "review" ]; then exec "$BIN" -p "$AGENT_PROMPT" --settings "$SETTINGS" \ + ${MODEL_ARGS[@]+"${MODEL_ARGS[@]}"} \ --permission-mode default \ --disallowedTools Edit Write NotebookEdit else # work and act-pr both mutate the worktree; the allowlist differs. exec "$BIN" -p "$AGENT_PROMPT" --settings "$SETTINGS" \ + ${MODEL_ARGS[@]+"${MODEL_ARGS[@]}"} \ --permission-mode acceptEdits fi diff --git a/manager/core/adapters/opencode/permission_config.py b/manager/core/adapters/opencode/permission_config.py index 94ac91f..617ba97 100644 --- a/manager/core/adapters/opencode/permission_config.py +++ b/manager/core/adapters/opencode/permission_config.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 """Print the opencode config JSON for one headless launch: the permission -rules for the launch's intent. +rules for the launch's intent, plus the model when the board configured one. Usage: permission_config.py [work|act-pr|review] @@ -14,13 +14,21 @@ and never a blanket allow: the worktree is isolated, the shell is not. work "edit": "allow" + git bookkeeping (add/commit/status/diff) and the project's test/check commands. No push. act-pr the work stance + `git push` + reading the PR's reviews and - line comments through gh. + line comments through gh + `git fetch`/`git merge` so a + conflicted PR can be resolved by merging main into the + branch. The branch is public, so resolution is additive + only: rebase and the force-push spellings get explicit deny + rules, placed last so they win over the `git push *` allow. review "edit": "deny" + reading the PR it judges + posting the verdict with gh pr review/comment. Everything else denied. The project's test/check commands arrive in AGENT_COMMANDS as comma- separated neutral command prefixes (set BOARD_AGENT_COMMANDS in local/.env); here each becomes "" and " *" allow rules. + +AGENT_MODEL, when set, becomes the config's top-level "model" key — +opencode's "provider/model-id" form (opencode.ai/docs/config), passed +through untranslated. Absent = no key, opencode's own resolution applies. """ import json import os @@ -30,10 +38,20 @@ import sys MODE_PREFIXES = { "work": ["git add", "git commit", "git status", "git diff"], "act-pr": ["git add", "git commit", "git status", "git diff", + "git fetch", "git merge", "git push", "gh pr view", "gh pr diff", "gh api"], "review": ["git status", "git diff", "git log", "git show", "gh pr view", "gh pr diff", "gh pr review", "gh pr comment"], } +# History must never rewrite under a public PR: deny the force and rebase +# spellings even though nothing allows them — "git push *" would otherwise +# cover them. Globs run over the whole command line, so the flag is caught +# wherever it sits. +MODE_DENY_PATTERNS = { + "act-pr": ["git rebase", "git rebase *", + "git push --force*", "git push * --force*", + "git push -f", "git push -f *", "git push * -f *"], +} # Which intents run the project's own test/check commands. MODES_WITH_PROJECT_COMMANDS = {"work", "act-pr"} @@ -53,20 +71,26 @@ def bash_rules(mode: str, commands: list[str]) -> dict: for prefix in prefixes: rules[prefix] = "allow" rules[f"{prefix} *"] = "allow" + for pattern in MODE_DENY_PATTERNS.get(mode, []): + rules[pattern] = "deny" # last, so it wins over the allows return rules -def build_config(mode: str, commands: list[str]) -> dict: - return { +def build_config(mode: str, commands: list[str], model: str = "") -> dict: + config = { "$schema": "https://opencode.ai/config.json", "permission": { "edit": "deny" if mode == "review" else "allow", "bash": bash_rules(mode, commands), }, } + if model: + config["model"] = model + return config if __name__ == "__main__": mode = sys.argv[1] if len(sys.argv) > 1 else "work" commands = split_commands(os.environ.get("AGENT_COMMANDS", "")) - print(json.dumps(build_config(mode, commands))) + model = os.environ.get("AGENT_MODEL", "").strip() + print(json.dumps(build_config(mode, commands, model))) diff --git a/manager/core/adapters/opencode/run b/manager/core/adapters/opencode/run index dbe901d..2a13bff 100755 --- a/manager/core/adapters/opencode/run +++ b/manager/core/adapters/opencode/run @@ -7,6 +7,9 @@ # (see core/adapters/README.md) # AGENT_COMMANDS comma-separated neutral command prefixes the # project lets agents run (tests/checks) +# AGENT_MODEL optional; opencode's "provider/model-id" form, +# set as the generated config's model key. +# Absent = opencode's own resolution, untouched. # AGENT_CWD working directory (already set as cwd by the board) # BOARD_* passthrough for the event bridge # stdout: captured by the board as the job log; `opencode run` prints diff --git a/manager/core/agents.py b/manager/core/agents.py index bcbab95..35840ec 100644 --- a/manager/core/agents.py +++ b/manager/core/agents.py @@ -81,6 +81,9 @@ def _agent_public(record: dict) -> dict: ("id", "task", "branch", "worktree", "status", "rc", "started", "session")} public["mode"] = record.get("mode", "work") public["name"] = record.get("name") + # The model the launch was actually given; None = inherited the + # vendor's own default. Honesty for the Sessions/Focus views. + public["model"] = record.get("model") return public @@ -111,9 +114,11 @@ def _launch(mode: str, prompt: str, cwd: Path, agent_id: str, filename: str, log The adapter contract: `run` gets AGENT_PROMPT, AGENT_MODE (the intent: work = mutate and commit, act-pr = work + push, review = read-only + - post PR verdicts) and AGENT_COMMANDS (the project's runnable command - prefixes) plus the BOARD_* passthrough for its event bridge; its - stdout is the job log; exit 0 = completed. + post PR verdicts), AGENT_COMMANDS (the project's runnable command + prefixes) and AGENT_MODEL (the configured model, when there is one) + plus the BOARD_* passthrough for its event bridge; its stdout is the + job log; exit 0 = completed. Returns (proc, log_file, model) with + model = '' when the launch inherits the vendor default. """ adapter = config.adapter_dir() if adapter is None: @@ -130,6 +135,13 @@ def _launch(mode: str, prompt: str, cwd: Path, agent_id: str, filename: str, log "BOARD_TASK": filename, "BOARD_PORT": str(state.serve_port), }) + model = config.agent_model(mode) + if model: + env["AGENT_MODEL"] = model + else: + # Inherit = the variable is simply absent. Popping also stops a + # stray AGENT_MODEL in the board's own environment leaking through. + env.pop("AGENT_MODEL", None) log_file = log_path.open("wb") try: proc = subprocess.Popen( @@ -138,7 +150,44 @@ def _launch(mode: str, prompt: str, cwd: Path, agent_id: str, filename: str, log except OSError as exc: log_file.close() raise ValueError(f"could not launch adapter {adapter}: {exc}") - return proc, log_file + return proc, log_file, model + + +def _fresh_branch_point() -> tuple[str | None, str | None]: + """Where a brand-new task branch should start: the newest main that + exists. With an `origin` remote, fetch its main (bounded by + FETCH_TIMEOUT) and branch from origin/main — never touching the main + checkout itself, the fetched ref is only the branch point. No remote, + a failed fetch or a timeout all mean today's behaviour: branch from + HEAD, because launching must never be blocked by network weather. + + Returns (start point, ticker note); (None, …) means HEAD. The note is + non-None whenever the branch point deserves a mention — origin/main + ahead of this checkout, or a fetch that had to be skipped. + """ + def _git(*args: str) -> subprocess.CompletedProcess: + return subprocess.run(["git", "-C", str(config.REPO), *args], + capture_output=True, text=True) + + if "origin" not in _git("remote").stdout.split(): + return None, None + try: + fetched = subprocess.run( + ["git", "-C", str(config.REPO), "fetch", "origin", "main"], + capture_output=True, text=True, timeout=config.FETCH_TIMEOUT) + except subprocess.TimeoutExpired: + return None, "fetch of origin/main timed out; branched from local HEAD" + if fetched.returncode != 0 or \ + _git("rev-parse", "--verify", "--quiet", "origin/main").returncode != 0: + return None, "fetch of origin/main failed; branched from local HEAD" + # Counted against HEAD, not main: HEAD is the fallback base, so this is + # exactly what launching would have missed — accurate even when the + # board checkout sits on another branch. + ahead = _git("rev-list", "--count", "HEAD..origin/main").stdout.strip() + if ahead.isdigit() and int(ahead) > 0: + return "origin/main", (f"branched from origin/main, " + f"{ahead} ahead of this checkout") + return "origin/main", None def start_agent(filename: str, stage: str) -> dict: @@ -156,6 +205,7 @@ def start_agent(filename: str, stage: str) -> dict: branch_exists = _git("rev-parse", "--verify", "--quiet", branch).returncode == 0 continuing = worktree.exists() + base_note = None if continuing: # earlier work exists — the agent continues on it rather than refusing current = subprocess.run( @@ -172,8 +222,14 @@ def start_agent(filename: str, stage: str) -> dict: base = _git("merge-base", "main", branch).stdout.strip() result = _git("worktree", "add", str(worktree), branch) else: - base = _git("rev-parse", "HEAD").stdout.strip() - result = _git("worktree", "add", "-b", branch, str(worktree)) + point, base_note = _fresh_branch_point() + if point: + base = _git("rev-parse", point).stdout.strip() + result = _git("worktree", "add", "--no-track", "-b", branch, + str(worktree), point) + else: + base = _git("rev-parse", "HEAD").stdout.strip() + result = _git("worktree", "add", "-b", branch, str(worktree)) if result.returncode != 0: raise ValueError(f"git worktree add failed: {result.stderr.strip()[:300]}") @@ -185,7 +241,7 @@ def start_agent(filename: str, stage: str) -> dict: prompt = config.prompt("work.md").format( branch=branch, filename=filename, body=task["body"]) - proc, log_file = _launch("work", prompt, worktree, agent_id, filename, log_path) + proc, log_file, model = _launch("work", prompt, worktree, agent_id, filename, log_path) name = _pick_name(stem) record = { @@ -193,15 +249,18 @@ def start_agent(filename: str, stage: str) -> dict: "worktree": str(worktree), "base": base, "status": "running", "rc": None, "started": time.time(), "session": None, "log": str(log_path), "proc": proc, "origin": stage, "mode": "work", - "name": name, + "name": name, "model": model or None, } with state.LOCK: state.AGENTS[agent_id] = record + summary = (f"{name} is back on {filename} — continuing branch {branch}" + if continuing else + f"{name} started on {filename} (branch {branch})") + if base_note: + summary += f" — {base_note}" state.record_board_event({ "kind": "agent", "actor": "agent", "file": filename, - "summary": (f"{name} is back on {filename} — continuing branch {branch}" - if continuing else - f"{name} started on {filename} (branch {branch})"), + "summary": summary, }) threading.Thread(target=_reap_agent, args=(agent_id, proc, log_file), daemon=True).start() @@ -219,14 +278,14 @@ def start_review(filename: str, stage: str) -> dict: prompt = config.prompt("review.md").format( stage=stage, filename=filename, body=task["body"]) - proc, log_file = _launch("review", prompt, config.REPO, agent_id, filename, log_path) + proc, log_file, model = _launch("review", prompt, config.REPO, agent_id, filename, log_path) name = _pick_name(filename) record = { "id": agent_id, "task": filename, "branch": None, "worktree": None, "base": None, "status": "running", "rc": None, "started": time.time(), "session": None, "log": str(log_path), "proc": proc, - "origin": stage, "mode": "review", "name": name, + "origin": stage, "mode": "review", "name": name, "model": model or None, } with state.LOCK: state.AGENTS[agent_id] = record @@ -352,13 +411,13 @@ def start_pr_review(filename: str, stage: str) -> dict: prompt = config.prompt("review-pr.md").format( filename=filename, pr=task["pr"], branch=branch, body=task["body"]) - proc, log_file = _launch("review", prompt, config.REPO, agent_id, filename, log_path) + proc, log_file, model = _launch("review", prompt, config.REPO, agent_id, filename, log_path) record = { "id": agent_id, "task": filename, "branch": branch, "worktree": None, "base": None, "status": "running", "rc": None, "started": time.time(), "session": None, "log": str(log_path), "proc": proc, - "origin": stage, "mode": "review", "name": name, + "origin": stage, "mode": "review", "name": name, "model": model or None, } with state.LOCK: state.AGENTS[agent_id] = record @@ -403,14 +462,14 @@ def start_pr_fix(filename: str, stage: str) -> dict: prompt = config.prompt("act-pr.md").format( filename=filename, branch=branch, pr=task["pr"], body=task["body"]) # act-pr is the one intent allowed to push: the PR must update. - proc, log_file = _launch("act-pr", prompt, worktree, agent_id, filename, log_path) + proc, log_file, model = _launch("act-pr", prompt, worktree, agent_id, filename, log_path) record = { "id": agent_id, "task": filename, "branch": branch, "worktree": str(worktree), "base": None, "status": "running", "rc": None, "started": time.time(), "session": None, "log": str(log_path), "proc": proc, "origin": stage, "mode": "work", - "name": name, + "name": name, "model": model or None, } with state.LOCK: state.AGENTS[agent_id] = record diff --git a/manager/core/board.html b/manager/core/board.html index 5252847..b2b367f 100644 --- a/manager/core/board.html +++ b/manager/core/board.html @@ -20,7 +20,7 @@ --accent:#56c2d8; --calm:#a6c96f; --alarm:#e08a63; --idle:#5d757b; --on-accent:#0a161b; --on-calm:#141a10; --shadow:0 10px 24px -18px rgba(0,0,0,.9); - --pad:14px; --gap:10px; --radius:10px; --col:296px; + --pad:14px; --gap:10px; --radius:10px; --col:296px; --col-min:240px; --sans:'IBM Plex Sans',system-ui,sans-serif; --mono:'IBM Plex Mono',ui-monospace,SFMono-Regular,Menlo,monospace; } @@ -35,6 +35,8 @@ @keyframes breathe{0%,100%{opacity:.35;transform:scale(.82)}50%{opacity:1;transform:scale(1)}} @keyframes blink{0%,49%{opacity:1}50%,100%{opacity:0}} @keyframes rise{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:translateY(0)}} + @keyframes fadein{from{opacity:0}to{opacity:1}} + @keyframes drain{from{transform:scaleX(1)}to{transform:scaleX(0)}} @keyframes slidein{from{opacity:0;transform:translateX(18px)}to{opacity:1;transform:translateX(0)}} @media (prefers-reduced-motion: reduce){ *,*::before,*::after{animation:none !important;transition:none !important} @@ -91,7 +93,7 @@ .view.on{display:flex} #view-board{flex-direction:column} #board{flex:1;display:flex;gap:var(--gap);align-items:stretch;padding:14px 18px;overflow-x:auto;min-height:0} - .kcol{flex:0 0 var(--col);width:var(--col);display:flex;flex-direction:column;gap:8px;min-height:0} + .kcol{flex:1 1 0;min-width:var(--col-min);max-width:var(--col);display:flex;flex-direction:column;gap:8px;min-height:0} .kcol > h2{ margin:0;display:flex;align-items:center;gap:8px;padding:0 4px 2px; font-size:11px;font-weight:600;letter-spacing:.09em;text-transform:uppercase;color:var(--muted); @@ -116,7 +118,9 @@ .card.dragging{opacity:.4} .card.selected{border-color:var(--accent)} .card.running{border-color:var(--border)} - .card .toprow{display:flex;align-items:center;gap:8px;min-width:0;min-height:20px} + /* the row reserves the action buttons' 24px, so hovering swaps the pill + for actions without the card growing or anything below it shifting */ + .card .toprow{display:flex;align-items:center;gap:8px;min-width:0;min-height:24px} .card .mark{width:6px;height:6px;border-radius:2px;flex:none} .card .mark.breathing{animation:breathe 2.4s ease-in-out infinite} .card .ref{font-family:var(--mono);font-size:11.5px;color:var(--dim)} @@ -135,7 +139,7 @@ padding-top:9px;border-top:1px solid var(--line); } .chip2{ - display:inline-flex;align-items:center;gap:5px;flex:none; + position:relative;display:inline-flex;align-items:center;gap:5px;flex:none; padding:3px 9px;background:transparent; border:1px solid var(--border);border-radius:99px; font-family:var(--mono);font-size:11px;color:var(--muted); @@ -166,21 +170,44 @@ .well.bad{border-left:2px solid var(--alarm);border-radius:0 7px 7px 0} .well.bad .lead{color:var(--alarm)} .caret{color:var(--accent);animation:blink 1.1s step-end infinite} - /* hover actions take over the pill's slot — never stack on top of it */ - .hoveracts{display:none;gap:4px;animation:rise .12s ease} - .card:hover .hoveracts,.hoveracts:has(.armed){display:flex} + /* hover actions take over the pill's slot — never stack on top of it. + they fade in place (opacity only): the target must not travel while + the pointer approaches it. padding + negative margin widen the slot's + hitbox without moving anything; clicks landing there die at the slot. */ + .hoveracts{display:none;gap:4px;animation:fadein .12s ease;padding:4px;margin:-4px} + .card:hover .hoveracts,.hoveracts:has(.armed),.hoveracts:has(.busy){display:flex} .card.has-acts:hover .toprow .pill.status, .card.has-acts:hover .toprow .high, .card.has-acts:has(.hoveracts .armed) .toprow .pill.status, - .card.has-acts:has(.hoveracts .armed) .toprow .high{display:none} + .card.has-acts:has(.hoveracts .armed) .toprow .high, + .card.has-acts:has(.hoveracts .busy) .toprow .pill.status, + .card.has-acts:has(.hoveracts .busy) .toprow .high{display:none} .hoveracts button{ - display:flex;align-items:center;gap:4px;padding:2px 8px; + position:relative;display:flex;align-items:center;gap:4px; + padding:4px 10px;min-height:24px; background:var(--raised);border:1px solid var(--border);border-radius:99px; font-size:11px;color:var(--muted); } .hoveracts button:hover{border-color:var(--accent);color:var(--accent)} .hoveracts button .g{font-family:var(--mono);font-size:10px} - .hoveracts button.armed{color:var(--alarm);border-color:var(--alarm)} + /* every state's label occupies the same grid cell, so the button is + born as wide as its widest state and never reshapes under the cursor */ + .actlbl{display:inline-grid;justify-items:center} + .actlbl>span{grid-area:1/1;white-space:nowrap;visibility:hidden} + .actlbl .l-rest{visibility:visible} + button.armed .actlbl .l-rest,button.busy .actlbl .l-rest{visibility:hidden} + button.armed .actlbl .l-arm{visibility:visible} + button.busy .actlbl .l-busy{visibility:visible} + /* armed = alarm, and the disarm window drains visibly along the bottom */ + .hoveracts button.armed,button.chip2.armed{color:var(--alarm);border-color:var(--alarm)} + button.armed::after{ + content:"";position:absolute;left:9px;right:9px;bottom:2px;height:2px; + border-radius:99px;background:var(--alarm);transform-origin:left; + animation:drain 5s linear var(--arm-delay,0s) forwards; + } + /* busy = the request is away: breathe until the redraw or the timeout */ + .hoveracts button.busy,button.chip2.busy{color:var(--accent);border-color:var(--accent);cursor:default} + button.busy .g,button.busy .g2{animation:breathe 2.4s ease-in-out infinite} /* ── the activity bar: log drawer + latest line + archive tray ── */ #logpanel{ @@ -519,6 +546,7 @@ const S = { logStick: true, // follow the newest event unless the user scrolled up logOpen: localStorage.getItem('bench-log-open') === '1', logFilter: 'all', + acts: {}, // action key -> {phase: 'armed'|'busy', until} across re-renders dragging: null, // {file, from} while a card is mid-drag dockHot: false, // pointer over the bar with an archivable card dark: localStorage.getItem('bench-theme') @@ -799,11 +827,11 @@ function cardFor(task) { // two actions per state, max — whatever you'd actually do without opening the card const actions = []; - const stillTrue = { glyph: '◔', label: 'still true?', confirm: 'check it?', + const stillTrue = { glyph: '◔', label: 'still true?', confirm: 'check it?', busy: 'checking…', title: 'A read-only agent checks this task is still true of the codebase', run: () => fireAgent(task, '/api/agent/review') }; if (agent) { - actions.push({ glyph: '‖', label: 'hold', confirm: 'hold it?', + actions.push({ glyph: '‖', label: 'hold', confirm: 'hold it?', busy: 'holding…', title: 'Stop this agent — nothing is lost', run: () => stopAgent(agent.id) }); } else if (task.stage === 'review' && task.pr) { @@ -811,28 +839,28 @@ function cardFor(task) { const reviewIn = !!task.prVerdict || (prState2 && (prState2.verdict !== 'pending' || (prState2.copilot && prState2.copilot !== 'asked'))); - const reviewPR = { glyph: '◔', label: 'review PR', confirm: 'review it?', + const reviewPR = { glyph: '◔', label: 'review PR', confirm: 'review it?', busy: 'reviewing…', title: 'A read-only agent reviews the PR and posts its verdict to GitHub', run: () => fireAgent(task, '/api/agent/review-pr') }; if (reviewIn) { - actions.push({ glyph: '↻', label: 'act on PR', confirm: 'act on it?', + actions.push({ glyph: '↻', label: 'act on PR', confirm: 'act on it?', busy: 'acting…', title: 'An agent addresses the review feedback in the worktree, commits and pushes', run: () => fireAgent(task, '/api/agent/act-pr') }, reviewPR); } else { - actions.push(reviewPR, { glyph: '⚑', label: 'copilot', confirm: 'ask copilot?', + actions.push(reviewPR, { glyph: '⚑', label: 'copilot', confirm: 'ask copilot?', busy: 'asking…', title: 'Request a GitHub Copilot review on the PR', run: () => askCopilot(task) }); } } else { if (task.stage === 'in-progress') { - actions.push({ glyph: '▸', label: 'start work', confirm: 'start it?', + actions.push({ glyph: '▸', label: 'start work', confirm: 'start it?', busy: 'starting…', title: 'A worktree, a branch, and a headless Claude on this task', run: () => fireAgent(task, '/api/agent/start') }); } else if (task.stage === 'review') { - actions.push({ glyph: '↩', label: 'back', title: 'Send it back for more work', + actions.push({ glyph: '↩', label: 'back', busy: 'moving…', title: 'Send it back for more work', run: () => move(task.file, 'review', 'in-progress') }); } else if (task.stage === 'done') { - actions.push({ glyph: '↺', label: 'reopen', title: 'Put it back in the queue', + actions.push({ glyph: '↺', label: 'reopen', busy: 'reopening…', title: 'Put it back in the queue', run: () => move(task.file, 'done', 'to-do') }); } actions.push(stillTrue); @@ -881,6 +909,10 @@ function cardFor(task) { chips.push({ label: 'CI', glyph: { pass: '✓', fail: '✕', running: '◌' }[prState.ci], cls: { pass: 'ok', fail: 'bad', running: 'accent' }[prState.ci], title: detail }); } + if (prState && prState.conflicts) { + chips.push({ label: 'conflicts', glyph: '✕', cls: 'bad', + title: 'GitHub cannot merge this into main — ↻ act on PR can attempt a resolution merge' }); + } if (prState && prState.copilot) { chips.push({ label: 'copilot', glyph: { asked: '◌', approved: '✓', changes: '✕', commented: '·' }[prState.copilot], @@ -936,7 +968,7 @@ function cardFor(task) { const g = c.glyph ? `${c.glyph}` : ''; if (c.href) return `${esc(c.label)}${g}`; if (c.act) return ``; - if (c.cmd) return ``; + if (c.cmd) return ``; return `${esc(c.label)}${g}`; }).join('') + '' : ''; @@ -954,35 +986,26 @@ function cardFor(task) { e.stopPropagation(); if (btn.dataset.drive === 'go') startDrive(task); else parkDrive(); })); - el.querySelectorAll('[data-cmd]').forEach(btn => - btn.addEventListener('click', (e) => { - e.stopPropagation(); - const name = btn.dataset.cmd; - if (btn.classList.contains('armed')) runCommand(task, name); - else { - btn.classList.add('armed'); - btn.firstChild.textContent = 'run it?'; - setTimeout(() => { btn.classList.remove('armed'); btn.firstChild.textContent = name; }, 3500); - } - })); + el.querySelectorAll('[data-cmd]').forEach(btn => { + btn.addEventListener('click', (e) => e.stopPropagation()); + const name = btn.dataset.cmd; + wireAction(btn, `${task.file}::$${name}`, { + label: name, confirm: 'run it?', busy: 'running…', + run: () => runCommand(task, name), + }); + }); if (actions.length) { const slot = document.createElement('span'); slot.className = 'hoveracts'; + // one guard for the whole slot: clicks in its padding or in the gap + // between buttons die here instead of opening the card's detail + slot.addEventListener('click', (e) => e.stopPropagation()); for (const act of actions) { const btn = document.createElement('button'); - btn.innerHTML = `${act.glyph}${act.label}`; + btn.innerHTML = `${act.glyph}${actLabel(act.label, act.confirm, act.busy)}`; btn.title = act.title; - btn.addEventListener('click', (e) => { - e.stopPropagation(); - if (!act.confirm || btn.classList.contains('armed')) { act.run(); return; } - btn.classList.add('armed'); - btn.lastElementChild.textContent = act.confirm; - setTimeout(() => { - btn.classList.remove('armed'); - btn.lastElementChild.textContent = act.label; - }, 3500); - }); + wireAction(btn, `${task.file}::${act.label}`, act); slot.appendChild(btn); } el.querySelector('.toprow').appendChild(slot); @@ -1005,6 +1028,78 @@ function cardFor(task) { return el; } +/* arm-then-fire is the contract for anything that costs tokens or stops + work. This one state machine walks every action button — hover actions + and $-command chips alike — through rest → armed → busy. The truth + lives in S.acts, keyed by task file + action, because cards are torn + down and rebuilt on every SSE render: a rebuild mid-window re-applies + the same picture (the drain bar resumes via a negative delay), and a + second click keeps its meaning across the redraw. */ +const ARM_MS = 5000, FIRE_TIMEOUT_MS = 15000; + +function actLabel(rest, confirm, busy) { + return `${esc(rest)}` + + (confirm ? `${esc(confirm)}` : '') + + `${esc(busy || rest)}`; +} + +function wireAction(btn, key, act) { + const st = S.acts[key]; + if (st && st.until > Date.now()) { + if (st.phase === 'busy') lockAction(btn); + else armAction(btn, key, st.until - Date.now()); + } else if (st) delete S.acts[key]; + btn.addEventListener('click', () => { + if (btn.disabled) return; + if (act.confirm && !btn.classList.contains('armed')) armAction(btn, key, ARM_MS); + else fireAction(btn, key, act); + }); +} + +function armAction(btn, key, remaining) { + S.acts[key] = { phase: 'armed', until: Date.now() + remaining }; + // the CSS drain animation is ARM_MS long; a rebuilt button rejoins it + // partway through with a negative delay instead of starting over + btn.style.setProperty('--arm-delay', `${remaining - ARM_MS}ms`); + btn.classList.add('armed'); + setTimeout(() => { + if ((S.acts[key] || {}).phase === 'armed') delete S.acts[key]; + btn.classList.remove('armed'); + }, remaining); +} + +function lockAction(btn) { + btn.classList.remove('armed'); + btn.classList.add('busy'); + btn.disabled = true; +} + +async function fireAction(btn, key, act) { + lockAction(btn); + S.acts[key] = { phase: 'busy', until: Date.now() + FIRE_TIMEOUT_MS }; + // client-side optimism needs an honest exit: if neither the response + // nor an SSE redraw arrives, come back to rest loudly — never silently + const bail = setTimeout(() => { + if ((S.acts[key] || {}).phase !== 'busy') return; + delete S.acts[key]; + toast(`${act.label} got no answer in ${FIRE_TIMEOUT_MS / 1000}s — not fired again; check the board`, true); + scheduleRender(); + }, FIRE_TIMEOUT_MS); + let ok; + try { ok = await act.run() !== false; } + catch (e) { ok = false; toast(`${act.label} failed — ${e.message || 'no response'}`, true); } + clearTimeout(bail); + if ((S.acts[key] || {}).phase === 'busy') { + delete S.acts[key]; + btn.disabled = false; + btn.classList.remove('busy'); + // a successful run usually re-rendered the card already (loadState); + // this pass unlocks any survivor whose card did not change + scheduleRender(); + } + return ok; +} + async function fireAgent(task, url) { toast(`starting on ${task.file}…`); const res = await fetch(url, { @@ -1012,7 +1107,7 @@ async function fireAgent(task, url) { body: JSON.stringify({ file: task.file, stage: task.stage }), }); const data = await res.json(); - if (!res.ok) { toast(data.error || 'that did not start', true); return; } + if (!res.ok) { toast(data.error || 'that did not start', true); return false; } const who = data.agent.name || 'An agent'; toast(url.endsWith('act-pr') ? `${who} is acting on the review of ${task.file}'s PR` @@ -1022,6 +1117,7 @@ async function fireAgent(task, url) { ? `${who} is checking ${task.file} is still true of the codebase` : `${who} is on it — branch ${data.agent.branch}`); await loadState(); + return true; } async function runCommand(task, name) { @@ -1032,7 +1128,8 @@ async function runCommand(task, name) { const data = await res.json(); toast(res.ok ? `${name} running on ${task.file} — the ticker narrates the ending` : (data.error || `${name} did not start`), !res.ok); - loadState(); + await loadState(); + return res.ok; } async function startDrive(task) { @@ -1061,6 +1158,7 @@ async function askCopilot(task) { const data = await res.json(); toast(res.ok ? `Copilot asked to review ${task.file}'s PR` : (data.error || 'Copilot request failed'), !res.ok); + return res.ok; } async function move(file, from, to) { @@ -1070,10 +1168,10 @@ async function move(file, from, to) { const stem = file.replace(/\.md$/, ''); if (task && (task.pr || (S.state.branches || []).includes(stem))) { completeSheet(task, from); - return; + return true; } } - await rawMove(file, from, to); + return rawMove(file, from, to); } async function rawMove(file, from, to) { @@ -1083,10 +1181,11 @@ async function rawMove(file, from, to) { body: JSON.stringify({ file, from, to }), }); const data = await res.json(); - if (!res.ok) { toast(data.error || 'move failed', true); await loadState(); return; } + if (!res.ok) { toast(data.error || 'move failed', true); await loadState(); return false; } toast(`${file} → ${to}/`); if (S.selected && S.selected.file === file) S.selected = data.task; await loadState(); + return true; } function closeSheet() { $('#sheetwrap').classList.remove('open'); $('#sheetwrap').innerHTML = ''; } @@ -1300,12 +1399,15 @@ function renderFlight() { const stopBtn = agent && agent.status === 'running' ? `` : ''; const branch = agent && agent.branch ? ` · ${esc(agent.branch)}` : ''; + // Honesty about what the run actually rode: the configured model, or + // the vendor default it inherited. Interactive sessions say nothing. + const model = agent ? ` · ${agent.model ? esc(agent.model) : 'model inherited'}` : ''; $('#fsession').innerHTML = `
${esc((meta.label || sid).split(' · ')[0])}` + `${esc(sid.slice(0, 8))}
` + `
${meta.task ? 'on ' + esc(meta.task) + ' · ' : ''}` + `started ${fmtShort(meta.started)} · ${meta.count || 0} events · ` + - `${files.size} files edited · ${checks} check runs${branch}
` + + `${files.size} files edited · ${checks} check runs${branch}${model}` + stopBtn + spark(events, meta); const stop = $('#stopagent'); if (stop) stop.addEventListener('click', () => stopAgent(stop.dataset.aid)); @@ -1325,7 +1427,8 @@ async function stopAgent(aid) { }); const data = await res.json(); toast(res.ok ? 'Held — nothing is lost' : (data.error || 'could not stop it'), !res.ok); - loadState(); + await loadState(); + return res.ok; } function spark(events, meta) { @@ -1490,6 +1593,7 @@ function renderFocus() { refBits.push(task.number ? '#' + esc(task.number) : esc(task.file)); refBits.push(`${esc((meta.label || '').split(' · ')[0])}`); if (agent && agent.branch) refBits.push('worktree ' + esc(agent.branch)); + if (agent) refBits.push(agent.model ? esc(agent.model) : 'model inherited'); refBits.push(esc(task.stage) + '/' + esc(task.file)); } else { refBits.push(esc(sid.slice(0, 8)), 'no task attached'); diff --git a/manager/core/board.py b/manager/core/board.py index b923718..eae7286 100644 --- a/manager/core/board.py +++ b/manager/core/board.py @@ -6,7 +6,7 @@ The manager sits cleanly on top of the tasks/ directory: it reads and moves task files, but the tasks work as a plain folder kanban without it. See -../CLAUDE.md for the workflow and the module map: +../AGENTS.md for the workflow and the module map: config.py paths, stages, launch configuration state.py shared registries, event persistence, SSE fan-out diff --git a/manager/core/config.py b/manager/core/config.py index 8d735da..e23d103 100644 --- a/manager/core/config.py +++ b/manager/core/config.py @@ -95,12 +95,35 @@ ADAPTER = setting("BOARD_AGENT_ADAPTER", "claude") # the adapter's own knowledge; this list is the project's half. AGENT_COMMANDS = setting("BOARD_AGENT_COMMANDS", "python3 -m unittest") +# Model per launch intent — an opaque vendor-native name core passes to the +# adapter untranslated (what names mean anything is vendor knowledge). Empty +# = inherit the vendor's own default, exactly today's behaviour. A per-intent +# setting beats the general one; review covers PR reviews and relevance +# checks (they share the review intent). +AGENT_MODEL = setting("BOARD_AGENT_MODEL", "") +AGENT_MODELS = { + "work": setting("BOARD_AGENT_MODEL_WORK", ""), + "act-pr": setting("BOARD_AGENT_MODEL_ACT_PR", ""), + "review": setting("BOARD_AGENT_MODEL_REVIEW", ""), +} + + +def agent_model(mode: str) -> str: + """The model one launch intent rides — '' means inherit.""" + return AGENT_MODELS.get(mode, "") or AGENT_MODEL + + # GitHub plumbing: the gh CLI (stub-able for tests) and the git remote PRs # go to. Empty remote = auto-detect the first remote; no remote = no PRs. GH_BIN = setting("BOARD_GH_BIN", "gh") GIT_REMOTE = setting("BOARD_GIT_REMOTE", "") PR_POLL_INTERVAL = float(setting("BOARD_PR_POLL_INTERVAL", "60")) +# How long a work-agent launch waits for `git fetch origin main` before +# branching from local HEAD instead. Launching must never be blocked by +# network weather; this bounds the whole delay. +FETCH_TIMEOUT = float(setting("BOARD_FETCH_TIMEOUT", "10")) + WATCH_INTERVAL = float(setting("BOARD_WATCH_INTERVAL", "2")) EVENTS_CAP = int(setting("BOARD_EVENTS_CAP", "800")) BOARD_EVENTS_CAP = int(setting("BOARD_HISTORY_CAP", "300")) diff --git a/manager/core/github.py b/manager/core/github.py index ad44e03..b3c80c9 100644 --- a/manager/core/github.py +++ b/manager/core/github.py @@ -191,16 +191,21 @@ def _is_copilot(login) -> bool: return "copilot" in str(login or "").lower() -def _poll_pr(filename: str, url: str) -> None: - number = url.rstrip("/").rsplit("/", 1)[-1] - result = _run([config.GH_BIN, "pr", "view", number, - "--json", "reviews,reviewRequests,statusCheckRollup,state"], timeout=60) - if result.returncode != 0: - return - try: - data = json.loads(result.stdout) - except json.JSONDecodeError: - return +def _conflict_state(data: dict, prev: dict) -> bool | None: + """GitHub computes mergeability lazily: UNKNOWN means "not computed + yet", never "fine" — keep the previous reading so the chip does not + flap while GitHub thinks.""" + mergeable = str(data.get("mergeable") or "").upper() + if mergeable == "CONFLICTING": + return True + if mergeable == "MERGEABLE": + return False + return prev.get("conflicts") + + +def _fold(data: dict, prev: dict) -> dict: + """One gh pr-view payload + the previous snapshot → the new snapshot. + Pure fold: fetching, events and broadcasts stay in _poll_pr.""" reviews = data.get("reviews") or [] checks = data.get("statusCheckRollup") or [] changes = any(r.get("state") == "CHANGES_REQUESTED" for r in reviews) @@ -219,15 +224,18 @@ def _poll_pr(filename: str, url: str) -> None: elif cop_requested: copilot = "asked" else: - copilot = PR_STATE.get(filename, {}).get("copilot") - copilot = "asked" if copilot == "asked" else None + copilot = "asked" if prev.get("copilot") == "asked" else None states = [_check_state(c) for c in checks] ci = ("fail" if "fail" in states else "running" if "running" in states else "pass" if states else None) - verdict = ("red" if (changes or ci == "fail") + conflicts = _conflict_state(data, prev) + + # A conflict is changes-needed-by-you, not a CI failure: it beats any + # approval but leaves the CI chip telling its own story. + verdict = ("red" if (changes or ci == "fail" or conflicts) else "green" if approved else "pending") detail_bits = [] if reviews: @@ -235,14 +243,32 @@ def _poll_pr(filename: str, url: str) -> None: if ci: detail_bits.append({"fail": "checks failing", "running": "checks running", "pass": "checks ok"}[ci]) + if conflicts: + detail_bits.append("conflicts with main") if copilot: detail_bits.append("copilot " + {"asked": "asked", "approved": "approved", "changes": "asked for changes", "commented": "commented"}[copilot]) + return {"verdict": verdict, "ci": ci, "copilot": copilot, + "conflicts": conflicts, "detail": " · ".join(detail_bits)} + + +def _poll_pr(filename: str, url: str) -> None: + number = url.rstrip("/").rsplit("/", 1)[-1] + result = _run([config.GH_BIN, "pr", "view", number, + "--json", "reviews,reviewRequests,statusCheckRollup,state,mergeable"], + timeout=60) + if result.returncode != 0: + return + try: + data = json.loads(result.stdout) + except json.JSONDecodeError: + return prev = PR_STATE.get(filename, {}) - PR_STATE[filename] = {"verdict": verdict, "ci": ci, "copilot": copilot, - "url": url, "detail": " · ".join(detail_bits), - "ts": time.time()} + entry = _fold(data, prev) + entry.update({"url": url, "ts": time.time()}) + PR_STATE[filename] = entry + verdict, ci, copilot = entry["verdict"], entry["ci"], entry["copilot"] if prev.get("copilot") in (None, "asked") and copilot in ("approved", "changes", "commented"): word = {"approved": "approved it", "changes": "asked for changes", "commented": "commented"}[copilot] @@ -250,7 +276,14 @@ def _poll_pr(filename: str, url: str) -> None: "kind": "agent", "actor": "board", "file": filename, "summary": f"Copilot reviewed {filename}'s PR and {word}"}) state.broadcast({"type": "board"}) - if prev.get("verdict") != verdict and verdict != "pending": + if bool(prev.get("conflicts")) != bool(entry["conflicts"]): + state.record_board_event({ + "kind": "agent", "actor": "board", "file": filename, + "summary": (f"{filename}'s PR conflicts with main — ↻ act on PR " + f"can attempt the resolution" if entry["conflicts"] + else f"{filename}'s PR no longer conflicts with main")}) + state.broadcast({"type": "board"}) + elif prev.get("verdict") != verdict and verdict != "pending": word = "approved" if verdict == "green" else "changes asked" state.record_board_event({ "kind": "agent", "actor": "board", "file": filename, @@ -283,7 +316,7 @@ def poller() -> None: def public_state() -> dict: - return {f: {k: v.get(k) for k in ("verdict", "ci", "copilot", "detail", "url")} + return {f: {k: v.get(k) for k in ("verdict", "ci", "copilot", "conflicts", "detail", "url")} for f, v in PR_STATE.items()} diff --git a/manager/core/prompts/act-pr.md b/manager/core/prompts/act-pr.md index e2b3d2d..4a8e6dd 100644 --- a/manager/core/prompts/act-pr.md +++ b/manager/core/prompts/act-pr.md @@ -14,7 +14,24 @@ Do this properly: repos/{{owner}}/{{repo}}/pulls//comments`. - Address each point in the code. If you disagree with a point, do not silently ignore it — leave it unchanged and say why in your summary. -- Follow repo CLAUDE.md: layering rules, definition of done. Run the tests +- If the PR conflicts with main (check `gh pr view {branch} --json + mergeable` — CONFLICTING means yes), resolve it mechanically: + - `git fetch origin main`, then `git merge origin/main` in this + worktree. Never rebase and never force-push: the branch is public, + so the resolution must be additive. + - Resolve each conflicted file honouring both sides' intent, and run + the project's tests until they pass. + - Put the resolution in its own commit — never folded into other + changes — with a message naming the conflicted files and the choice + made in each. + - Cover the resolution explicitly in your closing report: which files + conflicted and what you chose. + - If both intents cannot hold at once — main has made this branch's + premise false — resolve nothing: run `git merge --abort`, leave the + branch as it was, and state in your report that a human must + decide, naming the specific collision. Guessing at a semantic + conflict is the one forbidden move. +- Follow repo AGENTS.md: layering rules, definition of done. Run the tests that cover what you changed until they pass. - Commit in clear, reviewable commits and push the branch (`git push`) so the PR updates. diff --git a/manager/core/prompts/review-pr.md b/manager/core/prompts/review-pr.md index b191e2e..43abdb2 100644 --- a/manager/core/prompts/review-pr.md +++ b/manager/core/prompts/review-pr.md @@ -14,7 +14,7 @@ Review the PR properly: context, not in isolation. - Check the work against the task: does it do what the task asked? Is anything missing, wrong, or beyond scope? -- Check it against CLAUDE.md at the repo root: layering rules, definition of +- Check it against AGENTS.md at the repo root: layering rules, definition of done, testing expectations. You are read-only on the working tree: make NO edits, NO commits, move diff --git a/manager/core/prompts/review.md b/manager/core/prompts/review.md index 85a43c1..7a2c1f1 100644 --- a/manager/core/prompts/review.md +++ b/manager/core/prompts/review.md @@ -14,7 +14,7 @@ written? Specifically: written (renamed modules, replaced approaches, merged tasks)? - Is anything in it now wrong or misleading? -You are read-only: make NO edits, NO commits, move nothing. Read CLAUDE.md +You are read-only: make NO edits, NO commits, move nothing. Read AGENTS.md and the code; run read-only commands (grep, git log) as needed. End with a report whose FIRST line is exactly diff --git a/manager/core/prompts/work.md b/manager/core/prompts/work.md index e629a20..80421ac 100644 --- a/manager/core/prompts/work.md +++ b/manager/core/prompts/work.md @@ -4,7 +4,7 @@ You are in an isolated git worktree on branch `{branch}` created for this task. All your work happens here: commit to this branch, do not push, do not merge, and do not switch branches. -Read CLAUDE.md at the repo root first and follow it, including its +Read AGENTS.md at the repo root first and follow it, including its definition of done — run whatever checks it names until they pass. The task is `{filename}`. Its content: @@ -24,7 +24,7 @@ NOT READY: followed by a bullet list of the specific questions that block the task. The board treats that marker as "send the task back for refinement". Only questions that change what should be built count — implementation details -you can decide yourself by reading the codebase and CLAUDE.md do not. +you can decide yourself by reading the codebase and AGENTS.md do not. Rules: - Do NOT move, rename or edit the task file itself — the board manages its diff --git a/manager/core/release-manifest b/manager/core/release-manifest index e855dd1..7c8548d 100644 --- a/manager/core/release-manifest +++ b/manager/core/release-manifest @@ -20,8 +20,10 @@ # # Anything not listed here does not ship: bench's own task cards, its # manager/local/ content, local/state/, .claude/, tests/, release.sh. +copy AGENTS.md copy CLAUDE.md copy README.md +copy LICENSE copy install.py copy start.sh copy stop.sh @@ -41,4 +43,5 @@ keep manager/local/adapters keep manager/local/commands keep manager/local/driver keep manager/local/prompts +seed manager/local/AGENTS.md seed manager/local/CLAUDE.md diff --git a/manager/core/taskfiles.py b/manager/core/taskfiles.py index 5ca5866..0a1dd29 100644 --- a/manager/core/taskfiles.py +++ b/manager/core/taskfiles.py @@ -1,6 +1,6 @@ """Reading and moving task files — the only module that touches tasks/. -The directory a task file sits in *is* its status (see ../CLAUDE.md). Nothing +The directory a task file sits in *is* its status (see ../AGENTS.md). Nothing here knows about agents or HTTP; it is the same folder kanban you could drive by hand with mv. """ diff --git a/manager/local/AGENTS.md b/manager/local/AGENTS.md new file mode 100644 index 0000000..5df2766 --- /dev/null +++ b/manager/local/AGENTS.md @@ -0,0 +1,9 @@ +# Project-specific workflow notes + +This file is yours — updates never touch manager/local/. Put here what an +agent or teammate needs that the core doc cannot know: post-merge chores, +what the driver assumes, what each local command is for. + +Bench's definition of done is `python3 -m unittest` and nothing else; the +`checks` file beside this one is what the Focus view's CHECKS panel shows +for this project. diff --git a/manager/local/CLAUDE.md b/manager/local/CLAUDE.md index 5df2766..1ab24d8 100644 --- a/manager/local/CLAUDE.md +++ b/manager/local/CLAUDE.md @@ -1,9 +1,5 @@ -# Project-specific workflow notes + -This file is yours — updates never touch manager/local/. Put here what an -agent or teammate needs that the core doc cannot know: post-merge chores, -what the driver assumes, what each local command is for. - -Bench's definition of done is `python3 -m unittest` and nothing else; the -`checks` file beside this one is what the Focus view's CHECKS panel shows -for this project. +@AGENTS.md diff --git a/release.sh b/release.sh index a4ef025..2321778 100755 --- a/release.sh +++ b/release.sh @@ -22,9 +22,11 @@ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" MANIFEST="$ROOT/manager/core/release-manifest" ASSET="bench.tar.gz" -# The starter manager/local/CLAUDE.md a fresh install unpacks — bench's -# own repo has its real one, so this is generated, never copied. -seed_local_claude_md() { +# The starter manager/local/ notes a fresh install unpacks — bench's own +# repo has its real ones, so these are generated, never copied. AGENTS.md +# is the content; CLAUDE.md is the compatibility pointer, mirroring the +# root pair (task 13). +seed_local_agents_md() { cat <<'MD' # Project-specific workflow notes @@ -38,6 +40,16 @@ replaces core/checks as the Focus view's definition-of-done panel. MD } +seed_local_claude_md() { + cat <<'MD' + + +@AGENTS.md +MD +} + # origin's URL as "owner/repo" — what gets stamped into the shipped # update.sh so a fresh install can update with zero configuration. source_repo() { @@ -79,6 +91,10 @@ build_tarball() { # build_tarball ;; seed) case "$path" in + manager/local/AGENTS.md) + mkdir -p "$stage/$(dirname "$path")" + seed_local_agents_md > "$stage/$path" + ;; manager/local/CLAUDE.md) mkdir -p "$stage/$(dirname "$path")" seed_local_claude_md > "$stage/$path" diff --git a/tasks/task-template.md b/tasks/task-template.md index d17a9db..af1b870 100644 --- a/tasks/task-template.md +++ b/tasks/task-template.md @@ -8,7 +8,7 @@ Anything below marked *optional* is deletable, and deleting beats leaving it hollow — an empty boilerplate section reads as thinking that never happened. Board process (review, PR, CI, merge) and the project-wide definition of done stay off the card: the board does the former -mechanically and the repo CLAUDE.md owns the latter. +mechanically and the repo AGENTS.md owns the latter. This template lives in tasks/, which updates never touch — improvements to it ship only with fresh installs, so local edits are yours to keep. @@ -33,13 +33,13 @@ tasks (`../done/...`), plan files (`../../plans/...`) and reference documents (`../../reference/...`) — a link outlives a summary. **Affected areas:** the modules or layers this touches, one line in the -repo CLAUDE.md's module-map vocabulary — telling reviewers where to look +repo AGENTS.md's module-map vocabulary — telling reviewers where to look and agents where to stop. Optional: delete when the title already says it. ## What to build The work itself, concrete enough to start on. Name the layers things belong -in — the repo CLAUDE.md's dependency rules decide where code goes, not +in — the repo AGENTS.md's dependency rules decide where code goes, not convenience. - First piece diff --git a/tests/test_adapter_permissions.py b/tests/test_adapter_permissions.py index 69bb9b1..08ab969 100644 --- a/tests/test_adapter_permissions.py +++ b/tests/test_adapter_permissions.py @@ -53,6 +53,29 @@ class ClaudeAllowRules(unittest.TestCase): for prefix in ["git push", "gh pr view", "gh pr diff", "gh api"]: self.assertIn(f"Bash({prefix}:*)", rules) + def test_act_pr_resolves_conflicts_additively(self): + # Conflicted PRs: merging main into the branch is allowed; history + # rewriting is not, and not merely by omission — `git push` alone + # would cover the force spellings, so they are denied outright. + rules = hook_settings.allow_rules("act-pr", COMMANDS) + for prefix in ["git fetch", "git merge"]: + self.assertIn(f"Bash({prefix})", rules) + self.assertIn(f"Bash({prefix}:*)", rules) + joined = " ".join(rules) + self.assertNotIn("git rebase", joined) + self.assertNotIn("--force", joined) + deny = hook_settings.settings("act-pr", COMMANDS)["permissions"]["deny"] + for prefix in ["git push --force", "git push -f", "git rebase"]: + self.assertIn(f"Bash({prefix}:*)", deny) + + def test_only_act_pr_may_fetch_and_merge(self): + for mode in ("work", "review"): + joined = " ".join(hook_settings.allow_rules(mode, COMMANDS)) + self.assertNotIn("git fetch", joined) + self.assertNotIn("git merge", joined) + self.assertNotIn("deny", + hook_settings.settings(mode, COMMANDS)["permissions"]) + def test_review_posts_verdicts_but_writes_nothing_locally(self): rules = hook_settings.allow_rules("review", COMMANDS) for prefix in ["gh pr review", "gh pr comment", "gh pr view", @@ -90,6 +113,26 @@ class OpencodeConfig(unittest.TestCase): self.assertEqual(bash["git push *"], "allow") self.assertEqual(bash["gh pr view *"], "allow") + def test_act_pr_resolves_conflicts_additively(self): + bash = permission_config.build_config("act-pr", COMMANDS)["permission"]["bash"] + for prefix in ["git fetch", "git merge"]: + self.assertEqual(bash[prefix], "allow") + self.assertEqual(bash[f"{prefix} *"], "allow") + for pattern in ["git rebase", "git rebase *", "git push --force*", + "git push * --force*", "git push -f", "git push -f *", + "git push * -f *"]: + self.assertEqual(bash[pattern], "deny") + # last match wins: the denies must come after the push allow + keys = list(bash) + self.assertGreater(keys.index("git push --force*"), + keys.index("git push *")) + + def test_only_act_pr_may_fetch_and_merge(self): + for mode in ("work", "review"): + bash = permission_config.build_config(mode, COMMANDS)["permission"]["bash"] + for absent in ["git fetch", "git merge", "git rebase"]: + self.assertNotIn(absent, bash) # unlisted = denied by "*" + def test_review_cannot_edit_and_bash_default_denies(self): config = permission_config.build_config("review", COMMANDS) self.assertEqual(config["permission"]["edit"], "deny") diff --git a/tests/test_agent_model.py b/tests/test_agent_model.py new file mode 100644 index 0000000..a7c5bfe --- /dev/null +++ b/tests/test_agent_model.py @@ -0,0 +1,239 @@ +"""Choosing agent models per launch intent (task 12): the BOARD_AGENT_MODEL +settings resolve intent → model in config, travel to the adapter as +AGENT_MODEL (absent when empty — never an empty flag value), and each +adapter renders the opaque name natively. With nothing set, launches are +byte-identical to the inherit-everything behaviour. + +The `run` scripts are exercised end-to-end against stub binaries +(BOARD_CLAUDE_BIN / BOARD_OPENCODE_BIN), the same seam a live board uses. +Run with: python3 -m unittest discover -s tests +""" + +import importlib.util +import json +import os +import stat +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +REPO = Path(__file__).resolve().parents[1] +CORE = REPO / "manager" / "core" +CLAUDE = CORE / "adapters" / "claude" +OPENCODE = CORE / "adapters" / "opencode" + +sys.path.insert(0, str(CORE)) + +import agents # noqa: E402 +import config # noqa: E402 + + +def _load(name, path): + spec = importlib.util.spec_from_file_location(name, path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +permission_config = _load("permission_config", OPENCODE / "permission_config.py") + +# Neutralize any local/.env or shell leakage: process env beats .env, and +# an empty value is exactly "nothing configured". +UNSET = {"BOARD_AGENT_MODEL": "", "BOARD_AGENT_MODEL_WORK": "", + "BOARD_AGENT_MODEL_ACT_PR": "", "BOARD_AGENT_MODEL_REVIEW": ""} + + +def _resolve(settings: dict) -> dict: + """config.agent_model per intent, in a fresh interpreter so the given + settings are what config reads at import.""" + env = dict(os.environ) + env.update(UNSET) + env.update(settings) + out = subprocess.check_output( + [sys.executable, "-c", + "import sys; sys.path.insert(0, sys.argv[1]); import config, json; " + "print(json.dumps({m: config.agent_model(m) " + "for m in ('work', 'act-pr', 'review')}))", + str(CORE)], + env=env, text=True) + return json.loads(out) + + +class ModelResolution(unittest.TestCase): + def test_nothing_set_means_inherit_for_every_intent(self): + self.assertEqual(_resolve({}), + {"work": "", "act-pr": "", "review": ""}) + + def test_the_general_setting_covers_all_intents(self): + self.assertEqual(_resolve({"BOARD_AGENT_MODEL": "vendor-x"}), + {"work": "vendor-x", "act-pr": "vendor-x", + "review": "vendor-x"}) + + def test_a_per_intent_setting_beats_the_general_one_for_that_intent_only(self): + resolved = _resolve({"BOARD_AGENT_MODEL": "big", + "BOARD_AGENT_MODEL_REVIEW": "cheap"}) + self.assertEqual(resolved, + {"work": "big", "act-pr": "big", "review": "cheap"}) + + def test_per_intent_alone_leaves_the_others_inheriting(self): + resolved = _resolve({"BOARD_AGENT_MODEL_ACT_PR": "pusher"}) + self.assertEqual(resolved, + {"work": "", "act-pr": "pusher", "review": ""}) + + +class LaunchEnv(unittest.TestCase): + """_launch's half of the contract: AGENT_MODEL set iff a model is + configured — absent means absent, even against a leaky environment.""" + + def setUp(self): + self._tmp = tempfile.TemporaryDirectory() + tmp = Path(self._tmp.name) + self.capture = tmp / "env.json" + adapter = tmp / "stub-adapter" + adapter.mkdir() + run = adapter / "run" + run.write_text("#!/usr/bin/env python3\n" + "import json, os\n" + f"open({str(self.capture)!r}, 'w').write(json.dumps(dict(os.environ)))\n") + run.chmod(run.stat().st_mode | stat.S_IXUSR) + self._saved = (config.AGENT_MODEL, config.AGENT_MODELS, + config.adapter_dir, config.child_env) + config.adapter_dir = lambda: adapter + # A stray AGENT_MODEL inherited by the board process must not leak. + config.child_env = lambda: {"PATH": os.environ.get("PATH", ""), + "AGENT_MODEL": "stray-from-the-shell"} + + def tearDown(self): + (config.AGENT_MODEL, config.AGENT_MODELS, + config.adapter_dir, config.child_env) = self._saved + self._tmp.cleanup() + + def _launch_env(self, mode: str) -> tuple[dict, str]: + log = Path(self._tmp.name) / "job.log" + proc, log_file, model = agents._launch( + mode, "do the task", Path(self._tmp.name), "id-1", "t.md", log) + proc.wait() + log_file.close() + return json.loads(self.capture.read_text()), model + + def test_no_model_configured_means_no_variable_at_all(self): + config.AGENT_MODEL = "" + config.AGENT_MODELS = {"work": "", "act-pr": "", "review": ""} + env, model = self._launch_env("work") + self.assertNotIn("AGENT_MODEL", env) + self.assertEqual(model, "") + + def test_the_resolved_model_arrives_as_agent_model(self): + config.AGENT_MODEL = "big" + config.AGENT_MODELS = {"work": "", "act-pr": "", "review": "cheap"} + env, model = self._launch_env("review") + self.assertEqual(env["AGENT_MODEL"], "cheap") + self.assertEqual(model, "cheap") + env, model = self._launch_env("work") + self.assertEqual(env["AGENT_MODEL"], "big") + self.assertEqual(model, "big") + + +class AgentRecord(unittest.TestCase): + def test_public_record_carries_the_model_none_means_inherited(self): + base = {"id": "a", "task": "t.md", "branch": None, "worktree": None, + "status": "running", "rc": None, "started": 0.0, + "session": None, "mode": "review", "name": "Wren"} + self.assertIsNone(agents._agent_public(base)["model"]) + self.assertEqual( + agents._agent_public({**base, "model": "cheap"})["model"], "cheap") + + +def _write_stub(directory: Path, name: str, script: str) -> Path: + stub = directory / name + stub.write_text(script, encoding="utf-8") + stub.chmod(stub.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) + return stub + + +class ClaudeRunModel(unittest.TestCase): + """AGENT_MODEL reaches the claude launch as --model; absent (or empty, + which must never happen but costs nothing to survive) = no flag.""" + + def _run(self, model: str | None) -> list[str]: + with tempfile.TemporaryDirectory() as tmp: + capture = Path(tmp) / "args.json" + stub = _write_stub(Path(tmp), "claude-stub", + "#!/usr/bin/env python3\n" + "import json, sys\n" + f"open({str(capture)!r}, 'w').write(json.dumps(sys.argv[1:]))\n") + wrapper = _write_stub(Path(tmp), "bin", + f"#!/usr/bin/env bash\nexec python3 {stub} \"$@\"\n") + env = dict(os.environ) + env.pop("AGENT_MODEL", None) + env.update({"BOARD_CLAUDE_BIN": str(wrapper), + "AGENT_PROMPT": "do the task", "AGENT_MODE": "work", + "AGENT_COMMANDS": "python3 -m unittest"}) + if model is not None: + env["AGENT_MODEL"] = model + result = subprocess.run(["bash", str(CLAUDE / "run")], env=env, + capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stderr) + return json.loads(capture.read_text()) + + def test_unset_launches_byte_identical_to_today(self): + args = self._run(None) + self.assertNotIn("--model", args) + self.assertEqual(args, self._run("")) # empty behaves like absent + + def test_set_appends_model_and_changes_nothing_else(self): + args = self._run("claude-model-x") + i = args.index("--model") + self.assertEqual(args[i + 1], "claude-model-x") + self.assertEqual(args[:i] + args[i + 2:], self._run(None)) + + +class OpencodeRunModel(unittest.TestCase): + """AGENT_MODEL reaches the opencode launch as the generated config's + model key; absent = no key, config byte-identical to today.""" + + def _run(self, model: str | None) -> dict: + with tempfile.TemporaryDirectory() as tmp: + capture = Path(tmp) / "capture.json" + stub = _write_stub(Path(tmp), "opencode-stub", + "#!/usr/bin/env python3\n" + "import json, os\n" + f"open({str(capture)!r}, 'w').write(" + "json.dumps(json.load(open(os.environ['OPENCODE_CONFIG']))))\n") + wrapper = _write_stub(Path(tmp), "bin", + f"#!/usr/bin/env bash\nexec python3 {stub} \"$@\"\n") + env = dict(os.environ) + env.pop("AGENT_MODEL", None) + env.update({"BOARD_OPENCODE_BIN": str(wrapper), + "AGENT_PROMPT": "do the task", "AGENT_MODE": "review", + "AGENT_COMMANDS": "python3 -m unittest"}) + if model is not None: + env["AGENT_MODEL"] = model + result = subprocess.run(["bash", str(OPENCODE / "run")], env=env, + capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stderr) + return json.loads(capture.read_text()) + + def test_unset_launches_byte_identical_to_today(self): + cfg = self._run(None) + self.assertNotIn("model", cfg) + self.assertEqual(cfg, permission_config.build_config( + "review", ["python3 -m unittest"])) + + def test_set_lands_as_the_config_model_key_untranslated(self): + cfg = self._run("anthropic/model-x") + self.assertEqual(cfg["model"], "anthropic/model-x") + del cfg["model"] + self.assertEqual(cfg, self._run(None)) + + def test_build_config_only_grows_the_key_when_given_a_model(self): + commands = ["python3 -m unittest"] + self.assertNotIn("model", permission_config.build_config("work", commands)) + self.assertEqual( + permission_config.build_config("work", commands, "p/m")["model"], "p/m") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_card_actions.py b/tests/test_card_actions.py new file mode 100644 index 0000000..fa47a5a --- /dev/null +++ b/tests/test_card_actions.py @@ -0,0 +1,196 @@ +"""Card actions are click-solid (task 17). + +board.html is a single file with inline JS and no frontend test runner, so +these are source-level invariants — the ones that, if broken, would bring +back the wobble the task fixed: a hit target that moves or reshapes +mid-interaction, an armed window you cannot see, a fired action that gives +no feedback until the SSE redraw, or a stuck busy state with no honest exit. + + python3 -m unittest discover -s tests -v +""" + +from __future__ import annotations + +import re +import unittest +from pathlib import Path + +BOARD = Path(__file__).resolve().parents[1] / "manager" / "core" / "board.html" + + +class StableGeometryTests(unittest.TestCase): + """The target must not travel or reshape while being clicked at.""" + + @classmethod + def setUpClass(cls): + cls.html = BOARD.read_text(encoding="utf-8") + + def rule(self, selector: str) -> str: + m = re.search(re.escape(selector) + r"\{([^}]*)\}", self.html) + self.assertIsNotNone(m, f"board.html lost its {selector} rule") + return m.group(1).replace(" ", "").replace("\n", "") + + def test_slot_enters_with_opacity_only(self): + """The slot fades in place: any transform in its entry animation + means the target is moving while the pointer approaches it.""" + slot = self.rule(".hoveracts") + m = re.search(r"animation:(\w+)", slot) + self.assertIsNotNone(m, ".hoveracts must announce itself with an animation") + frames = re.search(r"@keyframes " + m.group(1) + r"\{([^@]*?)\}\n", self.html) + self.assertIsNotNone(frames, f"@keyframes {m.group(1)} missing") + self.assertNotIn("transform", frames.group(1), + "the slot's entry animation must animate opacity only") + + def test_hit_target_is_at_least_24px_and_reserved(self): + """Buttons are ≥24px tall and the toprow reserves that height even + when showing the (shorter) status pill, so hovering never grows the + card or shifts the cards below it.""" + btn = self.rule(".hoveracts button") + m = re.search(r"min-height:(\d+)px", btn) + self.assertIsNotNone(m, ".hoveracts button needs an explicit min-height") + self.assertGreaterEqual(int(m.group(1)), 24) + row = self.rule(".card .toprow") + rm = re.search(r"min-height:(\d+)px", row) + self.assertIsNotNone(rm, ".card .toprow needs a min-height") + self.assertGreaterEqual(int(rm.group(1)), int(m.group(1)), + "the row must reserve the buttons' height") + + def test_slot_pads_its_hitbox_without_moving_layout(self): + """Padding widens the slot's catch area; the matching negative + margin keeps the buttons exactly where they were.""" + slot = self.rule(".hoveracts") + pad = re.search(r"padding:(\d+)px", slot) + neg = re.search(r"margin:-(\d+)px", slot) + self.assertIsNotNone(pad, ".hoveracts must pad its hitbox") + self.assertIsNotNone(neg, "…and take the padding back out of layout") + self.assertEqual(pad.group(1), neg.group(1)) + + def test_every_state_label_shares_one_grid_cell(self): + """rest / confirm / busy labels are stacked in one cell, so the + button is born as wide as its widest state and never reshapes when + arming swaps the text under the cursor.""" + self.assertIn("grid-area:1/1", self.rule(".actlbl>span")) + self.assertIn("inline-grid", self.rule(".actlbl")) + for cls in ("l-rest", "l-arm", "l-busy"): + self.assertIn(cls, self.html, f"the {cls} label span is gone") + + +class ArmedWindowTests(unittest.TestCase): + """Armed is a visible, timed state — the user sees what they click in.""" + + @classmethod + def setUpClass(cls): + cls.html = BOARD.read_text(encoding="utf-8") + + def arm_ms(self) -> int: + m = re.search(r"const ARM_MS = (\d+)", self.html) + self.assertIsNotNone(m, "ARM_MS must be a named constant") + return int(m.group(1)) + + def test_window_is_about_five_seconds(self): + self.assertEqual(self.arm_ms(), 5000, + "the task lengthened the 3.5s window to ~5s") + + def test_drain_bar_matches_the_disarm_timer(self): + """The armed button wears a draining bar whose CSS duration equals + ARM_MS — two clocks showing different times is worse than one.""" + m = re.search(r"animation:drain (\d+(?:\.\d+)?)s linear", self.html) + self.assertIsNotNone(m, "button.armed::after must run the drain animation") + self.assertEqual(float(m.group(1)) * 1000, self.arm_ms()) + self.assertIn("@keyframes drain{from{transform:scaleX(1)}to{transform:scaleX(0)}}", + self.html) + + def test_rebuilt_buttons_rejoin_the_drain_mid_window(self): + """Cards are torn down on every SSE render; a rebuild mid-window + must resume the bar via a negative delay, not restart it.""" + self.assertIn("--arm-delay", self.html) + self.assertIn("remaining - ARM_MS", self.html) + + def test_armed_state_survives_a_rerender(self): + """The truth lives in S.acts, not on the DOM node.""" + self.assertRegex(self.html, r"acts:\s*\{\}", "S needs the acts store") + self.assertIn("phase: 'armed', until:", self.html) + + +class BusyStateTests(unittest.TestCase): + """Firing locks the button instantly; the exit is never silent.""" + + @classmethod + def setUpClass(cls): + cls.html = BOARD.read_text(encoding="utf-8") + + def test_fire_locks_before_the_request_leaves(self): + """lockAction (disable + busy class) is the first thing fireAction + does — the click must read as taken before the network is asked.""" + m = re.search(r"async function fireAction\(btn, key, act\) \{\n(\s*)lockAction\(btn\);", + self.html) + self.assertIsNotNone(m, "fireAction must lock the button first") + + def test_busy_wears_the_working_vocabulary(self): + """busy = breathe + accent: the design system's 'an agent is + working', not a new dialect.""" + self.assertRegex(self.html, r"button\.busy \.g,\s*button\.busy \.g2\{animation:breathe") + self.assertIn("button.chip2.busy{color:var(--accent)", self.html.replace(" ", "")) + + def test_busy_holds_the_slot_open_and_the_pill_hidden(self): + """A busy button stays visible when the pointer leaves, exactly as + an armed one does.""" + flat = self.html.replace(" ", "").replace("\n", "") + self.assertIn(".hoveracts:has(.armed),.hoveracts:has(.busy){display:flex}", flat) + self.assertIn(":has(.hoveracts.busy).toprow.pill.status", flat) + + def test_timeout_is_an_honest_exit(self): + """If neither the response nor a redraw arrives, the button comes + back with a toast naming the action — never a silent revert.""" + self.assertRegex(self.html, r"FIRE_TIMEOUT_MS = \d+") + self.assertIn("toast(`${act.label} got no answer", self.html) + self.assertIn("toast(`${act.label} failed", self.html) + + def test_run_functions_report_failure(self): + """fireAction can only unlock-on-error if the runners tell it the + truth: each POST helper returns its res.ok.""" + for fn in ("runCommand", "stopAgent", "askCopilot"): + body = re.search(r"async function " + fn + r"\([^)]*\) \{(.*?)\n\}", + self.html, re.S) + self.assertIsNotNone(body, f"{fn} is gone") + self.assertIn("return res.ok", body.group(1), f"{fn} must return res.ok") + agent = re.search(r"async function fireAgent.*?\n\}", self.html, re.S) + self.assertIn("return false", agent.group(0)) + self.assertIn("return true", agent.group(0)) + + +class OneSlotBuilderTests(unittest.TestCase): + """Every action walks through the same machine — no per-action forks.""" + + @classmethod + def setUpClass(cls): + cls.html = BOARD.read_text(encoding="utf-8") + + def test_hover_actions_and_command_chips_share_the_machine(self): + self.assertEqual(len(re.findall(r"(? e\.stopPropagation\(\)\)", + self.html) + self.assertIsNotNone(m, "the slot must swallow clicks itself") + builder = re.search(r"if \(actions\.length\) \{.*?el\.querySelector\('\.toprow'\)", + self.html, re.S).group(0) + self.assertNotIn("e.stopPropagation", builder.replace(m.group(0), ""), + "per-button stopPropagation would let gap clicks " + "bubble into the card") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_column_flex.py b/tests/test_column_flex.py new file mode 100644 index 0000000..b84ccc3 --- /dev/null +++ b/tests/test_column_flex.py @@ -0,0 +1,82 @@ +"""The kanban's columns yield on small laptops (task 09). + +board.html is a single file with inline JS and no frontend test runner, so +these are source-level invariants: the ones that, if broken, would bring +back the rigid 296px columns that clipped the Done column on a 13" MacBook, +or let the floor drift above what a 1280px viewport can hold. + + python3 -m unittest discover -s tests -v +""" + +from __future__ import annotations + +import re +import unittest +from pathlib import Path + +BOARD = Path(__file__).resolve().parents[1] / "manager" / "core" / "board.html" + +COLUMNS = 5 # backlog → to-do → in-progress → review → done + + +class ColumnFlexTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.html = BOARD.read_text(encoding="utf-8") + m = re.search(r"\.kcol\{([^}]*)\}", cls.html) + assert m, "board.html lost its .kcol rule" + cls.kcol = m.group(1) + + def token(self, name: str) -> int: + m = re.search(rf"{name}:\s*(\d+)px", self.html) + self.assertIsNotNone(m, f"{name} must be a px token at the top of board.html") + return int(m.group(1)) + + def test_floor_is_a_design_token(self): + """The floor lives beside --col with the other layout tokens, not as + a magic number buried in the rule.""" + self.token("--col-min") + self.assertIn("min-width:var(--col-min)", self.kcol.replace(" ", ""), + ".kcol must take its floor from the --col-min token") + + def test_columns_yield_between_floor_and_cap(self): + """flex:1 1 0 with max-width:var(--col): wide screens cap at today's + 296px (pixel-identical), narrower viewports shrink all five evenly.""" + flat = self.kcol.replace(" ", "") + self.assertIn("flex:110", flat, + ".kcol must grow and shrink from a zero basis") + self.assertIn("max-width:var(--col)", flat, + "wide screens must still cap columns at --col") + self.assertNotIn("width:var(--col);", flat.replace("max-width", ""), + "a fixed width would undo the flex") + self.assertEqual(self.token("--col"), 296, + "the cap is today's column width; changing it is a " + "redesign, not this fix") + + def test_five_columns_fit_a_1280px_viewport(self): + """At the floor, 5 columns + 4 gaps + the board's own padding must + not exceed 1280 CSS px — the smallest laptop this board honours.""" + floor = self.token("--col-min") + gap = self.token("--gap") + board = re.search(r"#board\{([^}]*)\}", self.html).group(1) + pad = re.search(r"padding:\s*\d+px\s+(\d+)px", board) + self.assertIsNotNone(pad, "#board lost its horizontal padding") + footprint = COLUMNS * floor + (COLUMNS - 1) * gap + 2 * int(pad.group(1)) + self.assertLessEqual(footprint, 1280, + f"five columns at the floor need {footprint}px; " + "a 1280px viewport would scroll") + + def test_cards_stay_legible_at_the_floor(self): + """Shrinking must stop somewhere: a floor under ~240px would start + crushing card internals instead of ellipsising them.""" + self.assertGreaterEqual(self.token("--col-min"), 240) + + def test_overflow_fallback_survives(self): + """Below the floor the old behaviour is the fallback: #board still + scrolls horizontally rather than clipping.""" + board = re.search(r"#board\{([^}]*)\}", self.html).group(1) + self.assertIn("overflow-x:auto", board.replace(" ", "")) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_fresh_branch_point.py b/tests/test_fresh_branch_point.py new file mode 100644 index 0000000..7c5269c --- /dev/null +++ b/tests/test_fresh_branch_point.py @@ -0,0 +1,133 @@ +"""Where a fresh work launch branches from: origin/main when a fetch can +reach it, local HEAD when there is no origin or the network fails — and +never a blocked launch either way. Run with: +python3 -m unittest discover -s tests +""" + +import subprocess +import sys +import tempfile +import time +import unittest +from pathlib import Path + +REPO = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(REPO / "manager" / "core")) + +import agents # noqa: E402 +import config # noqa: E402 + + +def _git(cwd: Path, *args: str) -> str: + return subprocess.check_output( + ["git", "-C", str(cwd), *args], text=True, + stderr=subprocess.DEVNULL).strip() + + +def _commit(cwd: Path, msg: str) -> None: + subprocess.check_call( + ["git", "-C", str(cwd), "-c", "user.email=t@t", "-c", "user.name=t", + "commit", "-q", "--allow-empty", "-m", msg]) + + +class FreshBranchPoint(unittest.TestCase): + """_fresh_branch_point decides the base of a brand-new task branch.""" + + def setUp(self): + self._tmp = tempfile.TemporaryDirectory() + root = Path(self._tmp.name) + self.upstream = root / "upstream" + self.upstream.mkdir() + _git(self.upstream, "init", "-q", "-b", "main") + _commit(self.upstream, "root") + self.local = root / "local" + subprocess.check_call( + ["git", "clone", "-q", str(self.upstream), str(self.local)], + stderr=subprocess.DEVNULL) + self._saved = config.REPO, config.FETCH_TIMEOUT + config.REPO = self.local + config.FETCH_TIMEOUT = 5.0 + + def tearDown(self): + config.REPO, config.FETCH_TIMEOUT = self._saved + self._tmp.cleanup() + + def _hang_remote(self, name: str) -> None: + """Point a remote at a transport that never answers.""" + _git(self.local, "config", "protocol.ext.allow", "always") + # ext:: runs the command as the remote helper; sleep never answers + # git's handshake, so the fetch blocks until the timeout fires. + _git(self.local, "remote", "set-url", name, "ext::sleep 30") + + def test_no_remote_branches_from_head_silently(self): + _git(self.local, "remote", "remove", "origin") + self.assertEqual(agents._fresh_branch_point(), (None, None)) + + def test_only_origin_counts_and_is_never_fetched_when_absent(self): + # A hanging remote under another name: if anything fetched it, this + # test would stall; instead the launch path answers HEAD instantly. + _git(self.local, "remote", "rename", "origin", "upstream") + self._hang_remote("upstream") + started = time.monotonic() + self.assertEqual(agents._fresh_branch_point(), (None, None)) + self.assertLess(time.monotonic() - started, 2) + + def test_origin_ahead_branches_from_its_tip_and_says_so(self): + _commit(self.upstream, "landed elsewhere 1") + _commit(self.upstream, "landed elsewhere 2") + point, note = agents._fresh_branch_point() + self.assertEqual(point, "origin/main") + self.assertEqual(note, "branched from origin/main, " + "2 ahead of this checkout") + # The fetch refreshed the ref the branch will start from. + self.assertEqual(_git(self.local, "rev-parse", "origin/main"), + _git(self.upstream, "rev-parse", "main")) + + def test_worktree_from_origin_main_has_its_tip_as_merge_base(self): + # The same commands start_agent runs for a fresh branch. + _commit(self.upstream, "landed elsewhere") + point, _ = agents._fresh_branch_point() + worktree = Path(self._tmp.name) / "wt" + _git(self.local, "worktree", "add", "--no-track", "-b", "task/x", + str(worktree), point) + self.assertEqual(_git(self.local, "merge-base", "task/x", "origin/main"), + _git(self.upstream, "rev-parse", "main")) + # --no-track: the task branch must not adopt origin/main as upstream. + upstream_cfg = subprocess.run( + ["git", "-C", str(self.local), "config", "branch.task/x.merge"], + capture_output=True) + self.assertNotEqual(upstream_cfg.returncode, 0) + + def test_ahead_count_is_relative_to_the_checkout_not_local_main(self): + # The checkout already holds origin/main's tip on another branch; + # only local main is behind. Nothing was missed, so no narration — + # counting main..origin/main would have claimed "1 ahead" here. + _commit(self.upstream, "landed elsewhere") + _git(self.local, "fetch", "-q", "origin") + _git(self.local, "checkout", "-q", "-b", "other", "origin/main") + self.assertEqual(agents._fresh_branch_point(), ("origin/main", None)) + + def test_origin_in_sync_is_used_without_narration(self): + self.assertEqual(agents._fresh_branch_point(), ("origin/main", None)) + + def test_unreachable_origin_falls_back_to_head_and_says_so(self): + _git(self.local, "remote", "set-url", "origin", + str(Path(self._tmp.name) / "gone")) + point, note = agents._fresh_branch_point() + self.assertIsNone(point) + self.assertEqual(note, "fetch of origin/main failed; " + "branched from local HEAD") + + def test_hanging_fetch_times_out_within_bound_and_says_so(self): + self._hang_remote("origin") + config.FETCH_TIMEOUT = 0.5 + started = time.monotonic() + point, note = agents._fresh_branch_point() + self.assertLess(time.monotonic() - started, 5) + self.assertIsNone(point) + self.assertEqual(note, "fetch of origin/main timed out; " + "branched from local HEAD") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_pr_conflicts.py b/tests/test_pr_conflicts.py new file mode 100644 index 0000000..17ff960 --- /dev/null +++ b/tests/test_pr_conflicts.py @@ -0,0 +1,95 @@ +"""Conflicted PRs become card state: the poller folds GitHub's +mergeable field into the PR snapshot, a conflict drops any +approved-green verdict as changes-needed-by-you (not a CI failure), +and GitHub's lazily-computed UNKNOWN keeps the previous reading so the +chip never flaps. The snapshot's `conflicts` key must reach the UI.""" + +import sys +import unittest +from pathlib import Path + +REPO = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(REPO / "manager" / "core")) + +import github # noqa: E402 + + +def payload(**overrides): + """A gh pr-view JSON payload with the fields _fold reads.""" + data = {"reviews": [], "reviewRequests": [], "statusCheckRollup": []} + data.update(overrides) + return data + + +APPROVED = [{"state": "APPROVED"}] + + +class ConflictFolding(unittest.TestCase): + def test_conflict_drops_green_even_when_approved(self): + entry = github._fold(payload(reviews=APPROVED, + mergeable="CONFLICTING"), {}) + self.assertTrue(entry["conflicts"]) + self.assertEqual(entry["verdict"], "red") + self.assertIn("conflicts with main", entry["detail"]) + + def test_conflict_is_not_a_ci_failure(self): + entry = github._fold(payload(mergeable="CONFLICTING"), {}) + self.assertIsNone(entry["ci"]) + self.assertEqual(entry["verdict"], "red") + + def test_mergeable_approved_pr_stays_green(self): + entry = github._fold(payload(reviews=APPROVED, + mergeable="MERGEABLE"), {}) + self.assertFalse(entry["conflicts"]) + self.assertEqual(entry["verdict"], "green") + + def test_unknown_keeps_the_previous_reading_both_ways(self): + # GitHub computes mergeability lazily after a push: UNKNOWN means + # "not yet", never "fine" — the chip must not flap. + still = github._fold(payload(mergeable="UNKNOWN"), + {"conflicts": True}) + self.assertTrue(still["conflicts"]) + self.assertEqual(still["verdict"], "red") + clean = github._fold(payload(reviews=APPROVED, mergeable="UNKNOWN"), + {"conflicts": False}) + self.assertFalse(clean["conflicts"]) + self.assertEqual(clean["verdict"], "green") + + def test_unknown_on_first_sight_alarms_nobody(self): + entry = github._fold(payload(mergeable="UNKNOWN"), {}) + self.assertIsNone(entry["conflicts"]) + self.assertEqual(entry["verdict"], "pending") + self.assertNotIn("conflicts", entry["detail"]) + + def test_resolution_lets_green_return(self): + entry = github._fold(payload(reviews=APPROVED, mergeable="MERGEABLE"), + {"conflicts": True, "verdict": "red"}) + self.assertFalse(entry["conflicts"]) + self.assertEqual(entry["verdict"], "green") + + +class SnapshotReachesTheUI(unittest.TestCase): + def test_public_state_carries_conflicts(self): + github.PR_STATE["x.md"] = {"verdict": "red", "ci": None, + "copilot": None, "conflicts": True, + "detail": "conflicts with main", + "url": "u", "ts": 1} + try: + self.assertTrue(github.public_state()["x.md"]["conflicts"]) + finally: + github.PR_STATE.pop("x.md", None) + + def test_the_card_wears_an_alarm_coloured_chip(self): + html = (REPO / "manager" / "core" / "board.html").read_text( + encoding="utf-8") + self.assertIn("prState.conflicts", html) + self.assertIn("label: 'conflicts', glyph: '✕', cls: 'bad'", html) + + def test_poller_asks_github_for_mergeable(self): + source = (REPO / "manager" / "core" / "github.py").read_text( + encoding="utf-8") + self.assertIn("statusCheckRollup,state,mergeable", source) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_release_artifact.py b/tests/test_release_artifact.py index 441568c..836a5bf 100644 --- a/tests/test_release_artifact.py +++ b/tests/test_release_artifact.py @@ -115,12 +115,16 @@ class ArtifactContents(unittest.TestCase): self.assertNotIn("manager/local/.env", self.files) def test_local_is_the_generated_starter_not_benchs_own(self): - seeded = self.read_member("manager/local/CLAUDE.md") + # The starter mirrors the root pair (task 13): AGENTS.md holds the + # notes, CLAUDE.md is the compatibility pointer. + seeded = self.read_member("manager/local/AGENTS.md") self.assertIn("This file is yours", seeded) self.assertNotEqual( seeded, - (REPO / "manager" / "local" / "CLAUDE.md").read_text("utf-8"), + (REPO / "manager" / "local" / "AGENTS.md").read_text("utf-8"), "bench's own local notes must never ship") + pointer = self.read_member("manager/local/CLAUDE.md") + self.assertIn("@AGENTS.md", pointer) for sub in ("adapters", "commands", "driver", "prompts"): self.assertIn(f"manager/local/{sub}/.gitkeep", self.files) diff --git a/tests/test_update_from_release.py b/tests/test_update_from_release.py index e9bd11c..a15e2dd 100644 --- a/tests/test_update_from_release.py +++ b/tests/test_update_from_release.py @@ -140,6 +140,29 @@ class UpdateFromRelease(unittest.TestCase): self.assertEqual((tm / path).read_bytes(), content, f"{path} must survive byte-identical") + def test_agents_brief_replaces_an_old_vendor_named_copy(self): + # Ported from the retired test_update_round_trip.py (whose harness + # targeted the removed git-clone mechanism): an install from the + # pre-rename era has the full brief as CLAUDE.md and no AGENTS.md; + # updating must deliver the brief under the cross-vendor name and + # turn CLAUDE.md into the pointer. + tm = self.make_install() + old_brief = "# Task Workflow\n\nThe old full vendor-named brief.\n" + (tm / "CLAUDE.md").write_text(old_brief, encoding="utf-8") + (tm / "AGENTS.md").unlink() + + result = self.run_update(tm) + + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + with tarfile.open(self.tarball) as tar: + agents = tar.extractfile("./AGENTS.md").read().decode("utf-8") + self.assertEqual((tm / "AGENTS.md").read_text(encoding="utf-8"), + agents) + self.assertIn("# Task Workflow", agents) + pointer = (tm / "CLAUDE.md").read_text(encoding="utf-8") + self.assertIn("@AGENTS.md", pointer) + self.assertNotEqual(pointer, old_brief) + def test_no_published_release_changes_nothing(self): tm = self.make_install() before = snapshot(tm)