# Development

Tend is an autonomous CI maintainer for GitHub repos: it reviews PRs, triages
issues, and fixes CI, powered by Claude or Codex. This repo ships the generator
(`uvx tend@latest`) that stamps each consumer's workflow files, plus the plugins
and composite actions those workflows run.

No backward compatibility. When a config format or API changes, cut over
completely — old formats should fail with a clear error, not silently parse.

Simplicity outranks efficiency. Complexity earns its place by preventing
wrong outward actions — what the bot posts, approves, merges, or closes —
rather than by saving compute. Prefer deleting a mechanism over refining it.
"What waste is worth fixing" below says which waste earns a change.

## Commands

```bash
wt test                                # Python, worker tests/typecheck, eval capture tests
uv run pytest                          # the Python half alone, from the repo root
uvx tend@latest init                   # regenerate workflows from .config/tend.yaml
uvx tend@latest init --dry-run         # preview without writing
uvx tend@latest check                  # verify branch protection, secrets, bot access
uv tool run pre-commit run --all-files # lint: every hook in .pre-commit-config.yaml
```

The repo root is a uv workspace, and pytest run from it collects every Python
test — generator/, proxy/, shared/steps/, and the install-tend scripts — so
one `pytest` is the whole Python run. `wt test` (defined in
[`.config/wt.toml`](.config/wt.toml)) adds worker/'s vitest suite and
typecheck, plus the deterministic eval capture tests. It never runs
models. Its arguments narrow pytest and nothing else, so a filtered run
still pays for both Node suites; `uv run pytest -k render`
is the Python half on its own.

`pre-commit` is not on the CI sandbox's PATH, which is why the lint command
above carries the `uv tool run` prefix; a narrower substitute (ruff alone,
shellcheck alone) skips most of the hooks, including the three
`repo: local` guards — the bang-backtick check, the install-tend mirror sync,
and the control-plane paths parity check.

Inside a tend session the sandbox has no DNS, so the two tests in
`generator/tests/test_refresh_consumers.py` fail: they drive the script
through `uv run --script`, which resolves its dependencies from PyPI. Nothing
else in the suite reaches the network, so a third failure is a real one.

## Architecture

Four pieces:

1. **Plugins** — `install-tend` (user-facing setup) and `tend-ci-runner` (CI
   skills). Both ship from the same marketplace.
2. **Composite actions** — the stable interface, pinned to an immutable
   release tag (`max-sixty/tend/<action-path>@X.Y.Z`, the generator's own version
   — no floating `v1`). Every action lives under a harness-named path; there
   is no bare-root default. The two harness runners are:
   - `max-sixty/tend/claude@X.Y.Z` (Claude) — runs the official `claude`
     binary headless (`claude -p`) inside the shared sandbox, a hardened
     systemd unit; completion is the process exit code plus result event.
     Inputs in `claude/action.yaml`.
   - `max-sixty/tend/codex@X.Y.Z` (Codex) — installs `@openai/codex` and
     shells out to `codex exec`. Skills are staged on disk and an
     `AGENTS.md` in `$CODEX_HOME` teaches Codex to resolve
     `/tend-ci-runner:NAME` slash commands. Inputs in `codex/action.yaml`.
     Shares the cross-harness workspace, sandbox lifecycle, preflight, and
     teardown scripts under `shared/steps/`.

   Both harness runners resolve the bot's numeric ID at runtime, run security
   and rate-limit preflight, then select the event's topology and run the
   complete agent turn in one systemd unit over a copy-on-write view of the
   runner's home, reap it, and upload bounded session logs. The generated
   workflow's checkout stays on the default or PR base tree for setup and
   local-action POST chains; nothing the agent writes reaches it.

   `max-sixty/tend/codex/refresh@X.Y.Z` is the Codex support action. A generated
   serialized workflow runs it weekly to rotate Plus/Pro credentials and
   publish the full bundle to `tend-codex-refresh` and access-only auth to
   `tend`. A preceding `tend` job passes only a consumer-auth presence flag,
   so a missing refresh credential fails the subscription run. It holds no bot token and does not
   inspect consumer code. Inputs in `codex/refresh/action.yaml`.

   Removed: `claude-interactive`, a PTY-supervised variant of the same binary
   that existed only to dodge the 2026-06-15 Agent SDK metering (which covered
   `claude -p` but not interactive sessions). Anthropic paused that change and
   the default harness now runs the binary rather than the SDK, so nothing
   selected it. Restore from `036f9c4` if the metering resumes.
