Metadata-Version: 2.4
Name: camas
Version: 0.1.31
Summary: Parallel and sequential task-tree runner with matrix expansion and effects plugins.
Author-email: JP Hutchins <jphutchins@gmail.com>
License-Expression: MIT
Project-URL: Homepage, https://github.com/JPHutchins/camas
Project-URL: Repository, https://github.com/JPHutchins/camas
Project-URL: Issues, https://github.com/JPHutchins/camas/issues
Keywords: task-runner,parallel,sequential,async,github,agents,CI/CD,MCP
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Programming Language :: Python :: 3.15
Classifier: Topic :: Software Development :: Build Tools
Classifier: Typing :: Typed
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: exceptiongroup<2,>=1.2; python_version < "3.11"
Requires-Dist: taskgroup<1,>=0.2; python_version < "3.11"
Requires-Dist: tomli<3,>=2; python_version < "3.11"
Requires-Dist: typing_extensions<5,>=4.12; python_version < "3.11"
Provides-Extra: github-checks
Requires-Dist: httpx<1,>=0.28; extra == "github-checks"
Provides-Extra: ctrf
Requires-Dist: msgspec<1,>=0.19; extra == "ctrf"
Provides-Extra: check
Requires-Dist: ty<1,>=0.0.31; extra == "check"
Provides-Extra: mcp
Requires-Dist: mcp<2,>=1.24; extra == "mcp"
Requires-Dist: camas[check]; extra == "mcp"
Provides-Extra: all
Requires-Dist: camas[check,ctrf,github_checks,mcp]; extra == "all"
Dynamic: license-file

<img align="right" width="150" src="https://raw.githubusercontent.com/JPHutchins/camas/main/resources/artwork/camas-451x1023.png" alt="camas-sketch-by-jph">

# Camas

A task runner with parallel execution, matrix expansion, MCP, and pluggable output effects.

- **For developers:** live tree view that updates in place as tasks stream
- **For CI/CD:** one definition drives both local runs and CI
- **For LLMs:** a closed edit→validate→run loop over structured MCP

<br clear="all">

&nbsp;

