Watch
0
0
Fork
You've already forked hyperhive
0

rename claude.md to agents.md for agents and the repo

Claude Code (>= 2.1.277) and opencode both read AGENTS.md, but Claude
Code reads it only when no CLAUDE.md exists in its cwd or above. So
this is a plain rename with no CLAUDE.md stub left behind.

- hive-agent/src/agents_md_migrate.rs: new. At harness start, before
  the first turn, renames state/CLAUDE.md to state/AGENTS.md when only
  the former exists; logs an error naming both paths when both exist
  and touches neither; does nothing otherwise. Idempotent.
- hive-agent/src/main.rs: calls it in serve_main; checkpoint prompt
  and a comment name AGENTS.md.
- hive-agent/src/claude_md_watch.rs: the size watch reads AGENTS.md;
  todo text, doc comments and tests follow. Module name, todo
  subsystem key ("claude_md") and metric name
  (hyperhive.agent.claude_md.lines) are unchanged so existing todo
  rows and dashboard history carry over.
- hive-agent/src/turn.rs, hive-agent/prompts/system.md: prompts tell
  the agent its file is AGENTS.md.
- hive-agent/src/{otel_turn_metrics,stats}.rs,
  frontend/packages/agent/src/stats.js, hive-subagent-mcp/src/*.rs:
  comments and a tool parameter description naming the agent's file.
- nix/host-modules/swarm-grafana/dashboards/agents.json: panel title
  and description.
- claude-plugins skills claude-subagents, state-hygiene: they mean the
  agent's own state file.
- CLAUDE.md -> AGENTS.md at the repo root; docs/crates/README.md link
  (checked by check-doc-refs.sh), hive-c0re/README.md,
  .prettierignore and the nix/sources.nix comment follow.
- docs/: every prose mention of the agent's file; persistence.md
  documents the boot-time rename, claude-invocation.md the shadowing.

Fixes #5509
This commit is contained in:
atlas 2026-10-18 01:06:47 +02:00 • committed by mara
commit a429b30448
25 changed files with 217 additions and 45 deletions

View file

@ -14,7 +14,7 @@ docs/tools/swarm-metrics-cli.md
# prettier's `proseWrap: "preserve"` prevents prose reflow but not list-item # prettier's `proseWrap: "preserve"` prevents prose reflow but not list-item
# indentation normalisation, so these files need manual re-wrap before # indentation normalisation, so these files need manual re-wrap before
# adding them back to the formatter scope. # adding them back to the formatter scope.
CLAUDE.md AGENTS.md
docs/agent-lifecycle/config-changes.md docs/agent-lifecycle/config-changes.md
docs/process/conventions.md docs/process/conventions.md
docs/networking/gateway.md docs/networking/gateway.md

219
AGENTS.md Normal file
View file

@ -0,0 +1,219 @@
# hyperhive — claude entry point
Hey claude. This is your starting page. The detailed docs live in
[`docs/`](docs/) and are written for humans + you both — read them
when you need depth on a subsystem. This file is the index.
- High-level project intro: **[README.md](README.md)**.
- Open work + backlog: the forge issue tracker at
**`$HIVE_FORGE_URL/hyperhive/hyperhive/issues`**. Read the host out of
the env var — inside an agent container `localhost` is the *agent*, not
the forge, so a hardcoded loopback address fails to connect.
- Operator/agent trust-boundary design:
**[docs/trust-boundary/boundary.md](docs/trust-boundary/boundary.md)** (`area/ops` issues
for the deployment/gateway/privsep work — the separator is a **slash**,
and `--label area:ops` now fails with `did you mean "area/ops"?`).
- Agent trust model (trust boundary, prompt-injection threat model,
capability = accepted risk), credential isolation + sandbox threat model:
**[docs/trust-boundary/security.md](docs/trust-boundary/security.md)**.
- Odd-looking nspawn / container plumbing usually has a story:
**[docs/process/gotchas.md](docs/process/gotchas.md)**.
## Build & check
Always through the devshell — a bare `cargo` fails (`failed to find tool
"cc"`, `cannot find -lsqlite3`). Details:
[`docs/process/conventions.md`](docs/process/conventions.md#building--local-checks).
```sh
nix develop -c cargo clippy --all-targets -- -D warnings
nix develop -c cargo test
nix fmt # treefmt is the CI gate, NOT bare cargo fmt
```
Changed a `hivectl` / `swarmctl` verb or flag? Regenerate its doc — only
CI's `nix flake check` catches the drift:
`nix develop -c cargo run --bin hivectl -- markdown-docs > docs/tools/hivectl-cli.md`
(same for `swarmctl` → `docs/tools/swarmctl-cli.md`). CI red but local
green → build the suspect check (e.g. `nix build
.#checks.x86_64-linux.hivectl-docs`) before blaming the builder.
## Repo map
One line per crate / top-level dir. **Each module's authoritative,
always-current description lives in its own `//!` doc-comment** —
`grep`/read the module when you need detail. This index is kept
deliberately lean: it auto-loads into every turn's context, and a
hand-maintained per-file tree drifts out of sync with the code.
### Rust workspace (`Cargo.toml` members)
Per-hive (host + in-container):
- **`hive-c0re/`** — host daemon (runs as the unprivileged `hive-core`
user), **daemon-only** — the operator CLI is `hivectl`. Owns the sqlite
broker, reminder queue, meta flake, lifecycle (`nixos-container`
shellouts), gateway wiring, per-container stats and the axum operator
dashboard. Agent forge/matrix accounts are `swarm-controller`'s. Largest
crate.
- **`hivectl/`** — operator CLI; talks to `hive-c0re` over the host admin
socket, does NOT link it. Verb reference (CI-enforced):
[`docs/tools/hivectl-cli.md`](docs/tools/hivectl-cli.md).
- **`hive-priv/`** — minimal root helper, socket-activated at
`/run/hive/priv.sock`; the few root ops (bind-mount edits, nsenter)
`hive-c0re` delegates. See `docs/trust-boundary/boundary.md`.
- **`hive-agent/`** — the in-container serve loop: turn-loop *policy*
(`turn.rs`) over `hive-runtime`, per-agent web UI, event + turn-stats
sinks, system-prompt renderer, claude launch config (`mcp_config.rs`).
With `hive-agent-mcp` it's what "hive-ag3nt" refers to — there is no
such directory.
- **`hive-agent-mcp/`** — the embedded MCP server (`hive-mcp-http` unit).
- **`hive-runtime/`** — the `Runtime` interface an agent's turns run on:
a `claude` backend (over `hive-claude`) and an `acp` backend whose stream
is translated into claude's `stream-json` shape. Links no hyperhive
binary crate, so the subagent daemon uses it too.
- **`hive-bash-mcp/`**, **`hive-matrix-mcp/`**, **`hive-subagent-mcp/`** —
per-agent MCP daemons serving streamable-http directly (no stdio
bridge): background bash tasks, matrix-sdk tools, nested subagent
sessions (optionally goal-driven over several turns; daemon state is
in-memory, the claude session survives a restart).
- **`hive-screen-mcp/`** — stdio MCP bridge for GUI agents: screenshot,
keyboard, mouse. All userspace.
- **`hive-forge/`** — `hive-forge` Forgejo CLI; one module per verb.
- **`hive-forge-notify/`** — notification pollers → todos on the in-agent
socket. Two binaries: `hive-forge-notify`, `hive-github-notify`.
- **`hive-jobq/`** — the job-DAG scheduler library. **Runtime-only —
nothing writes the graph to disk**, so node ids are stable within a run,
not across restarts. `hive-c0re`'s `job_queue/` is being removed in its
favour — new scheduler-shaped code goes here.
- **`hive-jobq-wire/`** — presentation of a `hive-jobq` graph to viewers
(`WireNode` / `WireResource`); kept out of `hive-jobq` on purpose.
- **`hive-jobq-metrics/`** — OTEL export of a graph's state rollup on a
timer.
- **`hive-log/`** — the one log-sink chooser: journald *or* stdout, never
both (under a unit, stdout already is the journal). Do not grow it into a
utility crate.
- **`hive-metric/`** — one-shot CLI pushing a labeled metric over OTLP.
Wire types + socket plumbing:
- **`hive-types/`** — leaf newtypes, chiefly the validated `Ident`.
- **`hive-sh4re/`** — shared payload types (`Message`, `HelperEvent`, …).
- **`hive-host-sock/`** (hivectl ↔ c0re), **`hive-priv-sock/`** (c0re ↔
priv), **`hive-core-agent-sock/`** (harness ↔ c0re, `/run/hive/mcp.sock`)
— per-socket wire types, split out so each client links only its protocol.
- **`hive-agent-sock/`** — the *in-agent* socket the harness serves to
in-container producers (todos, reminders); also holds the `extra_mcp`
spec. ⚠️ **This socket never leaves the container** — `hive-c0re` is not
in the path, unlike `hive-core-agent-sock`.
- **`hive-sock-client/`** — the shared JSON-line unix-socket client; retry
is a policy value (`Retry::None` / `Retry::RideOutRestart`). Also holds
the bounded request-line reader (`read_request_line`) the socket servers
use.
Swarm-level:
- **`swarm-controller/`** — owns what is true **across** hives; opt-in,
one per swarm. Serves HTTP over a unix socket nginx proxies to — ⚠️
**the socket's directory is its access control**; the crate README has
the constraint and a unit test pins the path.
- **`swarm-controller-api/`** — serde-only request and response bodies of
the controller's HTTP API, shared with `swarmctl` (forge, schedules and
the agent-create and forge-token responses).
- **`swarmctl/`** — swarm operator CLI; runs as **root and acts directly**
(README says why). Doesn't link `swarm-controller`: the forge and
schedule bodies and the agent responses come from
`swarm-controller-api`; the agent-create request, inference and
permissions ones are a local mirror. ⚠️ Edits authelia's
**`users.yml` in place**, shared with `swarm-authelia-bridge`; that
crate's README says what both writers must uphold. Verb reference
(CI-enforced):
[`docs/tools/swarmctl-cli.md`](docs/tools/swarmctl-cli.md).
- **`swarm-authelia-bridge/`** (+ **`swarm-authelia-bridge-sock/`** wire
types) — the other writer of authelia's users DB; runs inside
`swarm-authelia`.
- **`swarm-secret-client/`** — OpenBao client over `vaultrs`. ⚠️ Reads
the **`BAO_`** env spellings explicitly — `vaultrs`'s `VAULT_*` defaults
are set by no unit here, and falling through to them yields a client
with **no identity** and a TLS failure that names no cause.
- **`swarm-queue-client/`** — the one authenticated connect to the swarm
NATS queue.
- **`swarm-nats-auth/`** — NATS auth-callout responder; without it the
queue is fail-closed and admits nobody.
- **`swarm-agent-verify/`** — checks a `swarm-agent.<agent>.<secret>`
token against the store; shared by `swarm-nats-auth` and
`swarm-logs-auth`.
- **`swarm-logs-auth/`** — nginx `auth_request` responder for agent access
to the log store, and to the metrics store as `swarm-metrics-agent-auth`.
- **`swarm-metrics-auth/`** — nginx `auth_request` responder for Grafana's
bearer on the metrics store's query API.
- **`swarm-logs/`** — agent CLI: a LogsQL query in, log lines out.
- **`swarm-metrics/`** — agent CLI: a MetricsQL query in, the store's JSON out.
- **`swarm-matrix-client/`** — appservice calls against the swarm
homeserver, shared by `swarm-controller` and `swarm-matrix-ctl`.
- **`swarm-matrix-ctl/`** — the one binary run inside
`containers.hive-matrix`; new in-container jobs are verbs here, not
crates.
- **`swarm-builder/`** — builds agent config PR heads for commit status,
and evaluates the agent archetypes the create form lists.
Design: [`docs/swarm/builder.md`](docs/swarm/builder.md).
- **`swarm-ci-metrics/`** — polls the forge's Actions runs and pushes each
finished run's duration and queue wait to the metrics store. Design:
[`docs/swarm/ci-metrics.md`](docs/swarm/ci-metrics.md).
- **`swarm-claude-login/`** — runs `claude auth login` for one agent and
stores the credentials at `swarm/agents/<agent>/claude-credentials`.
Design: [`docs/swarm/claude-login.md`](docs/swarm/claude-login.md).
- **`swarm-review-classifier/`** — one `claude --print` per PR
classification, no tools, no forge credential. Design:
[`docs/swarm/review-classifier.md`](docs/swarm/review-classifier.md).
### External dependencies with no directory here
- **`hive-claude`** — app-agnostic driver for headless `claude --print`
(stream-json, `Telemetry`, the self-compacting `InfiniteSession`).
⚠️ **Its own repo, not a workspace member** — there is no
`hive-claude/` directory and `grep` here will not find it.
### Other top-level dirs
- **`frontend/`** — npm workspaces built hermetically by
`nix/packages/frontend.nix`: `shared`, `dashboard` (operator SPA),
`agent` (per-container UI), `swarm-ui` (Preact; packaged separately by
`nix/packages/swarm-ui.nix`, not in `packages.default`'s closure).
- **`nix/`** — `host-modules/` (the host stack), `agent-modules/`
(per-agent harness features), `container-modules/` (guest side of
service containers), `templates/agent.nix` (the container entry
point), `archetypes/` (the built-in agent archetypes), `packages/`,
`docs/` (options doc), `module-eval/` (NixOS
module-eval tests), `builder/` (`check-flake`), `script-tests/`, plus
`sources.nix` / `rust.nix` / `checks.nix` / `devshell.nix` /
`treefmt.nix` behind the thin `flake.nix`.
- **`docs/`** — subsystem reference docs (see *Reading paths* below).
- **`branding/`**, **`scripts/`** — static assets + helper scripts.
## Reading paths
**[`docs/README.md`](docs/README.md) is the single index**, grouped by
topic. Don't add a second copy of it here — a parallel list used to live
in this file and drifted.
## Conventions & process
- **Never add `#[allow(clippy::…)]`** — fix the lint instead (extract a
helper, add backticks, etc.). Worked examples:
[`docs/process/conventions.md`](docs/process/conventions.md#building--local-checks).
- **Pre-push lint hook** (catches tracker-tag and comment-block failures
before CI does — install once per clone):
`ln -sf ../../scripts/pre-push .git/hooks/pre-push`
- **Comment/PR-description length**: size it to how non-obvious the
thing is, not to how much investigation it took to find it — a
mechanical fix gets a one-line comment, a genuinely non-obvious
invariant or decision earns real prose. Don't restate a module's `//!`
doc in a comment further down the same file.
- **Operator-facing doc with real implementation detail mixed in**: wrap
the implementation part in a `<details><summary>…</summary>` block
(blank line after `<summary>` so the markdown inside renders) rather
than a sibling page — see `docs/integrations/github.md`'s
"Implementation" section. It only collapses in a rendered browser, and
the `<summary>` line isn't markdown-processed, so keep it plain prose
with no backticks.

View file

@ -37,7 +37,7 @@ actually attached, so a `continue` naming a session that isn't there
fails the tool call outright instead of looking like it worked; the error fails the tool call outright instead of looking like it worked; the error
says which directory it searched, which is usually the fix (pass `dir`). says which directory it searched, which is usually the fix (pass `dir`).
`dir` defaults to your work dir, `/agents/<you>/work`, which has no `dir` defaults to your work dir, `/agents/<you>/work`, which has no
`CLAUDE.md` above it, so a subagent started there does not load yours `AGENTS.md` above it, so a subagent started there does not load yours
(until that directory exists, the daemon's cwd is used). (until that directory exists, the daemon's cwd is used).
Use `status` for a Use `status` for a
zero-cost "is it still going" check — for a running subagent it also zero-cost "is it still going" check — for a running subagent it also

View file

@ -5,7 +5,7 @@ description: Enforces safe handling of an agent's own durable state/notes files
# State Hygiene # State Hygiene
Durable notes/state files (your `CLAUDE.md`, `state/notes.md`, `state/TODO.md`, Durable notes/state files (your `AGENTS.md`, `state/notes.md`, `state/TODO.md`,
and anything else under `state/`) are your memory across container resets and and anything else under `state/`) are your memory across container resets and
context compactions. They only work if you actually read them before you context compactions. They only work if you actually read them before you
change them - writing blind is how history gets silently destroyed or change them - writing blind is how history gets silently destroyed or

View file

@ -360,7 +360,7 @@ The system prompt (`hive-agent/prompts/system.md`, rendered by
varies is which MCP tools it surfaces (gated by the agent's permissions, varies is which MCP tools it surfaces (gated by the agent's permissions,
which the swarm sets). No `role:manager` block renders only which the swarm sets). No `role:manager` block renders only
for the root agent. The root agent's role-specific for the root agent. The root agent's role-specific
behaviour comes from its CLAUDE.md / agent-specific instructions, not behaviour comes from its AGENTS.md / agent-specific instructions, not
the system prompt template. the system prompt template.
## Helper events ## Helper events

View file

@ -270,6 +270,13 @@ Under `/var/lib/hyperhive/agents/<name>/`:
- `hyperhive-harness.json` — rate-limited / needs-login flags read - `hyperhive-harness.json` — rate-limited / needs-login flags read
by the dashboard's async container-state fetch. See by the dashboard's async container-state fetch. See
`docs/web-ui/dashboard.md::Container row`. `docs/web-ui/dashboard.md::Container row`.
The agent writes its own instructions to `AGENTS.md` here; claude and
opencode load it automatically from their cwd, `state/`. At boot,
`agents_md_migrate::run` renames a `state/CLAUDE.md` to `AGENTS.md`
when no `AGENTS.md` exists. When both exist it renames neither and
logs an error naming both paths: claude loads only `CLAUDE.md` while
one exists in its cwd or above.
- `harness/` — harness-internal ephemeral state; not intended for - `harness/` — harness-internal ephemeral state; not intended for
agent consumption. Bind-mounted to `/agents/<name>/harness` agent consumption. Bind-mounted to `/agents/<name>/harness`
inside the container (`$HYPERHIVE_HARNESS_DIR`). Contents: inside the container (`$HYPERHIVE_HARNESS_DIR`). Contents:
@ -321,7 +328,7 @@ Under `/var/lib/hyperhive/agents/<name>/`:
and other large working data. Bind-mounted RW to and other large working data. Bind-mounted RW to
`/agents/<name>/work` inside the container and owned by the agent `/agents/<name>/work` inside the container and owned by the agent
user. It sits outside `state/` so a claude or subagent started under user. It sits outside `state/` so a claude or subagent started under
`work/` doesn't walk up into `state/CLAUDE.md` and load the agent's `work/` doesn't walk up into `state/AGENTS.md` and load the agent's
own identity. Survives `destroy`/recreate, gone on `--purge`. Counted own identity. Survives `destroy`/recreate, gone on `--purge`. Counted
in the per-agent disk metric (`measure_agent_disk`) together with in the per-agent disk metric (`measure_agent_disk`) together with
`state/` and the container rootfs. The dashboard doesn't serve it: `state/` and the container rootfs. The dashboard doesn't serve it:

View file

@ -9,5 +9,5 @@ drift out of sync the way a hand-copied mirror would — edit the crate's
own README to change what shows up here. own README to change what shows up here.
For the one-line-per-crate index (which crate does what, at a glance) For the one-line-per-crate index (which crate does what, at a glance)
see the main [`CLAUDE.md`](../../CLAUDE.md#repo-map) repo map instead; see the main [`AGENTS.md`](../../AGENTS.md#repo-map) repo map instead;
this section is each crate's own, longer word on itself. this section is each crate's own, longer word on itself.

View file

@ -210,7 +210,7 @@ alongside the claude dir.
`/var/lib/hyperhive/agents/<name>/work/` bind-mounts to `/var/lib/hyperhive/agents/<name>/work/` bind-mounts to
`/agents/<name>/work` (RW, owned by the agent user). It's the place for `/agents/<name>/work` (RW, owned by the agent user). It's the place for
git worktrees and build dirs: a claude started under `state/` walks up git worktrees and build dirs: a claude started under `state/` walks up
into `state/CLAUDE.md` and takes on the agent's identity, and one into `state/AGENTS.md` and takes on the agent's identity, and one
started under `work/` doesn't. `/home/<name>` lives in the container started under `work/` doesn't. `/home/<name>` lives in the container
rootfs, which `destroy` deletes; `work/` survives `destroy`/recreate rootfs, which `destroy` deletes; `work/` survives `destroy`/recreate
and goes on `--purge`. The per-agent disk metric counts it; the and goes on `--purge`. The per-agent disk metric counts it; the

View file

@ -383,7 +383,7 @@ of their own — see [ACP-reported cost](#acp-reported-cost).
| `hyperhive.agent.session.count` | — | counter | `model` (incremented once per fresh, non-`--continue`'d session) | | `hyperhive.agent.session.count` | — | counter | `model` (incremented once per fresh, non-`--continue`'d session) |
| `hyperhive.agent.loose_ends.threads` | — | gauge | none | | `hyperhive.agent.loose_ends.threads` | — | gauge | none |
| `hyperhive.agent.loose_ends.reminders` | — | gauge | none | | `hyperhive.agent.loose_ends.reminders` | — | gauge | none |
| `hyperhive.agent.claude_md.lines` | — | gauge | none — recorded from the `CLAUDE.md`-size watch's own ~15-minute tick, **not** per turn like the rows above | | `hyperhive.agent.claude_md.lines` | — | gauge | none — recorded from the `AGENTS.md`-size watch's own ~15-minute tick, **not** per turn like the rows above |
| `hyperhive.agent.subagent_role.lines` | — | gauge | `role` (the file stem) — one series per role file in `subagent_roles/`, on the same ~15-minute tick | | `hyperhive.agent.subagent_role.lines` | — | gauge | `role` (the file stem) — one series per role file in `subagent_roles/`, on the same ~15-minute tick |
| `hyperhive.agent.claude_usage.percent` | `%` | gauge | `window` (`five_hour`, `seven_day`, … as the usage endpoint names them) — polled every 5 minutes, **not** per turn | | `hyperhive.agent.claude_usage.percent` | `%` | gauge | `window` (`five_hour`, `seven_day`, … as the usage endpoint names them) — polled every 5 minutes, **not** per turn |
| `hyperhive.agent.claude_usage.resets_at` | `s` | gauge | `window` — unix seconds at which that window resets; same 5-minute poll | | `hyperhive.agent.claude_usage.resets_at` | `s` | gauge | `window` — unix seconds at which that window resets; same 5-minute poll |

View file

@ -162,7 +162,7 @@ lands in a faithful copy of the agent's environment:
agent's `/home/<name>/.claude` where its OAuth credentials live. choom agent's `/home/<name>/.claude` where its OAuth credentials live. choom
prefixes the machine with `<name>@` (the meta-flake sets the agent's prefixes the machine with `<name>@` (the meta-flake sets the agent's
unix user name to its label). unix user name to its label).
- **from `/agents/<name>/state`**: the session and `CLAUDE.md` (the - **from `/agents/<name>/state`**: the session and `AGENTS.md` (the
persona) resolve against the right project dir. persona) resolve against the right project dir.
- **with the harness flags**: `--settings`, `--mcp-config`, and - **with the harness flags**: `--settings`, `--mcp-config`, and
`--system-prompt-file` from `/run/hive-config/` — the same files the `--system-prompt-file` from `/run/hive-config/` — the same files the

View file

@ -210,9 +210,12 @@ still applies.
The child runs with `cwd = /state` (when the bind exists; falls The child runs with `cwd = /state` (when the bind exists; falls
back to the parent's cwd in dev), so any relative path in a tool back to the parent's cwd in dev), so any relative path in a tool
call (`Read foo.md`, `Bash ls`, `Write notes.md`) lands in the call (`Read foo.md`, `Bash ls`, `Write notes.md`) lands in the
agent's durable bind-mounted dir. CLAUDE.md autoload walks agent's durable bind-mounted dir. AGENTS.md autoload walks
upward from `/state` — drop a per-agent CLAUDE.md there if you upward from `/state` — drop a per-agent AGENTS.md there if you
want long-term hints that survive destroy/recreate. want long-term hints that survive destroy/recreate. A `CLAUDE.md`
in `/state` or above it shadows `AGENTS.md`: claude then loads only
the `CLAUDE.md` (see `docs/agent-lifecycle/persistence.md` for the
boot-time rename).
The wake prompt is intentionally minimal: the popped message's The wake prompt is intentionally minimal: the popped message's
`from`/`body`, prefixed with a `[msg #<id>]` broker-row-id marker `from`/`body`, prefixed with a `[msg #<id>]` broker-row-id marker

View file

@ -427,7 +427,7 @@ hit-rate** (`cache_read` over all input-side tokens) and
scheduled / delivered / pending** for the window. When the scheduled / delivered / pending** for the window. When the
per-session capture has data, a **first-turn ctx** chip shows the per-session capture has data, a **first-turn ctx** chip shows the
input tokens of the most recent fresh claude session's first turn — input tokens of the most recent fresh claude session's first turn —
a proxy for system-prompt + CLAUDE.md sprawl (a fresh session's first a proxy for system-prompt + AGENTS.md sprawl (a fresh session's first
turn pays the full static prefix uncached). It's derived in `stats.rs` turn pays the full static prefix uncached). It's derived in `stats.rs`
(`first_turn_ctx`: the first turn, `ORDER BY started_at LIMIT 1`, of (`first_turn_ctx`: the first turn, `ORDER BY started_at LIMIT 1`, of
the latest `sessions` row in the window) and stays out of the JSON the latest `sessions` row in the window) and stays out of the JSON

View file

@ -147,7 +147,7 @@ window.Chart = Chart;
chips.push(["sessions", fmtInt(s.session_count)]); chips.push(["sessions", fmtInt(s.session_count)]);
} }
// First-turn ctx: input tokens of the most recent fresh session's // First-turn ctx: input tokens of the most recent fresh session's
// first turn — the cold system-prompt + CLAUDE.md cost, a sprawl // first turn — the cold system-prompt + AGENTS.md cost, a sprawl
// proxy. Omitted from the JSON (and so absent here) until the // proxy. Omitted from the JSON (and so absent here) until the
// per-session capture has data. // per-session capture has data.
if (typeof s.first_turn_ctx === "number") { if (typeof s.first_turn_ctx === "number") {

View file

@ -33,7 +33,7 @@ Durable knowledge:
- write to `/agents/{label}/state/notes.md` (free-form) or any other path under `/agents/{label}/state/`. That directory is bind-mounted from the host and persists across container destroy/recreate — claude's `--continue` session only carries short-term context, but `/agents/{label}/state/` is forever. Read it back at the start of relevant turns to remember things across resets. - write to `/agents/{label}/state/notes.md` (free-form) or any other path under `/agents/{label}/state/`. That directory is bind-mounted from the host and persists across container destroy/recreate — claude's `--continue` session only carries short-term context, but `/agents/{label}/state/` is forever. Read it back at the start of relevant turns to remember things across resets.
**Work dir**: `/agents/{label}/work/` is the place for git worktrees, clones, build trees and other bulky scratch. It is bind-mounted from the host, persists across container destroy/recreate and is backed up. Keep `state/` for notes and small durable files. Don't keep work under `/home`, `/tmp` or other container paths — they are outside what's backed up. Start subagents with their `dir` under `work/` (that is also the default): a claude started under `state/` walks up into `state/CLAUDE.md` and takes on your identity, one under `work/` doesn't. **Work dir**: `/agents/{label}/work/` is the place for git worktrees, clones, build trees and other bulky scratch. It is bind-mounted from the host, persists across container destroy/recreate and is backed up. Keep `state/` for notes and small durable files. Don't keep work under `/home`, `/tmp` or other container paths — they are outside what's backed up. Start subagents with their `dir` under `work/` (that is also the default): a claude started under `state/` walks up into `state/AGENTS.md` and takes on your identity, one under `work/` doesn't.
Claude session (OAuth credentials) lives at `/root/.claude/` and persists across restarts. Claude session (OAuth credentials) lives at `/root/.claude/` and persists across restarts.

View file

@ -0,0 +1,143 @@
//! One-time on-boot rename of the agent's instructions file from
//! `state/CLAUDE.md` to `state/AGENTS.md`. Runs in `main.rs` before the
//! first turn. Idempotent: once the file is renamed, later boots find no
//! `CLAUDE.md` and do nothing.
//!
//! Claude Code reads `AGENTS.md` only when no `CLAUDE.md` exists in its cwd
//! or above, so a `CLAUDE.md` left next to an `AGENTS.md` shadows it. When
//! both exist this module refuses to choose: it logs an error naming both
//! paths and leaves the files alone for the agent or operator to merge.
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
/// What [`rename_claude_md`] found and did.
#[derive(Debug, PartialEq, Eq)]
pub enum Outcome {
/// `CLAUDE.md` existed without an `AGENTS.md`, and is now `AGENTS.md`.
Renamed,
/// Neither file, or only `AGENTS.md`: nothing to do.
NothingToDo,
/// Both files exist; neither was touched.
BothExist {
claude_md: PathBuf,
agents_md: PathBuf,
},
}
/// Rename `<state_dir>/CLAUDE.md` to `<state_dir>/AGENTS.md` if only the
/// former exists. Logs the outcome; never fatal to boot.
pub fn run(state_dir: &Path) {
match rename_claude_md(state_dir) {
Ok(Outcome::Renamed) => {
tracing::info!(dir = %state_dir.display(), "renamed CLAUDE.md to AGENTS.md");
}
Ok(Outcome::NothingToDo) => {}
Ok(Outcome::BothExist {
claude_md,
agents_md,
}) => tracing::error!(
claude_md = %claude_md.display(),
agents_md = %agents_md.display(),
"both CLAUDE.md and AGENTS.md exist; claude loads only CLAUDE.md while it exists. \
Merge them into AGENTS.md and delete CLAUDE.md"
),
Err(e) => tracing::error!(error = ?e, "CLAUDE.md to AGENTS.md rename failed"),
}
}
/// Decide and apply the rename.
///
/// # Errors
///
/// Propagates a failed `rename`; both files are left as they were.
pub fn rename_claude_md(state_dir: &Path) -> Result<Outcome> {
let claude_md = state_dir.join("CLAUDE.md");
let agents_md = state_dir.join("AGENTS.md");
match (claude_md.exists(), agents_md.exists()) {
(true, false) => {
std::fs::rename(&claude_md, &agents_md).with_context(|| {
format!("rename {} -> {}", claude_md.display(), agents_md.display())
})?;
Ok(Outcome::Renamed)
}
(true, true) => Ok(Outcome::BothExist {
claude_md,
agents_md,
}),
(false, _) => Ok(Outcome::NothingToDo),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn claude_md_alone_is_renamed_once() {
let dir = tempfile::tempdir().expect("tempdir");
std::fs::write(dir.path().join("CLAUDE.md"), "me\n").expect("write");
assert_eq!(rename_claude_md(dir.path()).expect("run"), Outcome::Renamed);
assert!(!dir.path().join("CLAUDE.md").exists());
assert_eq!(
std::fs::read_to_string(dir.path().join("AGENTS.md")).expect("read"),
"me\n"
);
assert_eq!(
rename_claude_md(dir.path()).expect("second run"),
Outcome::NothingToDo
);
assert_eq!(
std::fs::read_to_string(dir.path().join("AGENTS.md")).expect("read"),
"me\n"
);
}
#[test]
fn both_present_touches_neither() {
let dir = tempfile::tempdir().expect("tempdir");
let claude_md = dir.path().join("CLAUDE.md");
let agents_md = dir.path().join("AGENTS.md");
std::fs::write(&claude_md, "old\n").expect("write");
std::fs::write(&agents_md, "new\n").expect("write");
assert_eq!(
rename_claude_md(dir.path()).expect("run"),
Outcome::BothExist {
claude_md: claude_md.clone(),
agents_md: agents_md.clone(),
}
);
assert_eq!(std::fs::read_to_string(&claude_md).expect("read"), "old\n");
assert_eq!(std::fs::read_to_string(&agents_md).expect("read"), "new\n");
}
#[test]
fn neither_present_creates_nothing() {
let dir = tempfile::tempdir().expect("tempdir");
assert_eq!(
rename_claude_md(dir.path()).expect("run"),
Outcome::NothingToDo
);
assert_eq!(std::fs::read_dir(dir.path()).expect("ls").count(), 0);
}
#[test]
fn agents_md_alone_is_left_alone() {
let dir = tempfile::tempdir().expect("tempdir");
std::fs::write(dir.path().join("AGENTS.md"), "me\n").expect("write");
assert_eq!(
rename_claude_md(dir.path()).expect("run"),
Outcome::NothingToDo
);
assert_eq!(
std::fs::read_to_string(dir.path().join("AGENTS.md")).expect("read"),
"me\n"
);
}
}

View file

@ -1,5 +1,5 @@
//! `CLAUDE.md` size watch: raises a loose-ends-v2 todo when the agent's //! `AGENTS.md` size watch: raises a loose-ends-v2 todo when the agent's
//! own `CLAUDE.md` grows past 500 lines, and feeds the same line count //! own `AGENTS.md` grows past 500 lines, and feeds the same line count
//! into an OTEL gauge every tick regardless of threshold. Per the //! into an OTEL gauge every tick regardless of threshold. Per the
//! operator's own framing of the ask — "claude md is personality and //! operator's own framing of the ask — "claude md is personality and
//! how you organize yourself, not a work log" — the prompt is asked to //! how you organize yourself, not a work log" — the prompt is asked to
@ -11,7 +11,7 @@
//! summary) — a third instance of the same anti-nag pattern rather than //! summary) — a third instance of the same anti-nag pattern rather than
//! a new one. //! a new one.
//! //!
//! `CLAUDE.md` lives at the top of the agent's own state dir — the same //! `AGENTS.md` lives at the top of the agent's own state dir — the same
//! directory `claude --print` runs with as its cwd (`turn::session_cwd`), //! directory `claude --print` runs with as its cwd (`turn::session_cwd`),
//! which is how the CLI's own auto-load discovers it. //! which is how the CLI's own auto-load discovers it.
//! //!
@ -40,20 +40,24 @@ const WARN_LINES: u64 = 500;
const BUCKET: u64 = 50; const BUCKET: u64 = 50;
/// Producing subsystem marker for the todo row. /// Producing subsystem marker for the todo row.
const SUBSYSTEM: &str = "claude_md"; const SUBSYSTEM: &str = "claude_md";
/// Fixed dedup key — there is only ever one `CLAUDE.md`-size todo per /// Fixed dedup key — there is only ever one `AGENTS.md`-size todo per
/// agent. /// agent.
const TODO_KEY: &str = "size"; const TODO_KEY: &str = "size";
/// Background loop: periodically reconcile the `CLAUDE.md`-size todo and /// Background loop: periodically reconcile the `AGENTS.md`-size todo and
/// feed the OTEL gauge. Detached task — runs for the harness's lifetime; /// feed the OTEL gauge. Detached task — runs for the harness's lifetime;
/// errors are logged, never fatal. /// errors are logged, never fatal.
pub async fn run(todos: Arc<Todos>, wake: Arc<Notify>) { pub async fn run(todos: Arc<Todos>, wake: Arc<Notify>) {
loop { loop {
tokio::time::sleep(CHECK_INTERVAL).await; tokio::time::sleep(CHECK_INTERVAL).await;
let lines = match tokio::task::spawn_blocking(|| line_count(&claude_md_path())).await { let lines = match tokio::task::spawn_blocking(|| {
line_count(&agents_md_path(&crate::paths::state_dir()))
})
.await
{
Ok(l) => l, Ok(l) => l,
Err(e) => { Err(e) => {
tracing::warn!(error = %e, "claude.md watch probe panicked"); tracing::warn!(error = %e, "agents.md watch probe panicked");
continue; continue;
} }
}; };
@ -72,25 +76,25 @@ fn reconcile(todos: &Todos, wake: &Notify, summary: Option<&str>) {
Some(summary) => match todos.upsert(SUBSYSTEM, Some(TODO_KEY), summary, None, false) { Some(summary) => match todos.upsert(SUBSYSTEM, Some(TODO_KEY), summary, None, false) {
Ok((_, true)) => wake.notify_one(), Ok((_, true)) => wake.notify_one(),
Ok((_, false)) => {} Ok((_, false)) => {}
Err(e) => tracing::warn!(error = ?e, "claude.md todo upsert failed"), Err(e) => tracing::warn!(error = ?e, "agents.md todo upsert failed"),
}, },
None => { None => {
if let Err(e) = todos.clear(SUBSYSTEM, Some(TODO_KEY)) { if let Err(e) = todos.clear(SUBSYSTEM, Some(TODO_KEY)) {
tracing::warn!(error = ?e, "claude.md todo clear failed"); tracing::warn!(error = ?e, "agents.md todo clear failed");
} }
} }
} }
} }
/// `CLAUDE.md`'s path: the top of the agent's own state dir, matching /// `AGENTS.md`'s path: the top of the agent's own state dir, matching
/// where `claude --print` runs (`turn::session_cwd`) and therefore where /// where `claude --print` runs (`turn::session_cwd`) and therefore where
/// its own auto-load discovers the file. /// its own auto-load discovers the file.
fn claude_md_path() -> std::path::PathBuf { fn agents_md_path(state_dir: &Path) -> std::path::PathBuf {
crate::paths::state_dir().join("CLAUDE.md") state_dir.join("AGENTS.md")
} }
/// Line count of the file at `path`. `None` when it can't be read — no /// Line count of the file at `path`. `None` when it can't be read — no
/// `CLAUDE.md` yet is not a warning, and a transient read failure isn't /// `AGENTS.md` yet is not a warning, and a transient read failure isn't
/// either. /// either.
fn line_count(path: &Path) -> Option<u64> { fn line_count(path: &Path) -> Option<u64> {
let contents = std::fs::read_to_string(path).ok()?; let contents = std::fs::read_to_string(path).ok()?;
@ -108,9 +112,9 @@ fn summary_for(lines: u64) -> Option<String> {
} }
let bucket = lines / BUCKET * BUCKET; let bucket = lines / BUCKET * BUCKET;
Some(format!( Some(format!(
"your CLAUDE.md is {bucket}+ lines. CLAUDE.md is personality and how you organize \ "your AGENTS.md is {bucket}+ lines. AGENTS.md is personality and how you organize \
yourself, not a work log — if it's grown past that, moving detail into separate files \ yourself, not a work log — if it's grown past that, moving detail into separate files \
(linked from CLAUDE.md) or an archive is what actually shrinks this count. It \ (linked from AGENTS.md) or an archive is what actually shrinks this count. It \
auto-loads into every turn, so a smaller file is a fixed cost saved on every turn. \ auto-loads into every turn, so a smaller file is a fixed cost saved on every turn. \
Nothing here is a hard limit, and deleting stale content outright is fine too if \ Nothing here is a hard limit, and deleting stale content outright is fine too if \
that's genuinely what it is — no obligation either way. No need to act on this now — \ that's genuinely what it is — no obligation either way. No need to act on this now — \
@ -175,16 +179,28 @@ mod tests {
#[test] #[test]
fn line_count_matches_editor_expectations() { fn line_count_matches_editor_expectations() {
let dir = tempfile::tempdir().expect("tempdir"); let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("CLAUDE.md"); let path = dir.path().join("AGENTS.md");
// Three lines, trailing newline — an editor shows 3 lines, not 4. // Three lines, trailing newline — an editor shows 3 lines, not 4.
std::fs::write(&path, "one\ntwo\nthree\n").expect("write"); std::fs::write(&path, "one\ntwo\nthree\n").expect("write");
assert_eq!(line_count(&path), Some(3)); assert_eq!(line_count(&path), Some(3));
} }
/// The watch counts `AGENTS.md`, and a leftover `CLAUDE.md` beside it
/// is not what it reads.
#[test]
fn watch_reads_agents_md_not_claude_md() {
let dir = tempfile::tempdir().expect("tempdir");
std::fs::write(dir.path().join("CLAUDE.md"), "one\n").expect("write");
assert_eq!(line_count(&agents_md_path(dir.path())), None);
std::fs::write(dir.path().join("AGENTS.md"), "one\ntwo\n").expect("write");
assert_eq!(line_count(&agents_md_path(dir.path())), Some(2));
}
#[test] #[test]
fn missing_file_is_silent_not_an_error() { fn missing_file_is_silent_not_an_error() {
assert_eq!( assert_eq!(
line_count(Path::new("/definitely/does/not/exist/CLAUDE.md")), line_count(Path::new("/definitely/does/not/exist/AGENTS.md")),
None None
); );
} }

View file

@ -9,6 +9,7 @@
//! Single bin crate: the module tree below (formerly this crate's `lib.rs`, //! Single bin crate: the module tree below (formerly this crate's `lib.rs`,
//! before lib + bin were collapsed into one) plus the serve loop. //! before lib + bin were collapsed into one) plus the serve loop.
mod agents_md_migrate;
mod boot_announce; mod boot_announce;
mod claude_credentials; mod claude_credentials;
mod claude_md_watch; mod claude_md_watch;
@ -215,7 +216,7 @@ fn graceful_stop_message() -> hive_sh4re::inbox::DeliveredMessage {
body: "You are being gracefully stopped — the container will shut down after this turn, \ body: "You are being gracefully stopped — the container will shut down after this turn, \
and new inbound messages are already fenced. This is your one checkpoint turn: \ and new inbound messages are already fenced. This is your one checkpoint turn: \
flush anything worth keeping into your durable /state files now — update your \ flush anything worth keeping into your durable /state files now — update your \
notes / CLAUDE.md / TODO.md with in-flight task state, decisions made, important \ notes / AGENTS.md / TODO.md with in-flight task state, decisions made, important \
file paths, and whatever you'd need to resume cleanly later with only a summary \ file paths, and whatever you'd need to resume cleanly later with only a summary \
of this conversation to go on. Do not start new work or reply to anyone; just \ of this conversation to go on. Do not start new work or reply to anyone; just \
write your notes and end your turn. The session may be compacted after this turn \ write your notes and end your turn. The session may be compacted after this turn \
@ -450,7 +451,7 @@ fn spawn_todo_socket(
// top level instead of disk pressure. // top level instead of disk pressure.
tokio::spawn(state_entry_watch::run(store.clone(), todo_wake.clone())); tokio::spawn(state_entry_watch::run(store.clone(), todo_wake.clone()));
// Same shape again: nudge (and feed an OTEL gauge) on a // Same shape again: nudge (and feed an OTEL gauge) on a
// grown CLAUDE.md instead of a grown state dir. // grown AGENTS.md instead of a grown state dir.
tokio::spawn(claude_md_watch::run(store.clone(), todo_wake.clone())); tokio::spawn(claude_md_watch::run(store.clone(), todo_wake.clone()));
// Swarm-scheduled prompts fire as todos too, from the list the // Swarm-scheduled prompts fire as todos too, from the list the
// swarm keeps for this agent. // swarm keeps for this agent.
@ -553,6 +554,8 @@ async fn serve_main<S: Surface>(socket: &Path, poll_ms: u64) -> Result<()> {
tracing::error!(error = %e, "web_ui::serve exited with error"); tracing::error!(error = %e, "web_ui::serve exited with error");
} }
}); });
// Before the first turn, so claude's own auto-load finds `AGENTS.md`.
agents_md_migrate::run(&paths::state_dir());
// Harness-local reminders: a due reminder fires // Harness-local reminders: a due reminder fires
// straight into an mpsc channel the serve loop races against the // straight into an mpsc channel the serve loop races against the
// broker long-poll (unlike todos, a fire carries real per-row data, // broker long-poll (unlike todos, a fire carries real per-row data,

View file

@ -53,7 +53,7 @@ struct Instruments {
open_threads: Gauge<u64>, open_threads: Gauge<u64>,
open_reminders: Gauge<u64>, open_reminders: Gauge<u64>,
/// Recorded from [`crate::claude_md_watch`]'s own tick, not from /// Recorded from [`crate::claude_md_watch`]'s own tick, not from
/// [`record`] — `CLAUDE.md` size is a continuously-live value (like /// [`record`] — `AGENTS.md` size is a continuously-live value (like
/// hive-c0re's container gauges), not a once-per-turn one, so it has /// hive-c0re's container gauges), not a once-per-turn one, so it has
/// its own entry point rather than riding `record`'s per-turn call. /// its own entry point rather than riding `record`'s per-turn call.
claude_md_lines: Gauge<u64>, claude_md_lines: Gauge<u64>,
@ -110,7 +110,7 @@ pub fn record(row: &TurnStatRow, fresh_session: bool) {
} }
} }
/// Record the current `CLAUDE.md` line count. Called from /// Record the current `AGENTS.md` line count. Called from
/// [`crate::claude_md_watch`]'s periodic tick (every ~15 minutes, not /// [`crate::claude_md_watch`]'s periodic tick (every ~15 minutes, not
/// per turn) — see the field doc on [`Instruments::claude_md_lines`] for /// per turn) — see the field doc on [`Instruments::claude_md_lines`] for
/// why this doesn't ride [`record`]. No-op when OTEL isn't configured, /// why this doesn't ride [`record`]. No-op when OTEL isn't configured,

View file

@ -142,7 +142,7 @@ pub struct Snapshot {
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub reminder_stats: Option<ReminderStats>, pub reminder_stats: Option<ReminderStats>,
/// First-turn input tokens of the most recent fresh claude session /// First-turn input tokens of the most recent fresh claude session
/// that started in the window — a proxy for system-prompt + CLAUDE.md /// that started in the window — a proxy for system-prompt + AGENTS.md
/// sprawl (a fresh session's first turn pays the full static prefix /// sprawl (a fresh session's first turn pays the full static prefix
/// uncached, so this is the current "cold context" cost). `None` until /// uncached, so this is the current "cold context" cost). `None` until
/// the sessions capture (per-session `session_id`) has data; every /// the sessions capture (per-session `session_id`) has data; every
@ -379,7 +379,7 @@ fn snapshot(path: &Path, window: Window) -> Result<Snapshot> {
} }
/// First-turn input tokens of the most recent fresh claude session that /// First-turn input tokens of the most recent fresh claude session that
/// started in `[from, now]`. Tracks system-prompt + CLAUDE.md sprawl: /// started in `[from, now]`. Tracks system-prompt + AGENTS.md sprawl:
/// the first turn of a fresh session (`--continue` suppressed) pays the /// the first turn of a fresh session (`--continue` suppressed) pays the
/// full static prefix as uncached input, so watching this over time /// full static prefix as uncached input, so watching this over time
/// surfaces creep. Uses the agreed per-session derive — the first turn /// surfaces creep. Uses the agreed per-session derive — the first turn

View file

@ -81,7 +81,7 @@ Your conversation context has grown large and the harness is about to run `/comp
which collapses the detailed turn-by-turn history into a short summary. Anything you \ which collapses the detailed turn-by-turn history into a short summary. Anything you \
do not persist now is effectively lost after the next turn.\n\n\ do not persist now is effectively lost after the next turn.\n\n\
Use THIS turn to flush anything worth keeping into your durable `/state` files: update \ Use THIS turn to flush anything worth keeping into your durable `/state` files: update \
your notes / CLAUDE.md / TODO.md with in-flight task state, decisions made, important \ your notes / AGENTS.md / TODO.md with in-flight task state, decisions made, important \
file paths, and whatever you would need to resume cleanly with only a summary of this \ file paths, and whatever you would need to resume cleanly with only a summary of this \
conversation to go on. Do not start new work or reply to anyone — just write your notes \ conversation to go on. Do not start new work or reply to anyone — just write your notes \
and end the turn."; and end the turn.";

Some files were not shown because too many files have changed in this diff Show more