3. **Generator** (`uvx tend@latest init`) — stamps workflow files into
   the consumer's `.github/workflows/` from `.config/tend.yaml`. Picks the
   right action ref and secret names per `harness`. Generation is
   idempotent — running `init` again overwrites all files from the
   current config. When the review workflow is generated, it also merges the
   `concurrency.queue` ignore into the consumer-owned
   `.github/actionlint.yaml` (see "Concurrency and filtering").
4. **Config** (`.config/tend.yaml`) — inputs to the generator. Overrides
   from defaults only. `harness: claude | codex` selects the harness
   (default `claude`). A per-workflow `harness:` override (and matching
   `model:`) lets a consumer trial a different harness on one workflow at a
   time. All workflows are generated by default. `enabled: false` omits a
   workflow on regeneration. Pausing all of tend is not config: every job that
   runs the agent checks the `TEND_ENABLED` repository variable in its `if:`.

Generated workflows are standalone — full `steps:` jobs, not
`workflow_call`. The generator owns the entire file. Setup (system tools,
dependencies, Actions caches) is defined in `setup:` and runs on the default
or PR base tree; the agent sees what it built through the view, and installs
whatever the event's own tree changes in the session.

## Structure

```
tend/
├── .claude-plugin/
│   └── marketplace.json  # Claude Code marketplace — lists both plugins
├── .agents/plugins/
│   └── marketplace.json  # Codex marketplace — lists both Tend plugins
├── plugins/
│   ├── install-tend/     # User-facing plugin (setup skill)
│   └── tend-ci-runner/   # CI plugin (review, triage, ci-fix, etc.)
│       ├── .codex-plugin/  # Codex plugin manifest
│       └── scripts/      # Helper scripts (survey, run listing)
├── claude/
│   └── action.yaml       # Claude harness composite action (default, headless)
├── codex/
│   ├── action.yaml       # Codex harness composite action
│   ├── refresh/
│   │   └── action.yaml   # Serialized Plus/Pro credential refresh action
│   ├── runner.py         # Codex harness commands (plugin, prompt, execution)
│   └── agents-tail.md    # AGENTS.md appendix for Codex
├── shared/
│   ├── steps/            # Shared composite-action step bodies (Python; bash for the install/plumbing ones)
│   └── system-prompt.md  # Harness-neutral system prompt base
├── proxy/                # Credential-injection proxy (setup_sandbox.py, addon)
├── generator/            # Python package (uvx tend@latest), uv_build backend
│   ├── src/tend/
│   │   ├── config.py     # Reads .config/tend.yaml
│   │   ├── workflows.py  # Generates workflow YAML
│   │   ├── checks.py     # Security checks (branch protection, secrets)
│   │   └── cli.py        # Click CLI (init, check)
│   └── tests/
├── site/                 # Astro marketing site (tend-src.com)
├── worker/               # Cloudflare Worker — serves the 2 site data streams
├── data/                 # consumers.json — Worker's input (refreshed weekly)
├── docs/
│   └── security-model.md
└── pyproject.toml        # uv workspace root: dev deps + the lockfile
```

## Key details

The `tend-*.yaml` files in `.github/workflows/` are generated by `uvx tend@latest init`
from `.config/tend.yaml`. Edit the generator or config, not the workflow files
directly.

The generator is a Python package under `generator/` — uses the uv_build
backend, requires Python 3.11+. Runtime dependencies: click, jinja2,
ruamel.yaml. It is the one member of the repo's uv workspace, so the lockfile
and the dev dependencies (pytest, pytest-regtest) live in the root
`pyproject.toml`, and the dev environment needs 3.12+ even though the package
supports 3.11. The root's `proxy` group (mitmproxy) is not dev-only: the
harness actions install the credential proxy from its locked closure in
`uv.lock`, so a lock change there ships to consumers with the next release.