![demo](https://raw.githubusercontent.com/JPHutchins/camas/gh-storage/demos/demo-latest.gif)

## Example

```python
from camas import Parallel, Sequential

ci = Sequential(
  Parallel(
    "ruff format . --check",
    "npx prettier ."
  ),
  Parallel(
    "ruff check .",
    "mypy .",
    "npx eslint src/",
    "pytest",
    "npx tsc --noEmit"
  ),
)
```

Nodes also compose with operators (in `tasks.py` — the `camas '<expr>'` expression surface does not yet accept them), each shorthand for a group's typed `extend`/`remove`: the left operand is the group being extended and the right operand is one child, so `check = format_check | lint | typecheck | test` keeps `typecheck` whole and `gate = (check - test) | coverage` swaps one child (`-` raises if it isn't a direct child). `|` extends a plain `Parallel` and `+` a plain `Sequential`; a left operand with fields, of a subclass, or of another kind nests whole instead, so `|` and `+` never move a task into or out of a `cwd`/`env`/`matrix` scope on their own — `.extend(...)` is the explicit way to join one, and passing a group's `.tasks` (`x | y.tasks`) the explicit way to merge its children rather than nest it, leaving its scope behind. `+` binds tighter than `|` — parenthesize a mixed chain to control its shape. `clippy > "sarif"` is a `Pipe` wiring `clippy`'s stdout into `sarif` (a stage can't nest, so `>` always extends the left `Pipe` — new stages join its scope — while a plain right-side `Pipe` contributes its stages and one with fields, `agent_only`, or a subclass type raises — pipe its `.tasks` instead: `x > y.tasks`); `>` is a comparison operator, so Python chains `a > b > c` as `(a > b) and (b > c)` — parenthesize a chain of more than two: `(a > b) > c` — and a composed pipe is always truthy, so a boolean context silently passes; compare with `==`.

The animated tree above is from a live test fixture — [see the walkthrough](#walkthrough).

## Install

> [!TIP]
> Add extras with PEP 621, e.g. `camas[github_checks]`

pipx:
```
pipx install camas
```

uv:
```
uv tool install camas
```

Nix:
```
nix run github:JPHutchins/camas                     # default
nix run github:JPHutchins/camas#with-github-checks  # adds httpx for the GitHubChecks effect
nix run github:JPHutchins/camas#with-check          # adds ty for `camas --check`
nix run github:JPHutchins/camas#all                 # both extras
```

> [!NOTE]
> camas requires Python ≥ 3.10. Adding it to a project that still supports older interpreters (e.g. `requires-python = ">=3.8"`) makes `uv add camas` / `uv lock` fail to resolve — camas can't install for the sub-3.10 part of the range. Guard the dependency with an environment marker so the lock stays green — `"camas[mcp]==0.1.26 ; python_version >= '3.10'"` — or keep camas out of the project environment entirely and launch it with uvx (`camas mcp init --launcher uvx`). Either way camas orchestrates from its own ≥3.10 interpreter and shells out, so it still drives the sub-floor cells.

Then scaffold a starter `tasks.py` in your project root:

```
camas --init
```

The starter demonstrates leaf tasks, `Sequential`/`Parallel` composition, a matrix, a [`Config`](#config) default task, and the optional [PEP 723 standalone block](#standalone-taskspy-pep-723) — cross-platform placeholder commands ready to be swapped for your real ones.

`--init` also creates a gitignored `.camas/` directory beside `tasks.py`. Camas writes run logs and a per-leaf timing cache there, so `camas --list` can annotate tasks with an estimated duration; delete the directory to opt out. Rename or relocate it with `Config(camas_dir=...)`.

> [!TIP]
> A multi-version matrix that builds a per-version environment should point it *into* `.camas/` — `env={"UV_PROJECT_ENVIRONMENT": ".camas/.venv-{PY}", "UV_PYTHON": "{PY}"}` — so the venvs land in the already-gitignored `.camas/` rather than the project root. In the root, a later `.`-scanning tool (a formatter, a linter that isn't gitignore-aware) descends into `.venv-3.13/` and friends; and note that a formatter's default `.venv` exclude often won't match a `.venv-3.13` suffix, so the breakage is tool-inconsistent and only appears *after* a matrix run.

Want to see the whole authoring surface at once instead of growing the short starter? `camas --init --verbose` writes a kitchen-sink `tasks.py` covering every `Task`/`Sequential`/`Parallel`/`Config` option — path scoping, matrix expansion, `agent_format` structured output, `Config(agent=Claude(fix=..., check=..., default=...))`, and a commented recipe for sourcing a matrix axis from your own project's SSOT — each one worked and explained in place. The MCP `camas_init` tool writes this template by default (pass `verbose=false` for the short one).

## Why camas?

camas is **not a build system**. camas is for the specific job of running structured trees of shell commands.

| | Python task runners† | just | Task | Mage | camas |
|---|---|---|---|---|---|
| Project scope | **Python projects only** | Any | Any | **Go projects only** | Any |
| Definition language | pyproject.toml TOML or `@task` decorator | justfile DSL | YAML | Go | Python (typed AST) |
| Inline anonymous parallel groups | No | No | No (must be a named task) | No | **Yes** |
| Parallel execution | poe yes; Invoke / taskipy no | `[parallel]` attr | `deps:` (parallel) | `mg.Deps(...)` | **`Parallel(...)`** |
| Matrix expansion | No | No | `for:` + `parallel:true` | Go loops | **`matrix=`** |
| Coupled (non-cross-product) fan-out | No | No | No | Go loops | **`variants=`** |
| CLI matrix override (e.g. `--PY 3.13`) | No | No | No | No | **Yes** |
| Live tree output | No | No | `prefixed` / `group` modes | No | **`Termtree` (default)** |
| Pluggable output renderers | No | No | 3 built-ins | No | **`--effects`** |
| Type checking on task definitions | Partial | No | Editor schema | Yes (Go) | **mypy / pyright** |
| First release / status | 2013–2020, stable | 2016, stable | 2017, stable | 2017, stable | 2026, alpha |
| Ecosystem | Moderate (Python) | Large | Large | Moderate | None yet |

†[poethepoet](https://poethepoet.natn.io), [Invoke](https://www.pyinvoke.org), [taskipy](https://github.com/taskipy/taskipy) — pyproject.toml-bound runners that assume a Python project.

| If you need... | Reach for |
|---|---|
| Reproducible, hermetic builds | [Nix](https://nixos.org) |
| Incremental file-based builds (skip when inputs unchanged) | [Task](https://taskfile.dev) |
| Simple project command menu | [just](https://just.systems) |
| Parameterized tasks (`--env=staging`, prompts, vars) | [Task](https://taskfile.dev) |
| Go project, build logic in Go | [Mage](https://magefile.org) |
| Inline parallel/sequential trees with a live view | **camas** |
| Pluggable output effects (live tree locally, summary in CI) | **camas** |
| Matrix runs across versions/platforms, overridable from the CLI | **camas** |

## CI integration

The renderer is swappable, not the tree. Run the same `tasks.py` locally with the live `Termtree` and in CI with `Status` — one flag changes the output, the pipeline definition is unchanged.

**On GitHub Actions, no flag is needed.** Camas detects `GITHUB_ACTIONS=true` and defaults `--effects` to `(Status(output_mode="github"),)` — collapsed workflow groups, ISO timestamps with millisecond precision, ANSI colors preserved.

**Under an AI coding agent, no flag is needed either.** Camas detects agents (the `CLAUDECODE` env var, or set `CAMAS_AGENT=1`) and defaults to the line-oriented `Status` renderer instead of the live `Termtree`, whose cursor-redraw frames bloat captured output. Prefer the `camas mcp` server over the CLI from an agent; a `Config` `default_effects` always overrides the detection.

```yaml
- run: uv run camas check
```

On other CI providers (or to opt into a specific mode), spell it out:

```yaml
- run: uv run camas check --effects='(Status(output_mode="errors"),)'
```

See the [`OutputMode`](https://github.com/JPHutchins/camas/blob/main/src/camas/effect/status.py) literal and `block_for` doctests for the per-mode behavior. The [`status-modes-demo` job](https://github.com/JPHutchins/camas/blob/main/.github/workflows/ci.yaml) renders one CI run per mode for visual comparison.

For per-leaf visibility in the PR Checks panel, add the `GitHubChecks` effect alongside `Status` (opt-in extra: `camas[github_checks]`). Each leaf task becomes its own check run, so reviewers see `lint` / `mypy` / `pytest` pass-or-fail individually instead of one monolithic log.

```yaml
- run: |
    uv run --extra github_checks camas matrix --effects='(
      Status(output_mode="github"),
      GitHubChecks(sha="${{ github.event.pull_request.head.sha || github.sha }}"),
    )'
  env:
    GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
```

The job needs `permissions: checks: write`. Defaults read `GITHUB_TOKEN`, `GITHUB_REPOSITORY`, and `GITHUB_SHA` from the Actions env. The `pull_request.head.sha` override attaches checks to the PR head rather than the synthetic merge commit. See the [`github-checks-demo` job](https://github.com/JPHutchins/camas/blob/main/.github/workflows/ci.yaml) for a working example.

**When to use it.** Two things it gives you:

- **SSOT between local dev and CI.** Your `camas matrix` terminal view and the PR Checks panel show the same per-leaf shape (`lint [PY=3.10]`, `mypy [PY=3.14]`, …). The matrix definition lives once in `tasks.py`; CI doesn't re-encode it in YAML.
- **One runner instead of N.** Camas-side parallel matrix on a single runner produces the same per-`(cell, leaf)` granularity that a GHA matrix gets across N runners — at 1/N the minutes and camas gives you efficient task parallelization pushing that runner to 100% utilization as much as possible. Worth it on paid-runner budgets.

The downside is that fork PRs get a read-only `GITHUB_TOKEN` from GitHub and can't write checks — usually a non-issue for Enterprise teams where contributors have push to the org repo.

**When not to bother.** OSS gets free runners — just GHA-matrix them in parallel for faster wall-clock time. The cost is small: you give up either SSOT (matrix definition moves into YAML) or per-leaf-UI granularity (one PR check entry per runner instead of per leaf) — pick one. What *is* a deal-breaker for OSS is fork PRs: external-contributor PRs return 403 from the Checks API, so per-leaf entries silently don't appear. The `github-checks-demo` job is marked `continue-on-error` so it doesn't block CI when that happens, but `GitHubChecks` isn't a workable OSS solution.

### GitHub Actions matrix (`--github-matrix`)

Fan out across N runners *without* giving up SSOT. `camas <task> --github-matrix` emits the task's fan-out as the JSON GHA's `strategy.matrix` consumes, so a `discover` job can source it from `tasks.py` and downstream jobs read it with `fromJSON` — the fan-out lives in one place. This repo dogfoods it: see the [`discover` and `check` jobs](https://github.com/JPHutchins/camas/blob/main/.github/workflows/ci.yaml) fanning out over `.python-version`.

Three shapes, one per way a `tasks.py` fans out:

| `tasks.py` | emitted | job runs |
|---|---|---|
| `matrix={"PY": (...)}` — a cross product | `{"PY": ["3.13", "3.14"]}` | `camas <task>` with the cell's axes pinned (flags, or job `env`) |
| `variants=({...}, {...})` — coupled bundles | `{"include": [{...}, {...}]}` | same, pinning the cell's keys |
| a `Parallel` of named tasks, no axes | `{"task": ["build", "lint", "test"]}` | `camas ${{ matrix.task }}` |

The last one is the common CI shape — N independent jobs, no version matrix — and its values are the *binding* names `camas <task>` dispatches on, so an emitted job can't 404 in CI. Compose a YAML-side axis (`os`, which a shell command can't change from inside a job anyway) with either object-of-arrays form:

```yaml
strategy:
  matrix:
    os: [ubuntu-latest, macos-latest]
    task: ${{ fromJSON(needs.discover.outputs.matrix).task }}
```

`--github-matrix=axes|variants|tasks` pins the shape; pin it in your `discover` step so a later `tasks.py` refactor fails there instead of quietly handing the workflow a differently-shaped object. Whatever the shape, camas checks the emitted object against the task's real run-set — each job must run exactly its own cell's leaves, and the jobs together every leaf exactly once — so a fan-out with no faithful projection (independent fan-outs in one tree, a plain leaf beside matrixed siblings) is rejected rather than silently widened or narrowed.

> [!NOTE]
> If your test matrix includes Python **below camas's floor** (camas needs ≥3.10; a library might test 3.8+), the sub-floor cells can't `uv run camas` — camas isn't installable there. Naively switching to `uvx` doesn't help either: `setup-uv`'s `python-version` (and any job-level `UV_PYTHON`) also pin a `uvx camas` invocation onto the sub-floor interpreter. Decouple camas's interpreter from the cell's by scoping `UV_PYTHON` to the sync step only:
> ```yaml
> - uses: astral-sh/setup-uv@...             # no python-version -> no job-wide UV_PYTHON
> - run: uv sync
>   env:
>     UV_PYTHON: ${{ matrix.python-version }}    # step-scoped: builds the cell's .venv
> - run: uvx camas==0.1.26 <task>            # camas gets its own >=3.10 interpreter;
>                                             # its leaves' `uv run` reuse the synced .venv
> ```
> The `discover` job still emits the matrix from `tasks.py`, so SSOT is preserved — only the per-cell launcher changes.

### Machine-readable report (`Ctrf`)

For a CI artifact or input to an AI code review, add the `Ctrf` effect (opt-in extra: `camas[ctrf]`). It writes the run as a [CTRF](https://ctrf.io) JSON report — each leaf a test with status, duration, output, command, and exit code. `path=` writes a file; the default is stdout.

```sh
uv run --extra ctrf camas check --effects='(Status(output_mode="errors"), Ctrf(path="ctrf-report.json"))'
```

## Config

Bind a `Config` in `tasks.py` and bare `camas` (no arguments) runs its `default_task`:

```python
from camas import Config, Sequential, Task

lint = Task("ruff check .")
test = Task("pytest")
ci = Sequential(lint, test, name="ci")

_ = Config(default_task=ci)
```

Now `camas` runs `ci`, `camas --dry-run` previews it, and the default's matrix axes stay overridable (`camas --PY 3.13`). With no `Config` (or no `default_task`), bare `camas` prints the full help — task listing, effects, hints — and exits non-zero.

`github_task` is the CI counterpart, falling back to `default_task` when unset; it runs under `GITHUB_ACTIONS=true`. Paired with the [automatic `Status` effect](#ci-integration), one bare `camas` does the right thing in both places:

```python
_ = Config(
  default_task=ci,                            # bare `camas` locally
  github_task=Sequential(ci, "pytest --cov"), # bare `camas` under GitHub Actions
)
```

`Config` is discovered by type, so the binding's name never matters — `_` by convention. Defining two is an error.

### Leaf color (`leaf_color`)

camas runs every leaf on a pipe, so a tool that probes for a terminal drops its color — and the tree, and any CI log that renders ANSI, lose it. camas therefore forces color on in the leaf environment (`FORCE_COLOR=1`, `CLICOLOR_FORCE=1`), which is why `camas lint` stays colored where a bare `lint | cat` would not.

That environment is inherited, so it also reaches commands your leaf spawns for itself — including one whose output the leaf *captures and asserts on*. A test checking that a CLI prints `Passes: 1, failures: 1` passes when run by hand and fails under camas, because the string arrives wrapped in ANSI. When a leaf's output is data rather than display, turn the forcing off:

```python
_ = Config(leaf_color=False) # project-wide
check = Task("cargo test -p mycli", env={"NO_COLOR": "1"}) # or just this leaf
```

`NO_COLOR` wins over both, per [no-color.org](https://no-color.org). `leaf_color=False` only stops camas from forcing — a leaf that asks for color itself (`env={"FORCE_COLOR": "1"}`, or an exported one) still gets it.

Because `github_task` reproduces CI, running it before a push catches a CI failure locally. If it is a named task, a one-line git hook guards every push:

```sh
echo 'exec camas check' > .git/hooks/pre-push && chmod +x .git/hooks/pre-push
```

`git push --no-verify` still bypasses it deliberately. An LLM agent needs no hook — when a `github_task` is declared, `camas_list` reports it as `github_default`, so the agent runs `camas_run` with that name before pushing. The field is `null` when no `github_task` is set; camas never infers it from your CI workflow files.

## Time budget (`--under`)

Once camas has timed a task's leaves (the `.camas/` cache, surfaced by `camas --list`), `camas --under=<duration>` runs only the leaves whose estimate fits a wall-clock budget — the fast inner-loop subset, picked for you instead of by hand.

```
camas --under=1s            # budget the Config default task
camas --under=500ms check   # budget a named task or expression
```

It runs the **mutating** leaves first, in sequence, then the read-only rest in parallel — so formatters never race a checker over the same files. Mark a leaf that writes the workspace with `mutates=True`:

```python
fix = Task("ruff check --fix .", mutates=True)
fmt = Task("ruff format .", mutates=True)
lint = Task("ruff check .")
```

```
$ camas --under=1s --dry-run
Time budget 1.00s — running 6 leaf(s) (0 unmeasured), excluded 2 over budget.
  over budget: pyright ~4.57s, coverage ~20.98s
fix → fmt → (mypy | ty | zuban | pyrefly)
```

Durations are `1s`, `500ms`, `2m`, `1h`, or a bare number of seconds. Only leaves *measured* to exceed the budget are excluded; a leaf with no recorded timing yet runs anyway (and is thereby measured) — skipping it would keep it forever untimed. The budget is per-leaf: a measured leaf runs when its own estimate fits, so the parallel group's wall-clock stays near the budget.

**For agents**, `camas_run` exposes the same budget as its `under` argument (omit `task` to budget the project default), and the response's `budget` field reports what was selected and excluded — a tight, time-boxed validate loop over structured MCP.

## Path scoping (`--paths`)

`camas <task> --paths <path>…` scopes a run to changed paths instead of the whole tree. A command opts in by writing the `{paths}` placeholder and declaring its scope with `paths=` — a directory prefix, or a `(changed) -> paths` callable. A `Sequential`/`Parallel` may carry `paths=` too: it's the default scope for descendant `{paths}` commands that set none (the same way `env`/`cwd` propagate into a group's leaves):

```python
py = Task("ruff format {paths}", mutates=True, paths="src")
web = Task("prettier --write {paths}", mutates=True, paths="web")
# the group's paths="." is the default for both children (neither sets its own):
autofix = Sequential(Task("ruff check --fix {paths}", mutates=True), Task("ruff format {paths}", mutates=True), paths=".")
_ = Config(agent=Claude(fix=Sequential(py, web, autofix)))
```

`--paths` works on any task — `camas check --paths src/a.py`. Without it, every `{paths}` resolves to its full-run default (`ruff format src`); with it, each `{paths}` command runs only over the changed files it covers, and one covering none is dropped. A command **without** `{paths}` can't be narrowed, so its `paths=` is a no-op and it always runs — unless it declares `when=` (below), camas errs on correctness (a tool it can't narrow might be affected by the edit). `--paths` is repeatable, or comma-separated.

A command that can't take `{paths}` (`cargo build`, `nix flake check`, `ctest`) is scoped with `when=` instead — a directory-prefix string or `Path` (coerced to its POSIX prefix), a tuple of those, or a `(changed) -> bool` callable. On a scoped run a leaf whose `when=` doesn't match the changed set is dropped; a full run never consults it. Like `paths=`, a group's `when=` is the default for descendant leaves that set none. A leaf with a `cwd` but no `when=` gates on its `cwd` directory — the monorepo file-tree default; set `when="."` to opt back into always-run:

```python
build = Task("cargo build", when="src")                # runs only when src/ changed
flake = Task("nix flake check", when=("flake.nix", "nix"))
tests = Task("cargo test", cwd="code-gen")             # when= defaults to "code-gen"
```

A `paths=` callable is called with `()` on a full run — one that filters the changed set would return `()` and strip the command's arguments entirely (a formatter reading stdin on no args hangs). `by_suffix(suffixes, default=...)` is the safe factory: it filters the changed files by suffix on a scoped run and returns `default` on a full run:

```python
tidy = Task("clang-tidy {paths}", paths=by_suffix((".c", ".h"), default=("src",)))
```

`camas --check` (and the MCP `camas_check`) flags both authoring mistakes as advisory warnings: a leaf whose own `paths=` can never apply (no `{paths}` token — use `when=` instead) and a `{paths}` callable that goes empty on a full run.

For the Claude Code plugin, you **register** the auto-fix node — whatever you named it — to `Config.agent.fix`; the `camas_gate` MCP tool settles *that* node over the changed files before it checks, and a `Stop` hook runs it at every turn end — deterministically, at zero model tokens, and never between an agent's edits, so a half-finished edit (an import not yet used) survives until the agent gates. The headless `camas mcp gate` stays read-only, so CI checks exactly what was committed. Run `camas mcp init --claude` to write the full Claude Code setup in one command (`.mcp.json` + the Stop autofix and async nudge hooks + the tiered camas-fixer agents + `gate` skill), resolving the launcher for your project (`uv run camas`/`uv run tasks.py`, `uvx`, or a PATH `camas`) and pinning it — to `tasks.py`'s PEP 723 block when present, else to the running camas release version (a dev/local build is left unpinned, since it isn't published to pin against). The launcher it picks is the one that can still see your project's tools: a `uv.lock`/PEP 723 project gets `uv run`, and a `camas` that is part of the project's environment — installed into a virtual environment, or provided by a flake devShell out of `/nix/store` — is preferred over `uvx`, which is isolated from both and so cannot run `ruff`/`mypy`/`treefmt`/`go` at all. A global `camas` is not preferred, since it shares nothing with the project and the pinned `uvx` entry is more portable. Detection reads where `camas` itself resolves, not `VIRTUAL_ENV`/`IN_NIX_SHELL`, which only report that a shell was entered. When init picks the environment's `camas`, the entry it commits needs your team in that same environment — activate the venv, or enter the devShell, before starting the client — and init says so. Pass `--launcher uv|uvx|camas` to force a strategy instead. For a bare `.mcp.json` for any MCP client, `camas mcp init` alone (same launcher/pin resolution, no Claude Code files). Either path warns when git excludes a file it just wrote: a repo whose `.gitignore` covers `.claude/` can only commit `.mcp.json`, so the autofix/gate loop stays per-developer — every teammate re-runs `camas mcp init --claude` in their own clone — until that rule is opened up, and the warning prints the `.gitignore` lines that do it. Or wire it by hand:

```jsonc
// .claude/settings.json
{ "hooks": { "Stop": [
  { "hooks": [{ "type": "command", "command": "camas mcp fix" }] } ] } }
```

`camas mcp fix` runs the registered `Config.agent.fix` node (not a task named `fix` — that's just `camas fix`, your own task); it reads the changed files from `--paths` or a `PostToolBatch` event on stdin, and a `Stop` event names none, so the fix covers the whole tree. With no fix registered it is a clean no-op, so the hook is harmless without it. It is synchronous, so it holds the turn until it has run and can never race the agent's next edit. `camas mcp init --claude` also writes a second `Stop` hook, `camas mcp gate --under 5s --nudge` (`async: true, asyncRewake: true`) — a headless, time-boxed check that first waits for the sibling fix to mark the prompt settled (Claude Code runs a `Stop` event's hooks in parallel), stays silent when green, and otherwise wakes the main agent with a reminder to delegate to the camas-fixer ladder (see the `gate` skill), without ever blocking the turn. The nudge is self-limiting: at most one wake per prompt (it honors the Stop event's `stop_hook_active` and tracks the `prompt_id` it last nudged), and a configuration state — no check node registered, a `tasks.py` load error, a missing `camas[mcp]` extra — exits `0` silently instead of waking the agent over something a rewake cannot fix. The launcher runs in your project's environment — `camas mcp init --claude` resolves and pins it: to `tasks.py`'s PEP 723 declaration (`dependencies = ["camas>=X.Y"]`) when present, else to the running camas release version; re-run `camas mcp init --claude` after bumping either to keep it current.

## Drift gate (`Clean`)

`Clean` keeps committed generated code up to date: it runs the generator, then fails if the working tree changed.

```python
from camas import Clean, Task

openapi = Clean(Task("make update-openapi", mutates=True))
```

`openapi` is a `Sequential` of three leaves: a check that the tree is clean, the generator, and the same check again. A dirty tree fails the first check and skips the generator. A failed second check prints the drift: the `git diff` of the changed tracked files, staged or not, then the `git status --porcelain` lines, untracked files included.

- `check=` replaces the whole-tree default with any task or command string whose exit 0 means clean. `check="git diff --exit-code -- schema/"` scopes the gate to one directory, but `git diff` doesn't see untracked files, so a generator that adds files needs a check that does. A check can't carry `{paths}`.
- `before=False` drops the first check. A tree that starts dirty then fails as drift.
- The default check reads git's view of the whole tree, where ignored paths don't count, so nothing else may write while a gate runs. A formatter sequenced before a gate fails the gate's first check whenever it changes a file, and two gates under one `Parallel` see each other's writes. Run the gates first, in sequence:

```python
from camas import Clean, Sequential, Task

generators = Sequential(
  Clean(Task("go generate ./...", mutates=True)),
  Clean(Task("make update-openapi", mutates=True)),
)
```

## When `Parallel` doesn't help

`Parallel` runs in the time of its slowest leaf only when the machine has room for every leaf at once. It pays for independent work, such as distinct tools or a long emulator or integration run overlapped with quick static checks, and across CI runners, each with its own cores and build directory. In these cases, keep the work in one leaf or in sequence:

- **Leaves that each fill the CPU.** Multicore tools on one machine share the same cores, so `golangci-lint` beside another Go linter, or `cargo clippy` beside `cargo test` on a warm cache, gains little over running them in sequence. `--jobs N` caps a fan-out that oversubscribes the machine; it can't make one faster.
- **Leaves behind one build lock.** Concurrent `cargo` commands over one `target/` wait on cargo's build-directory lock. Keep the batched invocation (`cargo hack`, `cargo-batch`, an `xtask`) as a single leaf.
- **A tool with its own scheduler.** Three `nix build .#attr` leaves evaluate the flake three times; one `nix build .#a .#b .#c` evaluates it once and schedules the builds itself.
- **Leaves that write the tree.** `mutates=True` orders leaves only under [`--under`](#time-budget---under). In a normal run, mutating leaves in one `Parallel` race each other and their read-only siblings, and a [`Clean`](#drift-gate-clean) gate fails on writes it didn't make. Run them first, in sequence:

```python
from camas import Parallel, Sequential, Task

dev = Sequential(
  Task("ruff format .", mutates=True),
  Task("ruff check --fix .", mutates=True),
  Parallel("mypy .", "pytest"),
)
```

To fan out across machines, let camas split the outer axes (target, chip, toolchain version) over CI runners with [`--github-matrix`](#github-actions-matrix---github-matrix) and keep each tool's batched invocation inside one leaf.

## Monorepos

A `tasks.py` composes others with `Project`. Binding one imports a child `tasks.py` as a task node — a self-contained child project — and mounts the child's own tasks under the binding name:

```python
from camas import Claude, Config, Parallel, Project

libs = Project("libs")            # camas libs, camas libs.search.lint, ...
api  = Project("services/api")    # name the handle whatever you like

_ = Config(
    default_task=Parallel(libs, api, name="all"),      # each child's default_task, in parallel
    github_task=Parallel(libs, api, name="ci"),        # each child's github_task
    agent=Claude(
        fix=Parallel(libs, api, name="fix"),           # each child's fix node
        check=Parallel(libs, api, name="check"),       # each child's check node
    ),
)
```

Name each composite (or bind it to a variable) so `camas_list` can report it as the default — an
anonymous inline `Parallel`/`Sequential` in a `Config` task field surfaces as `null`, and `camas
--check` flags that papercut.

A reference composes the child's **matching field**: the same bare `libs` grabs the child's `default_task` in `default_task`, its `github_task` in `github_task`, its fix node in `agent.fix`, its check node in `agent.check` — the slot the reference sits in selects which field of the child's own `Config` it contributes. So a `Parallel` of references in any slot is that slot composed across the monorepo, each child contributing its own. A binding name resolves by context instead — `camas libs` runs whatever a bare `camas` runs in that directory (its default locally, its `github_task` under CI, its agent default under an agent). `camas libs.search.lint` reaches a task the child exposes (because `libs/tasks.py` itself did `search = Project("search")`), and expressions compose across namespaces (`camas '{libs.search.lint, api.deploy}'`).

Nodes stay anchored where they were authored: a leaf's `cwd` and its `paths=`/`when=` scopes are relative to its own `tasks.py`, rebased across the boundary no matter where `camas` is invoked from. Because a leaf's `when=` defaults to its `cwd`, a scoped run (the gate, or `--paths`) automatically runs only the children whose directory changed — no per-leaf `when=` anywhere in a child's `tasks.py`. Children are referenced by path relative to the importing file and live within its directory. The [monorepo fixture](https://github.com/JPHutchins/camas/tree/main/tests/fixtures/monorepo) exercises the permutations.

## Effects plugins

Define an `Effect` in your `tasks.py` and it's discovered automatically — usable by name from `--effects` and listed under `camas --effects`. See [examples/effect-plugin/](https://github.com/JPHutchins/camas/tree/main/tests/fixtures/effect-plugin) for a typed `Tail` effect that streams per-task output as it arrives.

## Versioning

The public API is published through **versioned namespaces** — [`camas.v0`](https://github.com/JPHutchins/camas/tree/main/src/camas/v0) today, `camas.v1` and beyond later. Import from a generation to pin the API shape: a name a generation exports is never removed or changed within that generation. The scheme tracks semver — `v0` pairs with camas 0.x and is as loose as semver says 0.x is (the surface prefers to grow; breaking changes stay possible until 1.0, made deliberately and noted in releases). At 1.0 a generation freezes: a breaking change forces the next `camas.vN`, and published generations keep shipping, so a `tasks.py` or effect plugin pinned to one keeps working across upgrades.

The top-level `camas` namespace is the unversioned alias for the latest generation: `from camas import Task, Sequential, Parallel, Effect, Config` re-exports that generation's five headline definers and is kept **1:1** with its package surface. The rest of the public API for a generation — `TaskNode`, the `TaskEvent` stream, `LeafState`, `Completion` — lives in that generation's submodules, e.g. `from camas.v0.task_event import TaskEvent`. Everything under `camas.core` / `camas.main` is internal — it consumes whatever generations are installed and carries no stability promise.

To pin a minimum camas *feature* level, use your dependency declaration (`camas>=0.x` in `pyproject.toml`, or PEP 723 inline metadata) — the import path covers API shape; the package pin covers feature availability.

## Standalone `tasks.py` (PEP 723)

For a non-Python project that wants a single-file task runner — no `pyproject.toml`, no venv to manage — give `tasks.py` a [PEP 723](https://peps.python.org/pep-0723/) header and a `run_cli(globals())` entry point, then run it with any PEP 723-aware tool:

```python
# /// script
# requires-python = ">=3.11"
# dependencies = ["camas>=0.1.8"]
# ///
"""Build tasks for my project."""

from camas import Parallel, Task, run_cli

lint = Task("ruff check .")
test = Task("pytest")
check = Parallel(lint, test)

if __name__ == "__main__":
    run_cli(globals())
```

```bash
uv run tasks.py check     # uv reads the header, builds an ephemeral env, runs
uv run tasks.py --list    # every camas flag still works
pipx run tasks.py test
```

`run_cli(globals())` introspects the module for `Task` / `Sequential` / `Parallel` and `Effect` bindings — exactly what camas does when it auto-discovers a `tasks.py` — so the standalone file behaves identically to a discovered one: `camas <task> --help`, `--dry-run`, matrix overrides, and `--check` all work and cite the file. `run_cli` is part of the stable surface (`from camas import run_cli`, or `from camas.v0 import run_cli` to pin the generation); it's imported lazily, so a plain `from camas import Task` doesn't pull it in.

The header owns version pinning (`dependencies = ["camas>=0.1.8"]`) and the interpreter floor (`requires-python`); it's inert to `camas --check` and to auto-discovery, which read the module the same way with or without it.

## Reference

- **[examples/](https://github.com/JPHutchins/camas/tree/main/tests/fixtures)** — full project layouts under test coverage. The canonical reference for how to structure `tasks.py`, use `[tool.camas.tasks]` in `pyproject.toml`, drive a matrix from `.python-version`, or scope a 2-axis matrix from the CLI.
- **[src/camas/](https://github.com/JPHutchins/camas/tree/main/src/camas)** — typed Python with thorough docstrings. `camas --help` and `camas <task> --help` link back here.
- **`camas`** with no args runs the `Config` default task (or prints the full help when none is defined); `camas <task> --help` shows the expanded tree, matrix axes, and override flags.

## Walkthrough

The animated tree at the top is generated from this `tasks.py`:

<details>
<summary><code>examples/tauri-app/tasks.py</code> — Rust + TypeScript + Python in one tree</summary>

```python
from pathlib import Path

from camas import Config, Parallel, Sequential, Task

src_tauri = Path("src-tauri")
python_sdk = Path("python-sdk")
node = Path("node_modules/.bin")

frontend = Sequential(
    f"{node}/prettier --write .",
    Parallel(
        f"{node}/eslint src/",
        f"{node}/tsc --noEmit",
        f"{node}/vitest run",
    ),
)

backend = Sequential(
    Task("cargo fmt --all", cwd=src_tauri),
    Parallel(
        Task("cargo clippy --all-targets --locked -- -D warnings", cwd=src_tauri),
        Task("cargo test --all-targets --locked", cwd=src_tauri),
    ),
)

sdk = Sequential(
    Task("uv run ruff check --fix .", cwd=python_sdk),
    Task("uv run ruff format .", cwd=python_sdk),
    Parallel(
        Task("uv run mypy .", cwd=python_sdk),
        Task("uv run pytest", cwd=python_sdk),
    ),
)

all = Parallel(frontend, backend, sdk)

fix = Parallel(
    f"{node}/prettier --write .",
    Task("cargo fmt --all", cwd=src_tauri),
    Sequential(
        Task("uv run ruff check --fix .", cwd=python_sdk),
        Task("uv run ruff format .", cwd=python_sdk),
    ),
)

build = Parallel(
    Task("npm run tauri build {FLAG}"),
    matrix={"FLAG": ("-- --debug", "")},
    help="Debug and release builds (FLAG='-- --debug' debug, FLAG='' release)",
)

_ = Config(default_task=all)
```

</details>

```
$ cd examples/tauri-app
```

**List the tasks** —

```
$ camas --list
```

<details>
<summary>output</summary>

```
Available tasks from .../tauri-app/tasks.py:
  all       frontend | backend | sdk
  backend   cargo fmt --all, cargo clippy --all-targets --locked -- -D warnings | cargo test --all-targets --locked
  build     Debug and release builds (FLAG='-- --debug' debug, FLAG='' release)  [matrix: FLAG×2 (-- --debug..)]
  fix       node_modules/.bin/prettier --write . | cargo fmt --all | (uv run ruff check --fix ., uv run ruff format .)
  frontend  node_modules/.bin/prettier --write ., node_modules/.bin/eslint src/ | node_modules/.bin/tsc --noEmit | node_modules/.bin/vitest run
  sdk       uv run ruff check --fix ., uv run ruff format ., uv run mypy . | uv run pytest
```

</details>

**Preview what `all` would run** —

```
$ camas --dry-run all
```

<details>
<summary>output</summary>

```
all ∥
┃ frontend →
┃ ├─ node_modules/.bin/prettier --write .
┃ └─ node_modules/.bin/eslint src/ | node_modules/.bin/tsc --noEmit | node_modules/.bin/vitest run
┃   ┃ node_modules/.bin/eslint src/
┃   ┃ node_modules/.bin/tsc --noEmit
┃   ┃ node_modules/.bin/vitest run
┃ backend →
┃ ├─ cargo fmt --all  (cwd: src-tauri)
┃ └─ cargo clippy --all-targets --locked -- -D warnings | cargo test --all-targets --locked
┃   ┃ cargo clippy --all-targets --locked -- -D warnings  (cwd: src-tauri)
┃   ┃ cargo test --all-targets --locked  (cwd: src-tauri)
┃ sdk →
┃ ├─ uv run ruff check --fix .  (cwd: python-sdk)
┃ ├─ uv run ruff format .  (cwd: python-sdk)
┃ └─ uv run mypy . | uv run pytest
┃   ┃ uv run mypy .  (cwd: python-sdk)
┃   ┃ uv run pytest  (cwd: python-sdk)
```

</details>

Tree-symbol key: `∥` ends a Parallel header, `→` ends a Sequential. Children hang off `┃` (parallel siblings, run concurrently) or `├─` / `└─` (sequential steps, short-circuit on first failure).

**Discover a task's matrix axes and override flags** —

```
$ camas build --help
```

<details>
<summary>output</summary>

```
usage: camas build [-h] [--dry-run | --github-matrix [SHAPE]] [--effects EFFECTS] [--FLAG VAL[,VAL...]]

Debug and release builds (FLAG='-- --debug' debug, FLAG='' release)

runs the 'build' task:
build ∥
┃ npm run tauri build -- --debug [FLAG=-- --debug]: npm run tauri build -- --debug  FLAG=-- --debug
┃ npm run tauri build  [FLAG=]: npm run tauri build   FLAG=

Matrix axes (override with --AXIS VAL[,VAL...]):
  --FLAG  -- --debug,
```

</details>

**Pin the matrix from the CLI** —

```
$ camas build --dry-run --FLAG '-- --debug'
```

<details>
<summary>output</summary>

```
build ∥
┃ npm run tauri build -- --debug [FLAG=-- --debug]: npm run tauri build -- --debug  FLAG=-- --debug
```

</details>

