AGENTS.md@plugins/autorun · diff
git:20260816.2815b91 to git:20260816.f1daa10
117 added, 146 removed. Audit A to A.
# autorun plugin development guidance
- One file per directory, shared by every harness: `CLAUDE.md` and `GEMINI.md`
- here are symlinks to this file. Edit `AGENTS.md`; never replace a symlink with
- a second copy.
-
- Repository-level guidance (commands, installation, three-stage verification,
- plugin overview) lives in the root [`AGENTS.md`](../../AGENTS.md). This file
- covers developing the plugin itself.
-
- ## Development isolation is MANDATORY
-
- The rules and the sandbox recipe are in the root
- [`AGENTS.md`](../../AGENTS.md#development-isolation-is-mandatory) and are not
- repeated here; in one line: every install, uninstall, dry run, status probe,
- self-check, and test runs with `HOME`/`USERPROFILE`, `AUTORUN_HOME`, and
- `AUTORUN_TEST_STATE_DIR` redirected (or in Docker), and the live machine is
- touched only on the user's explicit written instruction in the current
- conversation. `pytest` isolates itself through `conftest.py`; nothing else does.
+ `CLAUDE.md` and `GEMINI.md` here are symlinks to this file. Edit `AGENTS.md`;
+ never replace a symlink with a copy. Repository-wide rules — **development
+ isolation is mandatory, and the live installation is touched only on the
+ user's written instruction** — are in the root [`AGENTS.md`](../../AGENTS.md)
+ and apply to everything below.
## Hook error prevention (CRITICAL)
Claude Code treats ANY stderr output from a hook as a hook error and discards
- that hook's JSON response. Every hook protection (rm blocking, git safety, file
- policies) silently stops working while the session still looks healthy.
-
- 1. **`pyproject.toml [tool.uv]`**: never add deprecated UV fields. UV removes fields silently across versions and prints a stderr warning for unknown ones, which breaks all hooks. `default-extras` was removed in UV 0.9+; put default extras in `[project] dependencies` instead.
- 2. **Slash commands**: every bash command in a `.md` file must use `uv run --project ${CLAUDE_PLUGIN_ROOT} python`, never bare `python3`. `allowed-tools` frontmatter must say `Bash(uv *)`, not `Bash(python3:*)`. This covers `!`-prefixed dynamic output too, for example ``!`uv run --project ${CLAUDE_PLUGIN_ROOT} python -c "from autorun.config import CONFIG; print(CONFIG['key'])"` ``.
- 3. **Hook stderr**: `hook_entry.py` must never write to stderr. Route all error handling through `fail_open()`, which writes JSON to stdout.
- 4. **Cache sync**: a fix to `pyproject.toml` or `hooks.json` reaches a harness only through the installer (`autorun --install --force`, sandboxed during development, live only on the user's written instruction). Hand-copying files into `~/.claude/plugins/cache/` is fragile and is overwritten on the next install.
- 5. **Session restart**: hook configuration is read once at session start, so `hooks.json` and `pyproject.toml` fixes take effect only in the NEXT session.
-
- Regression tests: `test_hook_entry.py::TestUVCompatibility` and
- `test_hook_entry.py::TestCacheSync`. Diagnose with `uv run --project
- <plugin_root> python -c "pass" 2>&1`; any output beyond Building/Installed
- lines is the problem.
+ that hook's JSON response, so every protection silently stops working while
+ the session looks healthy.
- ## Read and edit the git repository, not the plugin cache
+ 1. **`pyproject.toml [tool.uv]`**: never add deprecated UV fields. UV prints a
+ stderr warning for an unknown field, which breaks all hooks. `default-extras`
+ was removed in UV 0.9+; put default extras in `[project] dependencies`.
+ 2. **Slash commands**: every bash command in a `.md` file uses `uv run
+ --project ${CLAUDE_PLUGIN_ROOT} python`, never bare `python3`, and
+ `allowed-tools` says `Bash(uv *)`, not `Bash(python3:*)`. This includes
+ `!`-prefixed dynamic output.
+ 3. **`hooks/hook_entry.py` never writes to stderr**; errors go through
+ `fail_open()`, which writes JSON to stdout.
+ 4. **Cache sync**: a `pyproject.toml` or `hooks.json` fix reaches a harness only
+ through `autorun --install --force` (sandboxed; live only on the user's
+ instruction). Hand-copying into `~/.claude/plugins/cache/` is overwritten by
+ the next install.
+ 5. **Session restart**: hook configuration is read once at session start, so
+ those fixes take effect in the NEXT session.
- Work in `<git-root>/plugins/autorun/`. Never edit
- `~/.claude/plugins/cache/autorun/ar/<version>/` (the marketplace
- is `autorun`, the plugin inside it is `ar`).
+ Regression tests: `test_hook_entry.py::TestUVCompatibility` and `::TestCacheSync`.
+ Diagnose with `uv run --project <plugin_root> python -c "pass" 2>&1`; any output
+ beyond Building/Installed lines is the problem.
- Installing the plugin (`claude plugin marketplace add
- https://github.com/ahundt/autorun.git`, then `claude plugin install ar@autorun`)
- copies the repository into that cache, and the plugin loads from there, so an
- AI following a runtime path lands in the cache by accident. Edits there are not
- version controlled, are overwritten on the next install, and may already be
- behind the repository's bug fixes.
+ ## Edit the repository, not the plugin cache
- You are in the wrong place if the path contains `.claude/plugins/cache/` or a
- version directory like `1.0.0rc1/`. Recover by `cd <git-root>/plugins/autorun/`
- (`git status` must succeed, `pwd` must end in `plugins/autorun/`), make the edit
- and run the tests there, commit from the git root, then reinstall with
- `claude plugin update ar@autorun`.
+ Work in `<git-root>/plugins/autorun/`. `~/.claude/plugins/cache/autorun/ar/<version>/`
+ (marketplace `autorun`, plugin `ar`) is a copy the plugin loads from; edits
+ there are unversioned and overwritten. If your path contains
+ `.claude/plugins/cache/` or a version directory, `cd` back to the checkout.
## Feature implementation lessons
Tests set `AUTORUN_HOME` and `AUTORUN_TEST_STATE_DIR` before any autorun import
- or they reach the live daemon; full spec:
+ or they reach the live daemon; spec:
[`docs/RUNTIME_STATE_ISOLATION.md`](docs/RUNTIME_STATE_ISOLATION.md).
- Follow these when adding any new gated feature.
-
- 1. **Reuse `ScopedAllow` and `parse_scope_args` for every override grant.** Never write a second TTL/count parser: the `5m | 5 | perm | 2h30m | 2d` grammar is `scoped_allow.py:parse_scope_args`, and `scoped_allow._PARALLEL_GRACE_SECONDS` already absorbs rtk's double-hook. See `cache_guard.grant_override`.
- 2. **Use `state_get`, `state_set`, and `state_update` in daemon paths, never `session_state()`.** They keep `ThreadSafeDB` coherent; wrap legacy direct-persistence helpers in `state_synchronize`. `session_state()` is for standalone administration and persistence internals only.
- 3. **A new Claude event needs its Gemini analog wired in the same change, in three places:** `plugins.py:@app.on(...)`, `core.py:GEMINI_EVENT_MAP`, and BOTH `hooks/hooks.json` and `src/autorun/gemini_template/hooks/hooks.json`. `PreCompact` maps to `PreCompress`, which is advisory and cannot block; no `PostCompress` exists.
- 4. **New hook-stdin data needs a slot, a property, an `__init__` kwarg, and every `EventContext(...)` call site updated.** Never `getattr(ctx, "field", None)`: it returns None when the plumbing is broken instead of failing. `transcript_path` is the case that taught this.
- 5. **Features that may block tools slot AFTER TIER 1 (`/ar:ok` allows) and BEFORE TIER 2 (pattern blocks),** or an explicit allow cannot bypass the new gate. Site: `plugins.check_blocked_commands` → `CacheGuard.from_ctx(ctx).check(ctx)`.
- 6. **Keep full persistent-state reads off warm hooks.** Hydrate through `ThreadSafeDB` once per session and use atomic updates for shared fields. Coalescing file locks alone does not fix this; it still reparses the full durable state.
- 7. **Fail open when data is unknown.** A gate that errors or denies on missing fields is worse than one that allows, so CacheGuard returns `HookDecision.allow()` whenever its axis data is None. Cross-CLI robustness falls out of this for free.
- 8. **Default off.** A new gate defaults `False` in its `FeatureToggle`; users opt in with `/ar:<feature> on`.
- 9. **Anchor `.gitignore` directory patterns with a leading `/` when you mean the repo root.** Unanchored `cache/` also matches `plugins/autorun/skills/cache/`, which hid the `/ar:cache` skill from git entirely.
- 10. **Harness hook-event allowlists have one owner, `tests/harness_hook_events.py`.** Edit the sets there only, and only with a source: an unknown event name in a Claude-scanned manifest is what bug #24115 turns into a silent disable of every hook.
- 11. **Capture tool-result fixtures from a transcript's `toolUseResult` field, not its rendered `tool_result` block.** They are different objects, and only `toolUseResult` matches what the hook receives as `tool_response`. The delegation spawn ledger was built from the rendered prose `agentId: <id>` and recorded nothing in a live session, because the hook is handed `{"agentId": "<id>", ...}`, which `coerce_tool_result_to_str` JSON-encodes to `"agentId": "<id>"`. Every unit test passed against the wrong shape. `hooks/hook_entry.py` replays a captured payload from stdin; `~/.autorun/hook_entry_debug.log` cannot settle it, since it logs the stdin byte count and not the payload.
- 12. **Give every wire contract one live canary.** Fixture drift is invisible to fixture-driven tests by construction, so a contract only a real harness produces needs one real-money end-to-end check behind `AUTORUN_ENABLE_TESTS_THAT_COST_REAL_MONEY=1`. Assert on persisted state, never the model's reply: a harness exit code of 0 says nothing about whether the hook saw anything. `test_claude_live_fanout_populates_the_spawn_ledger` is the worked example, under $0.01 and ~27s. `test_pi_live_model_tool_call_is_blocked_and_the_file_survives` is the second, ~16s: it asks a live model to run one allowed command and one blocked one, then reads two files. The allowed one is not decoration — without it a model that answers in prose and calls nothing leaves the probe intact and is indistinguishable from a working guard. Pi's canary cannot be isolated the way the others are, because `auth.json` lives in the same directory as the installed extension, so redirecting `PI_CODING_AGENT_DIR` takes the credentials with it; only its working directory is temporary. Record what a canary cannot cover in `BACKEND_E2E_CONTRACTS`, which is asserted against `PLATFORMS` so a new harness cannot ship without declaring its strongest surface.
-
- ## Bug Workaround Policy
-
- Every SDK bug workaround (Claude Code, Gemini CLI, future CLIs) must follow all
- of this.
-
- **Flag** — ONE key serving as both env var and CONFIG entry:
-
- 1. Format `AUTORUN_BUG_<DESCRIPTIVE_NAME>_BUG_<NUMBER>_WORKAROUND_ENABLED`
- 2. Lookup order: env var → CONFIG dict → default `True`
- 3. Values: `true`/`1`/`auto` (affected platform) · `always` (all) · `false`/`0`/`never` (off)
-
- **Code** — a self-contained removable unit, invisible to callers:
-
- 1. One bracketed helper (`# --- BUG #N WORKAROUND START/END --- DELETE WHEN FIXED ---`) with one one-line call site
- 2. The helper checks env → CONFIG → `cli_type` (via `detect_cli_type()`, never a hardcoded name) and no-ops on unaffected platforms
- 3. It sets both the workaround AND the designed output (for example `systemMessage` AND `additionalContext`) so the designed field is ready when the bug is fixed
- 4. It preserves `respond()` print guards: `reason=""` when `systemMessage` is set (anti-double-print), and `reason=""` plus `systemMessage=""` on a PreToolUse deny (anti-triple-print with stderr)
- 5. It uses only fields in `HOOK_SCHEMAS` for that event type; `validate_hook_response()` strips the rest
- 6. Every affected site carries the bug number, full issue link, description, disable key, and deletion instruction
- 7. Removal is: delete the helper START→END, replace the call with the designed-behavior literal
-
- **Tests** — a self-contained removable block:
+ 1. **One override-grant parser.** `ScopedAllow` and `scoped_allow.parse_scope_args`
+ own the `5m | 5 | perm | 2h30m | 2d` grammar and `_PARALLEL_GRACE_SECONDS`
+ absorbs rtk's double hook; reuse them (`cache_guard.grant_override`).
+ 2. **Daemon paths use `state_get`/`state_set`/`state_update`**, never
+ `session_state()`; wrap legacy helpers in `state_synchronize`.
+ 3. **A new Claude event needs its Gemini analog in the same change:**
+ `plugins.py:@app.on(...)`, `core.py:GEMINI_EVENT_MAP`, and both
+ `hooks/hooks.json` and `src/autorun/gemini_template/hooks/hooks.json`.
+ `PreCompact` maps to `PreCompress`, which is advisory; there is no `PostCompress`.
+ 4. **New hook-stdin data needs a slot, a property, an `__init__` kwarg, and
+ every `EventContext(...)` call site updated.** Never `getattr(ctx, "field",
+ None)`: it hides broken plumbing (`transcript_path` taught this).
+ 5. **Tool-blocking features slot AFTER TIER 1 (`/ar:ok` allows) and BEFORE
+ TIER 2 (pattern blocks)** — `plugins.check_blocked_commands` →
+ `CacheGuard.from_ctx(ctx).check(ctx)` — or an explicit allow cannot bypass them.
+ 6. **Keep full persistent-state reads off warm hooks.** Hydrate through
+ `ThreadSafeDB` once per session; coalescing file locks still reparses everything.
+ 7. **Fail open when data is unknown.** CacheGuard returns `HookDecision.allow()`
+ whenever its axis data is None.
+ 8. **Default off.** A new gate defaults `False` in its `FeatureToggle`; users
+ opt in with `/ar:<feature> on`.
+ 9. **Anchor `.gitignore` directory patterns with `/`** when you mean the repo
+ root: unanchored `cache/` hid `plugins/autorun/skills/cache/` from git.
+ 10. **Harness hook-event allowlists have one owner, `tests/harness_hook_events.py`.**
+ An unknown event name in a Claude-scanned manifest is what bug #24115 turns
+ into a silent disable of every hook.
+ 11. **Capture tool-result fixtures from a transcript's `toolUseResult` field,
+ not its rendered `tool_result` block.** Only `toolUseResult` matches what
+ the hook receives as `tool_response`; the delegation spawn ledger was built
+ from the rendered prose and recorded nothing live while every unit test
+ passed. `hooks/hook_entry.py` replays a captured payload from stdin;
+ `~/.autorun/hook_entry_debug.log` logs only the byte count and cannot settle it.
+ 12. **Give every wire contract one live canary** behind
+ `AUTORUN_ENABLE_TESTS_THAT_COST_REAL_MONEY=1`, asserting on persisted state,
+ never the model's reply (`test_claude_live_fanout_populates_the_spawn_ledger`,
+ `test_pi_live_model_tool_call_is_blocked_and_the_file_survives`; Pi's cannot
+ be home-isolated because `auth.json` shares the extension directory). Record
+ what a canary cannot cover in `BACKEND_E2E_CONTRACTS`, which is asserted
+ against `PLATFORMS`.
- 1. Bracketed `# --- BUG #N TESTS START/END ---` with a shared `_BUG_FLAG` constant
- 2. Passing with the flag both True and False, covering affected+enabled, affected+disabled, unaffected, `env=always`, and `env=never`
- 3. No non-bug test depends on the block, so it can be deleted whole
+ ## Bug workaround policy
- **When fixed**: set the flag `False` for a quick disable, or delete the helper,
- replace the call with the literal, and delete the CONFIG key and test block.
- Defense-in-depth handlers stay.
+ Every SDK bug workaround (Claude Code, Gemini-family, future CLIs):
- **CONFIG template** (`config.py`, `# ─── Bug Workarounds ───`):
+ - **Flag**: one key that is both env var and CONFIG entry,
+ `AUTORUN_BUG_<NAME>_BUG_<NUMBER>_WORKAROUND_ENABLED`; lookup env → CONFIG →
+ default `True`; values `true`/`1`/`auto` (affected platform), `always`,
+ `false`/`0`/`never`. CONFIG template lives under `# ─── Bug Workarounds ───`
+ in `config.py`.
+ - **Code**: one bracketed helper (`# --- BUG #N WORKAROUND START/END --- DELETE
+ WHEN FIXED ---`) with a one-line call site; it checks env → CONFIG → `cli_type`
+ via `detect_cli_type()`, sets both the workaround and the designed output,
+ keeps `respond()`'s print guards (`reason=""` when `systemMessage` is set;
+ both empty on a PreToolUse deny), uses only `HOOK_SCHEMAS` fields
+ (`validate_hook_response()` strips the rest), and every site names the issue
+ link, disable key, and deletion instruction.
+ - **Tests**: a bracketed `# --- BUG #N TESTS START/END ---` block with a shared
+ `_BUG_FLAG`, covering flag True/False, affected/unaffected, `always`, `never`;
+ nothing outside the block depends on it.
+ - **When fixed**: set the flag `False`, or delete helper, call, CONFIG key, and
+ test block. Defense-in-depth handlers stay.
- ```
- # BUG #NNNNN: What's broken. https://github.com/anthropics/claude-code/issues/NNNNN
- # Workaround: what changes. Override: env var same name (true|false|always|never).
- # Evidence: notes/YYYY_MM_DD_*.md — Set to False when fixed.
- "AUTORUN_BUG_<NAME>_BUG_<NUMBER>_WORKAROUND_ENABLED": True,
- ```
+ | Bug | Key | Effect |
+ |-----|-----|--------|
+ | [#4669](https://github.com/anthropics/claude-code/issues/4669) deny ignored at exit 0 | `AUTORUN_BUG_CLAUDE_CODE_DENY_IGNORED_AT_EXIT_ZERO_BUG_4669_WORKAROUND_ENABLED` (`AUTORUN_EXIT2_WORKAROUND`, `--exit2-mode` are higher-precedence aliases) | stderr + exit 2 |
+ | [#18534](https://github.com/anthropics/claude-code/issues/18534) additionalContext dropped | `AUTORUN_BUG_CLAUDE_CODE_IGNORES_ADDITIONAL_CONTEXT_JSON_ENTRY_BUG_18534_WORKAROUND_ENABLED` | channel `ai` → `both` |
- | Bug | Platform | Key | Default | Effect |
- |-----|----------|-----|---------|--------|
- | [#4669](https://github.com/anthropics/claude-code/issues/4669): deny ignored at exit 0 | Claude Code | `AUTORUN_BUG_CLAUDE_CODE_DENY_IGNORED_AT_EXIT_ZERO_BUG_4669_WORKAROUND_ENABLED`; `AUTORUN_EXIT2_WORKAROUND` and `--exit2-mode` remain as higher-precedence aliases | `True` | stderr + exit 2 |
- | [#18534](https://github.com/anthropics/claude-code/issues/18534): additionalContext dropped | Claude Code | `AUTORUN_BUG_CLAUDE_CODE_IGNORES_ADDITIONAL_CONTEXT_JSON_ENTRY_BUG_18534_WORKAROUND_ENABLED` | `True` | channel="ai" → "both" |
+ `config.py:should_use_exit2_workaround()` is the worked example. Upstream
+ status (checked 2026-08-05): #4669 and #24115 closed `NOT_PLANNED`, #18534
+ closed `DUPLICATE`, #14449 `COMPLETED` by the `hooks/hooks.json` convention this
+ already targets — a closed issue is not permission to delete a workaround.
- Read `src/autorun/config.py:should_use_exit2_workaround()` and its bracketed
- block for #4669 (resolution order, value tokens, removal). The permanent
- Claude/Gemini split layout is documented in `src/autorun/installer/extension.py`.
+ ## Harness families
- Upstream status, checked 2026-08-05 with `gh`: #4669 closed 2026-01-05 and
- #24115 closed 2026-04-27, both `NOT_PLANNED`; #18534 closed 2026-01-19 as
- `DUPLICATE`; only #14449 closed `COMPLETED` (2025-12-19), by PR #14460 merging
- the `hooks/hooks.json` convention this already targets. Three of the four were
- closed without a fix, so a closed issue is not permission to delete a
- workaround. Verify the behavior first.
+ - **Gemini family**: `gemini` covers Qwen Code and Antigravity (agy); one
+ `GEMINI_EVENT_MAP`, one `gemini_template/`. Standalone Gemini CLI is retired
+ but supported (`--gemini`); see [`TROUBLESHOOTING.md`](TROUBLESHOOTING.md).
+ - **Pi family**: `prime` is Prime Agent, PrimeIntellect's Pi build
+ (`~/.prime/agent/`). `platforms.PRIME` is `dataclasses.replace(PI, ...)`;
+ `pi_template/` and `steps.pi_extension_step` serve both by substituting
+ `__AUTORUN_CLI_TYPE__`. A new Pi variant is a registry entry and a `STEPS`
+ row, never a second template.
## Trying a change end to end
- Default: the sandboxed install from the root `AGENTS.md`, then read the
- sandbox's trees and hooks. `--install-dry-run` inside the same sandbox
- previews without writing.
-
- Live machine — **only when the user has written the instruction in the
- current conversation** — from the repository root:
+ Default: the sandboxed install from the root `AGENTS.md`, then inspect the
+ sandbox's trees and hooks (`--install-dry-run` previews without writing). On
+ the live machine — only when the user has written the instruction — from the
+ repository root:
```bash
- (uv run --project plugins/autorun python -m autorun --install --force && \
- cd plugins/autorun && \
- uv tool install --force --editable . && \
- cd ../.. && \
- autorun --restart-daemon) 2>&1 | tee "install-$(date +%Y%m%d-%H%M%S).log"
+ uv run --project plugins/autorun python -m autorun --install --force && \
+ (cd plugins/autorun && uv tool install --force --editable .) && autorun --restart-daemon
```
That publishes to every detected harness, installs the `autorun`,
- `autorun-install`, and `extract-pdfs` commands globally, and restarts the
- daemon that every running session shares. Allow a 3-minute timeout: the UV
- tool step takes 1-2 minutes on a first run or a dependency change.
-
- ## Gemini-family harnesses
-
- `gemini` here covers the Qwen Code and Antigravity (agy) family, and Qwen Code
- forked Gemini CLI, so `GEMINI_EVENT_MAP`, `gemini_template/`, and the `gemini`
- platform key cover all of them. Standalone Gemini CLI is retired but still
- supported; its `enableHooks` prerequisite and legacy install live in
- [`TROUBLESHOOTING.md`](TROUBLESHOOTING.md) and [README.md](../../README.md).
-
- ## Pi-family harnesses
-
- `prime` is Prime Agent, PrimeIntellect's build of the Pi coding agent
- (`pkg.piConfig` rebrands the config dir to `~/.prime/agent/`; the bundle still
- sets `PI_CODING_AGENT=true`). `platforms.PRIME` is `dataclasses.replace(PI,
- ...)` with only identity and discovery paths changed, and `pi_template/` plus
- `steps.pi_extension_step` serve both: staging substitutes `__AUTORUN_CLI_TYPE__`
- per harness, so the installed extension reports `cliType: "pi"` or
- `"prime"` and the fallback hook carries the matching `--cli`. A new Pi
- variant is a registry entry and a `STEPS` row, never a second template.
+ `autorun-install`, and `extract-pdfs` commands, and restarts the daemon every
+ running session shares (allow ~3 minutes).
## Entry points
- - **Commands**: `commands/autorun` — executable called by the plugin system (JSON stdin/stdout)
- - **Hooks**: `hooks/hook_entry.py` — configured via `hooks/hooks.json`
- - **CLI**: `autorun` → `src/autorun/__main__.py:main` (via `uv tool install --editable .`)
- - **Config**: `src/autorun/config.py` — single source of truth for all CONFIG values
+ `commands/autorun` (plugin command executable, JSON stdin/stdout) ·
+ `hooks/hook_entry.py` via `hooks/hooks.json` · CLI `autorun` →
+ `src/autorun/__main__.py:main` · `src/autorun/config.py` (all CONFIG values).