Consumer repos regenerate their `tend-*.yaml` workflows nightly (tend itself
included — it dogfoods its own workflows). Changes to the generator do not
require manual regeneration in downstream repos.

Tend's own `tend-*.yaml` workflows track the latest published release. They
update each night via `uvx tend@latest init`. Updating earlier to the latest
release (e.g., during a release commit) is fine. Never regenerate them with
the in-tree generator: the action ref is pinned to the generator's own
version (`max-sixty/tend/<harness>@X.Y.Z`), so an unreleased in-tree version
stamps a tag that does not exist yet, and the workflow's `uses:` fails to resolve.
Between a generator commit and the next release the committed workflows lag
the in-tree generator; that is expected, and the gap closes at the next
release (which tags `X.Y.Z` before regenerating, so the pin always resolves).

`claude_version` in `claude/action.yaml` is an exact version taken from npm's
`latest` dist-tag, not `stable`. `stable` lags several releases, and pinning it
wouldn't keep un-promoted code out of the job anyway: `install.sh` always
downloads the `latest` build as its bootstrap installer, and only the agent
session runs the pin.

## Generator vs consumer ownership

| Aspect | Owner | Lives in |
|---|---|---|
| Trigger events (`on:`) | Generator | generated workflow |
| Filter conditions (`if:`) | Generator | generated workflow |
| Engagement verification (mention) | Generator | generated workflow |
| Concurrency groups | Generator | generated workflow |
| Permissions | Generator | generated workflow |
| Checkout | Generator | generated workflow |
| Composite action call | Generator | generated workflow |
| Setup (system tools, dependencies, Actions cache) | Consumer | `setup:` in `.config/tend.yaml` |
| Bot identity, auth config | Consumer | `.config/tend.yaml` |
| Skills (generic) | Tend | `tend-ci-runner` plugin (marketplace) |
| Skills (project-specific) | Consumer | `.claude/skills/` in their repo |

## Workflow overrides

Consumers extend generated workflows via YAML keys that the generator
merges into the rendered YAML using RFC 7396 (JSON Merge Patch — mappings
deep-merge, scalars and lists replace, `null` deletes):

```yaml
workflows:
  review:
    workflow_extra:
      env:
        MY_VAR: hello
    jobs:
      review:
        timeout-minutes: 240
        runs-on: ubuntu-22.04-large
```

YAML has a native `null` literal, so RFC 7396's null-deletes works
directly — e.g. drop the cron from a scheduled workflow while keeping
`workflow_dispatch`:

```yaml
workflows:
  nightly:
    workflow_extra:
      on:
        schedule: null
```

Workflow-level (`workflow_extra`) and job-level (`jobs.<name>`) overrides
are supported in restricted merge mode; step-level is not — `setup:` handles
injected runner steps in both modes. Yolo refuses the two override forms so
credential-bearing jobs retain their audited shape. Runner-side setup in yolo
is a trial: bot-merged code may execute there before the sandbox starts.
No allowlist of override keys; unknown job names produce a warning.

When overrides are present, the generator renders the base template,
parses it, merges the overrides, and re-serializes. Output YAML formatting
differs slightly from the base template (block-style lists) but is
functionally identical.

## Auth

Each consumer creates a GitHub bot account and a classic PAT with `repo`,
`workflow`, `notifications`, `write:discussion`, `gist`, and `user`. `repo`
is required on public and private repositories alike — `install-tend` mints
exactly that set, and the nightly scope audit (`pat_scope_audit.py`) reports
anything narrower as missing. The PAT and a Claude OAuth token are
stored as secrets in the repo's `tend` GitHub Environment, whose deployment
branch policy admits the default branch and any `protected_branches` that
exist and are protected. A workflow the bot pushes to any other ref is
refused them before its first step. Codex subscription refresh credentials live
in `tend-codex-refresh`, whose policy admits only the default branch; agent
jobs never name it. In `restricted` mode the bot cannot
move any admitted branch. In `yolo`, it may merge pull requests to the
default branch, while a CODEOWNERS-backed ruleset reserves Tend's workflows
and config, CODEOWNERS, and agent instructions for a maintainer owner. Two things use the `gist` scope, both
through bot-owned secret gists: `review-reviewers` keeps a per-month
structured evidence store (avoids the 65 KB comment-body limit), and the
experimental `memory_gist` setting persists Claude Code's auto memory
across runs. The `user` scope lets `install-tend` set the bot's profile
bio (`PATCH /user`) so the account's authorization stance is discoverable
on the bot's user page.

