diff --git a/README.md b/README.md index 1c91b1f..a982108 100644 --- a/README.md +++ b/README.md @@ -121,15 +121,18 @@ project_board: merge_poll: true # poll merged PRs as a fallback to the webhook Done edge goal_verify: false # flip true: verify the coder's diff vs acceptance_criteria before opening a PR max_mode_n: 1 # >1 = best-of-N "Max-Mode": N coders per feature, keep the best diff - local_gate_cmd: "auto" # pre-PR gate, run in each worktree before a PR opens. "auto" - # = DISCOVER it from the bound repo (a package.json ci/check/ - # verify script → `pnpm run `; a Makefile/justfile ci - # target → `make/just `; else the `pnpm -r --if-present - # typecheck build test` superset). Prefer a repo-DECLARED - # entrypoint whose OWN CI calls the same target, so local == CI - # and can't drift. Give an explicit command to override; blank + local_gate_cmd: "auto" # pre-PR gate (the FAST slice of CI — lint/typecheck/unit, + # NOT the full suite), run in each worktree before a PR opens. + # "auto" = DISCOVER it from the bound repo, ecosystem-neutral: + # a package.json gate/ci/check/verify script → `pnpm run `; + # a Makefile/justfile gate/ci/check target → `make/just ` + # (Python/Rust/Go); else the `pnpm -r --if-present typecheck + # build test` superset. `gate` wins first so a repo can point + # coders at a fast slice distinct from a heavy `ci`. Prefer a + # repo-DECLARED target whose OWN CI calls the same thing, so + # local == CI and can't drift. Explicit command overrides; blank # = no gate. NOTE: `auto` resolves at construction — the repo - # must be cloned before the loop starts. + # must be cloned before the loop starts. See "The gate" below. preflight: true # fail-CLOSED smoke of local_gate_cmd on the clean base before # dispatching ANY work: an UNRUNNABLE gate (missing tool, base # broken) HOLDS all ready work (visible on the board) instead of @@ -168,6 +171,89 @@ project_board: `/plugins/project_board/board` — Kanban + list, live-refreshing, served by the same router as the API (so the declared view path is genuinely mounted). +## The gate — the coder's fast slice of CI + +The **pre-PR gate** (`local_gate_cmd`) is the command the loop runs in each coder's +worktree before opening a PR, so the coder's own solve-loop iterates to **green** +locally instead of shipping a PR that only fails in CI. + +### Two tiers — the gate is NOT a full-CI replica + +| | Local gate (this) | CI | +|---|---|---| +| **Question** | "is my code correct?" | "is it releasable?" | +| **Runs** | every worktree, every attempt | once per PR | +| **Contains** | lint + typecheck + **unit** tests — fast, hermetic, deterministic | everything: integration, cross-platform matrix, image build, release, deploy | +| **Owner** | the coder's iterate loop | the human merge + the loop's CI-bounce re-dispatch | + +You **never replicate a complex CI locally**. Anything needing services, secrets, a +matrix, network, or an image build stays CI-only — the PR still runs it, and whatever +the local slice didn't catch comes back to the coder via the CI-bounce. The gate's job +is to kill the cheap, common failures in seconds so the loop isn't a slow CI-bounce +casino. Getting that slice faithful matters — the failure modes are subtle (a +build-only gate compiles a test file but never runs it; a build+test gate still misses +`typecheck`, since most test runners strip types without checking them). + +### `auto` — discover it, don't transcribe it + +A hand-copied gate rots the moment the repo's CI changes, and is wrong the instant a +team is pointed at another repo. So: + +```yaml +project_board: + local_gate_cmd: "auto" +``` + +`auto` **discovers** the gate from the bound repo — **ecosystem-neutral**, keyed on how +the repo builds, always preferring a single repo-**declared** target: + +1. `package.json` script `gate` / `ci` / `check` / `verify` → `pnpm run ` *(node)* +2. `Makefile` / `justfile` `gate` / `ci` / `check` target → `make ` / `just ` + *(Python / Rust / Go / anything — e.g. `make gate` = `ruff check . && pytest -q`)* +3. `package.json`, none declared → `pnpm -r --if-present typecheck build test` +4. nothing recognized → gateless (fail-open, warns) + +`gate` is checked **first**: it's the unambiguous "this is the fast coder slice", so a +repo whose `ci` target is the whole heavy suite points coders at `gate` and the loop +won't grab the heavy one. An explicit command overrides; blank still = no gate. + +### Make your repo team-ready + +Give the team **one gate target** — the fast slice — and have your own CI call the +**same** target, so local == CI by construction. Node: + +```jsonc +// package.json ci.yml: - run: pnpm run gate +"scripts": { "gate": "pnpm -r typecheck && pnpm -r --if-present test" } +``` +> Invoke it `pnpm run gate` — `pnpm ci`/`pnpm gate` shorthands can collide with pnpm builtins. + +Python (protoAgent-shaped: a 9-workflow CI, but only `checks.yml` — ruff + pytest — is +the coder's concern; the matrix / docker-publish / release / deploy workflows are +`push`/`tag`/`dispatch` triggered and never a pre-PR gate): + +```makefile +# Makefile checks.yml: - run: make gate +gate: ## the coder's fast slice — lint + unit tests, no services + ruff check . + pytest tests/ -q -m "not integration" +``` + +Same shape in a `justfile` (`just gate`), `nox` (`make gate` → `nox -s gate`), Cargo +(`make gate` → `cargo clippy && cargo test`), etc. The heavy jobs stay in their own +workflows; the coder never runs them. + +### Preflight (fail-closed) + +Before dispatching **any** work, the loop smoke-runs the resolved gate on the clean +base checkout (`preflight: true`, the default). If the gate can't even launch +(missing tool, broken deps, base already red) it **holds** all ready work — flagged +blocked, with the reason, visible on the board — rather than burn generations no coder +could pass, and re-checks each cycle so work resumes the moment it's fixed. A slow gate +that times out is treated as indeterminate → allowed (a slow gate must never wedge the +board). This is the fail-**closed** complement to the per-PR gate's fail-**open**: a +flaky gate never blocks good work, but an *unrunnable* gate never starts bad work. + ## Layout | File | What | diff --git a/loop.py b/loop.py index c2d1aca..9b867a6 100644 --- a/loop.py +++ b/loop.py @@ -60,18 +60,33 @@ # orchestrator (or the operator's dispatch) rots two ways: the repo's CI changes and # the transcription silently goes stale (green-locally / red-in-CI), or the same team # is pointed at a DIFFERENT repo and the gate is simply wrong. So ``local_gate_cmd: -# "auto"`` asks the loop to DISCOVER the gate from the bound checkout. Precedence -# favors a single repo-DECLARED entrypoint — the repo owns its gate, and its own CI -# should invoke that SAME target, so local == CI by construction and can't drift — -# and only falls back to ecosystem conventions: -# 1. package.json script ci / check / verify → ``pnpm run `` -# 2. Makefile / justfile ci / check target → ``make `` / ``just `` -# 3. package.json present, none declared → ``pnpm -r --if-present typecheck build test`` -# 4. nothing recognized → "" (no gate; fail-open, warns) +# "auto"`` asks the loop to DISCOVER the gate from the bound checkout. +# +# WHAT the gate is (and isn't): the coder's iterate-to-green loop, so it must be the +# FAST, HERMETIC, deterministic slice of CI — lint + typecheck + unit tests, runnable +# in a worktree in minutes with no services/secrets/matrix/image-builds. It is NOT a +# full-CI replica. A complex CI's heavy jobs (integration, cross-platform matrix, +# docker publish, release, deploy) stay CI-only; they run once on the PR as the human's +# merge gate, and anything the local slice missed comes back via the CI-bounce re- +# dispatch. So a repo with a big CI declares a dedicated ``gate`` target = that fast +# slice, distinct from a heavy ``ci`` — which is why ``gate`` is the top precedence. +# +# ECOSYSTEM-NEUTRAL: node is just one case. The contract is "declare ONE gate target +# your own CI also calls"; the runner is inferred from how the repo builds: +# 1. package.json script gate / ci / check / verify → ``pnpm run `` (node) +# 2. Makefile / justfile gate / ci / check target → ``make `` / ``just `` +# (this is the path for Python / Rust / Go / anything — e.g. `make gate` = +# `ruff check . && pytest -q`) +# 3. package.json present, none declared → ``pnpm -r --if-present typecheck build test`` +# 4. nothing recognized → "" (no gate; fail-open, warns) # An explicit command always passes through unchanged; blank still means "no gate". # Resolved once at construction (the coder only ever touches worktrees, so the bound # checkout is a stable base); the deployment clones the repo before the loop starts. _PNPM_INSTALL = "pnpm install --frozen-lockfile --prefer-offline" +# Precedence of DECLARED target names. ``gate`` first: it is the unambiguous "this is +# the pre-PR coder gate (the fast slice)", so a repo whose ``ci`` is the whole heavy +# suite can point coders at ``gate`` without the loop grabbing the heavy target. +_GATE_TARGET_NAMES = ("gate", "ci", "check", "verify") def _resolve_gate_cmd(raw: str, repo_path: str) -> str: @@ -87,7 +102,7 @@ def _resolve_gate_cmd(raw: str, repo_path: str) -> str: scripts = (json.load(fh) or {}).get("scripts", {}) or {} except (OSError, ValueError): scripts = {} - for name in ("ci", "check", "verify"): + for name in _GATE_TARGET_NAMES: if name in scripts: return f"{_PNPM_INSTALL} && pnpm run {name}" # No declared entrypoint — run the standard checks any workspace exposes. @@ -105,12 +120,14 @@ def _resolve_gate_cmd(raw: str, repo_path: str) -> str: body = fh.read() except OSError: body = "" - for target in ("ci", "check"): + for target in _GATE_TARGET_NAMES: if re.search(rf"(?m)^{target}:", body): return f"{runner} {target}" log.warning( "[project_board] local_gate_cmd=auto but no gate could be discovered in %s " - "(no package.json ci/check script, no Makefile/justfile ci target) — running gateless", + "(no package.json gate/ci/check script, no Makefile/justfile gate/ci target) — " + "running gateless. Declare a `gate` target (e.g. `make gate` = lint + unit tests) " + "to make this repo team-ready.", repo_path, ) return "" diff --git a/tests/test_loop.py b/tests/test_loop.py index 32f58bd..52737d3 100644 --- a/tests/test_loop.py +++ b/tests/test_loop.py @@ -2176,11 +2176,24 @@ def test_resolve_gate_auto_convention_fallback_when_no_entrypoint(tmp_path): ) +def test_resolve_gate_auto_prefers_gate_over_ci_script(tmp_path): + # A complex-CI repo declares a dedicated fast `gate` alongside a heavy `ci`; + # the coder must gate on `gate`, not the whole `ci`. + _write(tmp_path, "package.json", '{"scripts": {"gate": "x", "ci": "everything"}}') + assert _resolve_gate_cmd("auto", str(tmp_path)) == f"{loop_install()} && pnpm run gate" + + def test_resolve_gate_auto_reads_makefile_ci_target(tmp_path): _write(tmp_path, "Makefile", "build:\n\tgo build ./...\nci:\n\tgo test ./...\n") assert _resolve_gate_cmd("auto", str(tmp_path)) == "make ci" +def test_resolve_gate_auto_makefile_gate_beats_ci(tmp_path): + # Python/Go/Rust path: `make gate` (fast slice) wins over a heavy `make ci`. + _write(tmp_path, "Makefile", "ci:\n\tpytest -q -m 'integration'\ngate:\n\truff check . && pytest -q\n") + assert _resolve_gate_cmd("auto", str(tmp_path)) == "make gate" + + def test_resolve_gate_auto_justfile_check_target(tmp_path): _write(tmp_path, "justfile", "default:\n\techo hi\ncheck:\n\tcargo test\n") assert _resolve_gate_cmd("auto", str(tmp_path)) == "just check"