The agent may use PAT-backed GitHub API and git access for any repository the
bot account can reach; this cross-repository access is intentional. Credential
isolation keeps the PAT itself out of the agent process. If the PAT is stolen
by compromising the runner or proxy, its `repo` scope grants full write to
every repository the bot account can reach, private ones included.
Fine-grained PATs allow per-category scoping but don't support outside
collaborators ([GitHub roadmap
#601](https://github.com/github/roadmap/issues/601), not shipped).

**Current privilege model: write + merge mode + environment gate.**
The bot has write access. `merge: restricted` keeps it out of the default branch;
`merge: yolo` grants a pull-request-only bypass there, while a second
ruleset requires fresh CODEOWNER approval for `.github/**`,
`.config/tend.yaml`, the CODEOWNERS files themselves, and agent instructions
(`CONTROL_PLANE_PATHS` in `workflows.py`). Extra protected
branches and tags remain admin-only.
The `tend` environment releases operational secrets only on those configured
branches. Generic credential environments may admit the default branch under
either merge mode: yolo deliberately exposes those credentials to bot-merged
code on that branch. Other admitted branches and tags need bot-inaccessible
ref protection or a non-bot reviewer. In yolo mode, Tend also requires exact generated
workflows and rejects other workflows whose environment use is dynamic or
hidden in an external or ref-qualified reusable workflow. `tend check` verifies the complete
policy, and `--fix` reconciles it. See
`docs/security-model.md` for the full threat model. Alternative models
(GitHub App, triage+fork) are in `TODO.md`.

Every consumer runs the environment gate: both secrets live in the `tend`
environment with no repo-level copies. This repo's own workflows name it
too, including the hand-maintained ones the generator never touches.

## Concurrency and filtering

Events pass through three layers before the bot does work:

1. **GHA `if:` conditions** — evaluated by Actions before the job starts.
   A false condition skips the job entirely (never enters the concurrency
   group, never queues).
2. **Custom `should_run` pre-checks** — cheap deterministic steps that decide
   whether the agent boots: mention's verify job checks engagement, and
   notifications' check repairs repository watching, captures a paginated
   cutoff snapshot, and finds configured-bot PR conflicts whose current heads
   have not already been deferred for manual resolution.
3. **Concurrency groups** — at most one running job per group.

Concurrency groups:

| Workflow | Group key | Cancel-in-progress |
|---|---|---|
| review | `workflow-PR#` | **no** — killing a session discards a review it can still deliver; `queue: max` holds pending PR events within GitHub's queue limit while it folds the push in and posts |
| mention-relay | none | stateless — secretless job that re-posts review events to mention as a `repository_dispatch` |
| mention/verify | none | stateless |
| mention/handle | `workflow-handle-issue#\|PR#` | **no** — each mention runs to completion |
| triage | `workflow-issue#` | yes — latest comment wins |
| notifications | `tend-notifications` | **no** — one poll drains notifications and repairs bot PRs at a time |
| ci-fix | `workflow-<watched workflow>-<branch>` | **no** — a session mid-fix may already have pushed a branch or opened a PR |
| nightly / weekly | none | cron-serialized |
| codex-auth-refresh (codex only) | `tend-codex-auth-refresh` | **no** — only this workflow may rotate the refresh-token chain, so a second refresher must never race it |

**Fork guard.** Workflows whose triggers can fire from a fork's own
Actions (`schedule`, `workflow_dispatch`, `workflow_run`, `issues`) carry
`if: github.repository_owner == '<owner>'` so a fork that's enabled
Actions but doesn't have the bot/Claude secrets no-ops cleanly. The
canonical owner is detected at `init` time (via `gh repo view`, walking
`source.owner.login` if the local repo is itself a fork) and pinned in
the generated workflow. `tend-review` uses `pull_request_target` (base
repo only) and `tend-mention-relay`, which carries tend-mention's review
events, already filters forks via `head.repo.full_name ==
github.repository`, so neither needs the guard.

**Red branches.** A red default branch fails every push that follows it, each
on its own commit, so ci-fix keys its group on the branch — a commit-keyed
group collapses nothing, since the burst is distinct commits rather than one
commit retried. The watched workflow is in the key too, so a red
`publish-site` isn't starved behind a stream of red `ci`. ci-fix's group is
job-level, not workflow-level: most `workflow_run` events are green runs the
job's `if` skips, and a skipped job never enters the group.

**GHA queue depth.** Review sets `queue: max`, so pending PR events within
GitHub's queue limit wait and a later push cannot replace `ready_for_review`.
GitHub accepts the key; actionlint's schema does not, so `init` merges the
matching ignore into `.github/actionlint.yaml`, which the binary reads for
every invocation.
Mention/handle and notifications keep the default one-pending-run queue; when a
third job arrives while one runs and one queues, the pending job is replaced. For mention,
mitigation lives in the skill prompts: dedup if the bot already responded to
the triggering comment; self-heal earlier comments without a bot reply (oldest
first). The workflow injects the queue-to-run time delta (seconds between event
timestamp and job start) into the prompt — over ~40 s indicates the job was
queued behind another run, making conversation drift more likely.
Notifications stay unread until a poll records an outcome, so the newest
pending run covers a replaced poll. ci-fix keeps the default too, and wants
it: while a session works a red branch, the newest unsuccessful run carries
that branch's current state, so replacing the pending run loses nothing.

## Shipped instructions and tend's own

Two kinds of instructions live in this repo, split by which repo the text
governs.

**Shipped instructions** are whatever text reaches a repo other than this one.
They tell the bot what to do in a repo it maintains, and have to hold in repos
nobody here has seen. More of them than `plugins/**`: `shared/system-prompt.md`,
`codex/agents-tail.md`, the header comment `workflows.py` stamps into every
generated workflow, and `docs/tend.example.yaml`, which a pre-commit hook
mirrors into `install-tend`.
Before writing instructions outside `plugins/`, check whether they end up in a
consumer's repo or session.

**Tend's own instructions** are read only here: `AGENTS.md`, `TODO.md`,
`docs/security-model.md`, and `.claude/skills/`. They tell a session — the
bot's or yours — how to work on tend.

The test for a line of shipped text: does it hold in a repo whose maintainer
disagrees with us? What tend values, what this repo will spend complexity on,
how many open PRs is too many, how much runner time is worth saving — those are
one maintainer's calls. Shipped, they make every consumer's repo answer to
tend's; they belong here instead. Mechanics ship freely: how GitHub behaves,
what the harness does, what a session must never do. A default a consumer might
want differently ships as a default their overlay can change, stated without
our reasoning for it.

Two naming decisions. A repo that installs tend is a **consumer**, the term
`data/consumers.json`, `refresh_consumers.py`, and the Worker already use — not
an adopter, which the changelog keeps only in released entries. And the second
kind isn't `internal`: a skill's `metadata: internal: true` already
means it isn't user-invocable, and every shipped skill carries it.

### Bundled and overlay

Within shipped instructions, `plugins/tend-ci-runner/skills/` holds the defaults
and a consumer overlays them at `.claude/skills/running-tend/SKILL.md`; where
the two conflict, the overlay wins. `.agents/skills` links to `.claude/skills`,
so Claude and Codex discover the same repo-local skills. Repo-specific policy,
taste, or convention (PR title formats, label names, branch routing) goes in an
overlay. Use outcomes from consumer runs to refine the defaults: a general
missing instruction or wrong default is a bundled fix, repository policy an
overlay one.

Tend's overlay here is tend's own instructions by the rule above — it doesn't
ship, and it carries the tasks and conventions for bot sessions in this repo. A
whole skill belongs there when only this repo's workflows invoke it:
`.claude/skills/review-reviewers/` is one, dispatched by the hand-maintained
`review-reviewers.yaml`, whose prompt invokes it as `/review-reviewers` — a
slash command like any skill, without the plugin prefix. A tend-only skill
left in the plugin installs into every consumer, is invoked by none of them,
and collects tend's internal text next to the files they do load.

### Which file

Shipped or not, a rule is **loaded** by one set of sessions and **acted on** by
a subset of them. Put it at the narrowest level every actor still reads: the
sessions in the gap each pay attention for text that changes nothing they do.

| File | Read by | Ships |
|---|---|---|
| `shared/system-prompt.md` | every session, both harnesses | yes |
| `run-tend/SKILL.md` | every session | yes |
| a workflow's `SKILL.md` | that workflow's sessions | yes |
| a per-action `SKILL.md` | only the sessions taking that action | yes |
| a skill's `references/` | sessions of the skills whose steps cite it | yes |
| `.claude/skills/` (the overlay, and any skill only this repo invokes) | sessions in that one repo | no |
| `AGENTS.md` | sessions working on the repo it sits in | no |

- **A rule has one home.** Another file that needs it names the section
  instead of restating it — copies drift, and a partial copy drops what the
  copier left out. Before adding a rule, search for a distinctive phrase from
  it across `plugins/`, `shared/`, and `AGENTS.md`.
- **Every workflow that invokes the agent names a skill** (`default_prompt` in
  `config.py`). One without a skill has nowhere to put its own rules, so they
  land in the every-session file instead.

When reviewing or surveying a skill, a reference, or a project instruction
file, ask both questions of it — does this ship, and who acts on it — and ask
them of the sections a change leaves in place, not only the ones it touches: a
restructure that moves three sections and keeps four has reviewed three.

### Authoring skills

When adding to or editing files in `plugins/tend-ci-runner/skills/` or
`.claude/skills/`:

- **Be brief.** Skills are loaded into every relevant session — extra prose
  is overhead. Lead with the rule or recipe; cut motivation, anecdotes, and
  historical context unless required to apply the rule.
- **An action more than one workflow takes gets its own skill**, named for
  the action and described by the situation that calls for it
  (`post-to-github`, `push-commits`, `open-pr`). The skill listing every
  session already carries is the index, so `run-tend/SKILL.md` names only the
  handful nearly every task needs and sends the session to the listing for the
  rest — don't rebuild a table of them. A pointer gets read where the session
  already is when it acts: that listing, or a step in a skill it has loaded.
  So text keeps its `references/` file while the skills whose steps cite it can
  be named — `review`'s four, two of them also reached by `weekly` and
  `review-runs` from their own steps. Where instead the need arises from an
  action any workflow might take, no skill body can be relied on to raise it
  and the listing has to: that is a skill. A repo overlay's references stay in
  the overlay, named where its own steps use them.
- **Cite a skill as `/tend-ci-runner:<name>`**, and a reference file by its
  path from the owning skill's directory: `` `references/<file>.md` ``, with
  the owning skill in front where the file belongs to another skill
  (`` `/tend-ci-runner:review`'s `references/approving.md` ``). That holds
  inside a `references/` directory too, where the file being cited is a
  neighbour — one form reads the same wherever the sentence ends up, and
  `test_skill_reference_citations_resolve` rejects the bare filename. Only
  files a repo keeps at its root (`CLAUDE.md`, `AGENTS.md`, `CONTRIBUTING.md`)
  are named bare; `ROOT_FILES` in that test is the list. To point at a rule
  rather than a whole file, name its section and the skill, which also covers
  a rule in a skill's own `SKILL.md`: **Reader-facing prose** in
  `/tend-ci-runner:run-tend`.
- **No specific past-run references.** Don't link GitHub Actions runs, cite
  session IDs, or quote durations from individual incidents. They age into
  trivia and aren't useful when the skill is reused. State the structural
  rule without the run links.
- **No specific past-case references.** Don't cite individual past PRs,
  issues, or commits as supporting precedent for a rule (e.g. "[#123] tried
  X and was closed"). They age into trivia, accumulate as the rule is
  re-tested, and create read-time work. State the structural rule (what
  shape is accepted, what shape is not) without naming the cases that
  produced it.
- **Date-stamp only when the value depends on time.** A baseline number
  expected to drift can be dated; a one-shot incident citation should not be.
- **Prefer recipe over narrative.** A code block plus a one-sentence framing
  beats a multi-paragraph explanation.
- **Examples over templates; open frames over closed menus.** For text the
  agent *produces* (comments, PR bodies, summaries), give an example labeled
  as such rather than verbatim wording to paste — canned phrasing reproduced
  literally reads awkwardly off-situation. For choices the agent *makes* (when
  to respond, which cases to check), frame the goal and give examples rather
  than a list it will read as exhaustive. Reserve mandatory exact wording for
  fragile mechanics: dedup keys, commands, API formats.

## Agent-driven vs deterministic steps

Tend's workflows invoke the agent through the harness-specific composite
action (`max-sixty/tend/claude@X.Y.Z` for Claude, `max-sixty/tend/codex@X.Y.Z`
for Codex). When adding new capability, split work along this line:

- **The agent drives diagnostics and remediation.** Once the action is
  running, put logic into the relevant skill (or a script the skill calls —
  see `plugins/tend-ci-runner/scripts/`). The agent handles edge cases,
  interprets output, and writes clearer messages than shell.
- **Actions gate whether the agent runs at all.** Agent invocations cost
  tokens; gating them in YAML is cheap. Pre-check steps that early-exit
  the job (e.g. `tend-notifications`'s notification and PR conflict check)
  save an entire agent run when there's nothing to do. A gate stays cheap
  only while it stays trivial: one pre-check against a frequent no-op (an
  empty inbox every cron tick) earns its place; a run of bespoke gates,
  each skipping one rare event shape, is machinery that costs more than
  the boots it saves.

Keep the actions' Bash as linear installation and plumbing, not a second
implementation layer. Do not add branches, retries, caches, or state for rare
event shapes or small runner-time savings; put substantial behavior in a skill
or tested Python.

Don't build deterministic YAML steps for work that happens *inside* an
agent run. Extend the skill instead.

### What waste is worth fixing

Wasted runner time (a slow CI job, a run lost to a blip that a later tick
retries) costs cents, while the retry wrapper, cache, or scheduling
arithmetic that would prevent it is maintained forever: fix it only with a
simple knob — a cadence value, a deleted step, a one-line condition — and
otherwise leave it. Model tokens are the larger cost, dollars per session,
so keep sessions, and the context each one carries, to what the work needs.

Judge a token saving by the share of total spend it removes. A change that
materially cuts the total — a frequent boot that never had work to do, bulk
reading carried into every deciding context — is worth the mechanism it
leaves behind, so estimate that share before proposing one. A small or rare
saving doesn't justify complication, however well the change reads line by
line.

Judge the fix itself by the whole change, and by what it leaves behind —
logic a future session must re-derive, a rule every later run loads, failure
modes of its own — rather than by its line count. The same setting repeated
across workflows, jobs, platforms, or call sites is a configuration scheme
rather than one knob, and a mechanism compressed into one dense line is
still a mechanism.

This is tend's answer, and it stays here. The bundled skills carry no view on
what compute is worth — a bot session in this repo reads this file, and a
session in another repo reads that repo's.

## Live testing against real GitHub

For live experiments against real GitHub behavior (environments, branch
protection, workflow triggers, secret release), `tend-agent/tend-integration`
is a persistent public repo we own, admin via the `tend-agent` account
(`gh auth token --user tend-agent`). Its `main` is branch-protected with
`enforce_admins: false`, so the owner can push directly and reset in place.
The weekly integration test (`.claude/skills/running-tend/references/integration-test.md`)
also drives it, so clean up any probe artifacts (extra workflows, branches,
environments, dummy secrets) when done.

## Site and worker

`site/` is the Astro marketing site (tend-src.com); `worker/` is the
Cloudflare Worker that serves its two data streams from `data/consumers.json`.

The site's dev server starts automatically per worktree via a `wt` post-start
hook (`.config/wt.toml`) on a deterministic port derived from the branch name.
Get the URL with
`wt list statusline --format json | jq -r '.items[].dev_server.url'`; logs land
in `.git/wt/logs/`. Don't run `npm run dev`; it duplicates the running server on
a different port.
