docs: AGENTS.md is the canonical agent-instructions file (#260)
The instructions in CLAUDE.md were never Claude-specific — they are the repo's rules, and any agent working here needs them. AGENTS.md is the vendor-neutral name the rest of the family already uses (paulca.com and woodstar both track one), so this renames the file and leaves CLAUDE.md as a six-line pointer to it. `git mv`, so the history follows. The 18 comments across 15 Go files that cite the rules by filename now cite AGENTS.md, because after this "CLAUDE.md's rule" would point at the pointer rather than the rule. Dated plans and specs under docs/superpowers/ keep saying CLAUDE.md on purpose: they record what was true when they were written. Companion to amadannet/amadan#136, which does the same there.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
17 files changed,
+217
−207
AGENTS.md+195 −0CLAUDE.md+4 −189domains_cli_test.go+2 −2fleets_cli_test.go+1 −1internal/appconfig/syncexec.go+2 −2internal/appconfig/syncexec_test.go+1 −1internal/applog/record.go+1 −1internal/applog/record_test.go+1 −1internal/console/apiservices.go+1 −1internal/console/apiservices_test.go+1 −1internal/console/apistore.go+1 −1internal/console/economicspage_test.go+1 −1internal/console/ladder_test.go+1 −1internal/status/status_test.go+1 −1services_cli_test.go+2 −2store.go+1 −1store_test.go+1 −1
diff --git a/AGENTS.md b/AGENTS.md| new file mode 100644 |
| index 000000000..efe0aa0ee |
| --- /dev/null |
| +++ b/AGENTS.md |
| @@ -0,0 +1,195 @@ |
| +# AGENTS.md |
| + |
| +Guidance for AI coding agents working in this repository. This is the |
| +canonical agent-instructions file — tool-specific files (`CLAUDE.md`) |
| +are pointers to it, so keep it current *here*. |
| + |
| +CARLOS platform — bucket-as-control-plane deployment platform. Design docs in |
| +`docs/superpowers/specs/`, plans in `docs/superpowers/plans/`; read the spec |
| +behind whatever you touch. |
| + |
| +## Delegation policy (Paul's rule, 2026-08-02) |
| + |
| +Delegate mechanical work to subagents on the cheapest model that fits, and |
| +have Opus review anything that grants authority or touches production paths: |
| + |
| +- **Haiku** — transcription-grade work: the plan contains the exact code, |
| + single-file mechanical fixes, doc edits. |
| +- **Sonnet** — implementation from a precise brief: 1-3 files, clear |
| + interfaces, established conventions to follow. |
| +- **Opus** — design judgment, multi-file integration, anything security- or |
| + production-touching; and the **reviewer** for work in those areas. |
| +- **Fable** — where the tier changes the outcome, not the speed (Paul, |
| + 2026-08-03): implementation whose correctness rests on several interacting |
| + invariants at once (converge-grade, root-side, fleet-wide blast radius), |
| + whole-branch final reviews, and deep work that wants a fresh |
| + full-capability context window. It burns usage budget fastest — never |
| + spend it where a precise Sonnet brief or an Opus review already fits. |
| + |
| +Always name the model explicitly when dispatching; an omitted model silently |
| +inherits the session's (usually the most expensive). A precise brief beats a |
| +capable model — exact values, file paths and interfaces go in the brief. |
| + |
| +## Operate via API and CLI (Paul's rule, 2026-08-06) |
| + |
| +Day-to-day CARLOS administration goes through the console API and the |
| +`carlos` CLI — listing routes, inspecting apps, checking channels, all of it. |
| +Direct AWS access (SSM sessions, S3 console, aws CLI) is reserved for work on |
| +the platform itself: building CARLOS internals, provisioning infra, debugging |
| +below the API surface. |
| + |
| +If a day-to-day task has no API/CLI command, that is a product gap, not a |
| +license to drop to AWS: plan and build the command (or file the gap) instead |
| +of working around it. Box-local-only counts as a gap too. (2026-08-06: |
| +`carlos instances` lists routes but only by opening the registry DB on local |
| +disk, so an agent went to Systems Manager to run it — the missing API-backed |
| +remote form was the bug.) |
| + |
| +The design-side corollary (Paul, 2026-08-08): **every `carlos` command is |
| +designed for a user with no access to the underlying infra and no box |
| +commands.** The command alone must complete the job — no follow-up SSM |
| +session, AWS console visit, or on-box verb may be required for it to |
| +deliver or verify its result. |
| + |
| +## Every change lands through a PR (Paul's rule, 2026-08-10) |
| + |
| +**Nothing merges to `main` without a pull request.** No local merge to |
| +`main`, no direct commit to `main`, no fast-forward "it's only a docs |
| +change" — every change, however small, goes up as a branch and lands |
| +through a PR. That includes doc-only edits, one-line fixes, and revert |
| +commits. |
| + |
| +Agents: this rule is on you as much as on people. When work is complete, |
| +push the branch and open the PR — never a local merge, never a direct |
| +commit, however small the change. |
| + |
| +**Merging that PR is fine** (Paul, 2026-08-16, clarifying an earlier |
| +wording that read as a blanket ban and cost a session a round trip). The |
| +rule is about how work REACHES `main`, not about whose hand lands it: the |
| +PR has to exist, so that it can carry the context, the iterations and the |
| +several commits that a single squashed push to `main` would flatten. Once |
| +it exists and is green, `gh pr merge` is an ordinary step. |
| + |
| +Use the judgement the rest of this file asks for: a PR that is still |
| +collecting review, that is gated on something (`#62`), or whose diff you |
| +would want a second pair of eyes on, waits for a person. |
| + |
| +The reason is timing, not ceremony. `.github/workflows/ci.yml` triggers on |
| +`pull_request` *and* on `push` to `main`, so a locally merged change does |
| +still meet the gate — but only **after** it is on `main`, where a red run |
| +is an incident to clean up rather than a merge that simply doesn't happen. |
| +The PR is the one place the gate runs against the merge result while the |
| +change can still be turned away, and the one place the branch's diff is |
| +read as a single piece rather than commit by commit. |
| + |
| +## Build & test |
| + |
| +- `go build ./...`, `go vet ./...`, `go test ./... -count=1` — all clean, |
| + every package (21 as of 2026-08-08; the count grows), before any commit |
| + lands. |
| +- `gofmt -l` clean for files you touch (three pre-existing unformatted files |
| + elsewhere are left alone). |
| +- `.github/workflows/ci.yml` runs that same gate on every PR, plus a |
| + `GOOS=darwin` compile. **The gate is not Linux-only**: `internal/host` is |
| + linked into the CLI, which people run on Macs, so a Linux-only type |
| + assumption breaks `go build .` for them. Cross-compiling is enough to catch |
| + it — no Mac required. |
| +- Sandbox note: use `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod |
| + GOPROXY=off`; go commands may need the sandbox disabled ("read-only file |
| + system" from the toolchain is the tell). |
| + |
| +## Rules this repo has paid for |
| + |
| +- **When a storage shape changes, the consumer list is everything that PARSES |
| + the shape** — not everything that calls the API. (Learned three times.) |
| +- **Wire-shape changes are additive**: live pointers/manifests exist in |
| + production buckets; anything written before your change must still parse. |
| +- **Production channel names are pinned in tests** |
| + (`internal/release/laddercheck_test.go`) — the fleet runs on `canary/*` |
| + channels today; a ladder change that refuses them stops deploys. |
| +- **Adoption swaps a symlink; it does not restart processes.** Order is: |
| + promote → adopt → restart. Long-running units (console, keymail relay) need |
| + the restart; hibernating tenants pick it up on wake. |
| +- **A route's Channel and Environment belong to its instance record** once the |
| + route is `Provisioned` (main, 2026-08-10). The provisioning reconciler |
| + converges both from `apps/<sqid>/<app>/instances/<host>.json` on every pass, |
| + so a `carlos route -channel` on the box lands and is reverted within seconds |
| + of the next serial bump — `carlos route` refuses both axes on such a row and |
| + names the record. Reconcile also ADOPTS an existing route whose shape matches |
| + an appearing record, which is how the fleet gets backfilled with no box |
| + commands. Adoption stamps `Provisioned`, and `Provisioned` is the one |
| + condition that GRANTS the removal phase authority over a row (the loop also |
| + gates on region/pool scope and on the record being gone) — so adoption is an |
| + authority transfer, |
| + and the shape match is what stands between a backfill and a retired |
| + production route. It refuses any row carrying a column the record cannot |
| + express (`Backing`, `Tunnel`, `Caps`) or owned by another pass (`Platform`, |
| + `Canary`). |
| +- **A ResponseWriter wrapper that edits headers needs its own header map.** |
| + Wrappers in `internal/edge` embed the writer below, so `Header()` is ONE |
| + shared map from handler to connection — and `maybeStore` caches |
| + `rec.Header().Clone()`. The gzip wrapper (`internal/edge/compress.go`) keeps |
| + a private map and writes `Content-Encoding` only to the real writer's; on the |
| + shared map it would have stored an identity body labelled gzip and served it |
| + to every later visitor. Two corollaries it paid for: a header set AFTER |
| + `WriteHeader` is a TRAILER, so the wrapper must sync late additions down or |
| + it silently eats them; and canonical for `ETag` is `Etag`, so a raw map-key |
| + comparison spelled `"ETag"` matches nothing. Ordering is load-bearing too: |
| + recorder ABOVE the compressor (cache stores identity), usage counter BELOW it |
| + (tenants are billed for wire bytes), wrap site AFTER the upgrade branch. |
| +- **Console security gates are ordered**: session → CSRF → owner (byte-identical |
| + 404) → `requireFreshAuth` LAST. A non-member must never get a redirect that |
| + confirms an account exists. |
| +- **Verify against the thing you changed, with the binary you built.** Stale |
| + DNS answers and stale `CARLOS_BIN` binaries have both produced false |
| + "verified" reports in one day. |
| +- Icons are vendored Lucide (in-code SVG constants, never CDN); emoji is |
| + content, icons are chrome. |
| +- **A test that shells out to the carlos binary must build its env through |
| + `carlosTestEnv`** (masks `CARLOS_CREDENTIALS`, `HOME`, `CARLOS_CONSOLE`) |
| + **and set `cmd.Dir` to a scratch dir** — the binary walks cwd ancestors for |
| + `.carlos/config`. The fallback is the operator's real login and default |
| + console; a logged-in laptop running `go test ./...` minted 40+ real accounts |
| + on the production console before this was isolated. |
| +- **Absence of `apps/<acct>/<app>/pipeline.json` = the legacy ladder**, |
| + byte-for-byte; new apps get a one-channel `edge` pipeline at create; |
| + per-channel rules are console-enforced, bake windows box-enforced through |
| + the `FirstSeen.Holds` ratchet. |
| + |
| +## Deploy reality (2026-08-02; convergence rewritten 2026-08-08) |
| + |
| +- Flagship (keymail AWS acct): console, kass, keymail, platform-site. Ship via |
| + `scripts/ship-app.sh` / `promote-app.sh` in the pinfra checkout — the signing |
| + key comes from SSM inside the script, never a terminal. |
| +- Tito production is self-hosted CARLOS on `carlos-tito-ie` (titogo AWS acct). |
| +- keymail.dev deploys via `carlos ship`/`promote` (account `bae`), NOT via its |
| + repo's legacy `deploy/deploy.sh` — see that repo's CLAUDE.md. |
| +- **Since main 1fba18f (PR #108, live both boxes 2026-08-08): convergence is |
| + seconds, not ticks.** Console-mediated writes bump `control/serial`; boxes |
| + poll it at 2s and run the full pass set. Direct-bucket promotes (pinfra |
| + scripts) don't bump but canary/* channels are name-scoped fast anyway. |
| + Blocked stages relay to a root pass (`carlos-stage.path` — must be armed; |
| + update-platform-binary.sh does it). `carlos deploy` is the member one-command |
| + form: ship → promote → wait until `X-Carlos-Version` on the URL reports the |
| + shipped build over 2xx. Verify deploys with that header (`curl -sI |
| + --resolve`), never a cached answer. `-I` is fine for the VERSION header, but |
| + it sends a HEAD — and HEAD is excluded from edge compression, so anything |
| + about `Content-Encoding` needs a GET (`curl -s -D - -o /tmp/b`) instead. |
| +- **Console restarts: manual until the route is unit-stamped** (#110, fixed |
| + in main 916cd6b, not yet rolled). Routes can now name the systemd unit that |
| + owns their process (`carlos route -registry /data/registry.db -host |
| + <console-host> -unit console.service`, run as carlos, not root); a stamped |
| + route is cycled by adoption itself, and a failed named-unit restart rolls |
| + the adoption back instead of stamping a lying header. Until a box runs |
| + 916cd6b+ AND its console row is stamped, the old rule stands: promote → |
| + adopt → `systemctl restart console.service`, and the header overclaims |
| + until the restart. Ordinary unit/exec tenants restart themselves either way. |
| +- **`carlos restart -app X`** (#113, merged 916cd6b, live after the same |
| + roll): member-facing process cycle with no version or config change — |
| + console door touches `apps/<acct>/<app>/restart-gen.json` + bumps the |
| + serial; boxes act on the serial move (exec children bounced in-process, |
| + unit-shaped routes via root `carlos adopt -restart-only` through |
| + `carlos-restart.path`, which update-platform-binary.sh arms since pinfra |
| + PR #10). Hibernating instances are never woken. Static routes carry |
| + `X-Carlos-Version` from the same roll (#112), so `carlos deploy -kind |
| + static -host <site>` verifies static sites too. |
diff --git a/CLAUDE.md b/CLAUDE.md| index 5bf29eab2..270f5c9ca 100644 |
| --- a/CLAUDE.md |
| +++ b/CLAUDE.md |
| @@ -1,191 +1,6 @@ |
| # CLAUDE.md |
| -CARLOS platform — bucket-as-control-plane deployment platform. Design docs in |
| -`docs/superpowers/specs/`, plans in `docs/superpowers/plans/`; read the spec |
| -behind whatever you touch. |
| - |
| -## Delegation policy (Paul's rule, 2026-08-02) |
| - |
| -Delegate mechanical work to subagents on the cheapest model that fits, and |
| -have Opus review anything that grants authority or touches production paths: |
| - |
| -- **Haiku** — transcription-grade work: the plan contains the exact code, |
| - single-file mechanical fixes, doc edits. |
| -- **Sonnet** — implementation from a precise brief: 1-3 files, clear |
| - interfaces, established conventions to follow. |
| -- **Opus** — design judgment, multi-file integration, anything security- or |
| - production-touching; and the **reviewer** for work in those areas. |
| -- **Fable** — where the tier changes the outcome, not the speed (Paul, |
| - 2026-08-03): implementation whose correctness rests on several interacting |
| - invariants at once (converge-grade, root-side, fleet-wide blast radius), |
| - whole-branch final reviews, and deep work that wants a fresh |
| - full-capability context window. It burns usage budget fastest — never |
| - spend it where a precise Sonnet brief or an Opus review already fits. |
| - |
| -Always name the model explicitly when dispatching; an omitted model silently |
| -inherits the session's (usually the most expensive). A precise brief beats a |
| -capable model — exact values, file paths and interfaces go in the brief. |
| - |
| -## Operate via API and CLI (Paul's rule, 2026-08-06) |
| - |
| -Day-to-day CARLOS administration goes through the console API and the |
| -`carlos` CLI — listing routes, inspecting apps, checking channels, all of it. |
| -Direct AWS access (SSM sessions, S3 console, aws CLI) is reserved for work on |
| -the platform itself: building CARLOS internals, provisioning infra, debugging |
| -below the API surface. |
| - |
| -If a day-to-day task has no API/CLI command, that is a product gap, not a |
| -license to drop to AWS: plan and build the command (or file the gap) instead |
| -of working around it. Box-local-only counts as a gap too. (2026-08-06: |
| -`carlos instances` lists routes but only by opening the registry DB on local |
| -disk, so an agent went to Systems Manager to run it — the missing API-backed |
| -remote form was the bug.) |
| - |
| -The design-side corollary (Paul, 2026-08-08): **every `carlos` command is |
| -designed for a user with no access to the underlying infra and no box |
| -commands.** The command alone must complete the job — no follow-up SSM |
| -session, AWS console visit, or on-box verb may be required for it to |
| -deliver or verify its result. |
| - |
| -## Every change lands through a PR (Paul's rule, 2026-08-10) |
| - |
| -**Nothing merges to `main` without a pull request.** No local merge to |
| -`main`, no direct commit to `main`, no fast-forward "it's only a docs |
| -change" — every change, however small, goes up as a branch and lands |
| -through a PR. That includes doc-only edits, one-line fixes, and revert |
| -commits. |
| - |
| -Agents: this rule is on you as much as on people. When work is complete, |
| -push the branch and open the PR — never a local merge, never a direct |
| -commit, however small the change. |
| - |
| -**Merging that PR is fine** (Paul, 2026-08-16, clarifying an earlier |
| -wording that read as a blanket ban and cost a session a round trip). The |
| -rule is about how work REACHES `main`, not about whose hand lands it: the |
| -PR has to exist, so that it can carry the context, the iterations and the |
| -several commits that a single squashed push to `main` would flatten. Once |
| -it exists and is green, `gh pr merge` is an ordinary step. |
| - |
| -Use the judgement the rest of this file asks for: a PR that is still |
| -collecting review, that is gated on something (`#62`), or whose diff you |
| -would want a second pair of eyes on, waits for a person. |
| - |
| -The reason is timing, not ceremony. `.github/workflows/ci.yml` triggers on |
| -`pull_request` *and* on `push` to `main`, so a locally merged change does |
| -still meet the gate — but only **after** it is on `main`, where a red run |
| -is an incident to clean up rather than a merge that simply doesn't happen. |
| -The PR is the one place the gate runs against the merge result while the |
| -change can still be turned away, and the one place the branch's diff is |
| -read as a single piece rather than commit by commit. |
| - |
| -## Build & test |
| - |
| -- `go build ./...`, `go vet ./...`, `go test ./... -count=1` — all clean, |
| - every package (21 as of 2026-08-08; the count grows), before any commit |
| - lands. |
| -- `gofmt -l` clean for files you touch (three pre-existing unformatted files |
| - elsewhere are left alone). |
| -- `.github/workflows/ci.yml` runs that same gate on every PR, plus a |
| - `GOOS=darwin` compile. **The gate is not Linux-only**: `internal/host` is |
| - linked into the CLI, which people run on Macs, so a Linux-only type |
| - assumption breaks `go build .` for them. Cross-compiling is enough to catch |
| - it — no Mac required. |
| -- Sandbox note: use `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod |
| - GOPROXY=off`; go commands may need the sandbox disabled ("read-only file |
| - system" from the toolchain is the tell). |
| - |
| -## Rules this repo has paid for |
| - |
| -- **When a storage shape changes, the consumer list is everything that PARSES |
| - the shape** — not everything that calls the API. (Learned three times.) |
| -- **Wire-shape changes are additive**: live pointers/manifests exist in |
| - production buckets; anything written before your change must still parse. |
| -- **Production channel names are pinned in tests** |
| - (`internal/release/laddercheck_test.go`) — the fleet runs on `canary/*` |
| - channels today; a ladder change that refuses them stops deploys. |
| -- **Adoption swaps a symlink; it does not restart processes.** Order is: |
| - promote → adopt → restart. Long-running units (console, keymail relay) need |
| - the restart; hibernating tenants pick it up on wake. |
| -- **A route's Channel and Environment belong to its instance record** once the |
| - route is `Provisioned` (main, 2026-08-10). The provisioning reconciler |
| - converges both from `apps/<sqid>/<app>/instances/<host>.json` on every pass, |
| - so a `carlos route -channel` on the box lands and is reverted within seconds |
| - of the next serial bump — `carlos route` refuses both axes on such a row and |
| - names the record. Reconcile also ADOPTS an existing route whose shape matches |
| - an appearing record, which is how the fleet gets backfilled with no box |
| - commands. Adoption stamps `Provisioned`, and `Provisioned` is the one |
| - condition that GRANTS the removal phase authority over a row (the loop also |
| - gates on region/pool scope and on the record being gone) — so adoption is an |
| - authority transfer, |
| - and the shape match is what stands between a backfill and a retired |
| - production route. It refuses any row carrying a column the record cannot |
| - express (`Backing`, `Tunnel`, `Caps`) or owned by another pass (`Platform`, |
| - `Canary`). |
| -- **A ResponseWriter wrapper that edits headers needs its own header map.** |
| - Wrappers in `internal/edge` embed the writer below, so `Header()` is ONE |
| - shared map from handler to connection — and `maybeStore` caches |
| - `rec.Header().Clone()`. The gzip wrapper (`internal/edge/compress.go`) keeps |
| - a private map and writes `Content-Encoding` only to the real writer's; on the |
| - shared map it would have stored an identity body labelled gzip and served it |
| - to every later visitor. Two corollaries it paid for: a header set AFTER |
| - `WriteHeader` is a TRAILER, so the wrapper must sync late additions down or |
| - it silently eats them; and canonical for `ETag` is `Etag`, so a raw map-key |
| - comparison spelled `"ETag"` matches nothing. Ordering is load-bearing too: |
| - recorder ABOVE the compressor (cache stores identity), usage counter BELOW it |
| - (tenants are billed for wire bytes), wrap site AFTER the upgrade branch. |
| -- **Console security gates are ordered**: session → CSRF → owner (byte-identical |
| - 404) → `requireFreshAuth` LAST. A non-member must never get a redirect that |
| - confirms an account exists. |
| -- **Verify against the thing you changed, with the binary you built.** Stale |
| - DNS answers and stale `CARLOS_BIN` binaries have both produced false |
| - "verified" reports in one day. |
| -- Icons are vendored Lucide (in-code SVG constants, never CDN); emoji is |
| - content, icons are chrome. |
| -- **A test that shells out to the carlos binary must build its env through |
| - `carlosTestEnv`** (masks `CARLOS_CREDENTIALS`, `HOME`, `CARLOS_CONSOLE`) |
| - **and set `cmd.Dir` to a scratch dir** — the binary walks cwd ancestors for |
| - `.carlos/config`. The fallback is the operator's real login and default |
| - console; a logged-in laptop running `go test ./...` minted 40+ real accounts |
| - on the production console before this was isolated. |
| -- **Absence of `apps/<acct>/<app>/pipeline.json` = the legacy ladder**, |
| - byte-for-byte; new apps get a one-channel `edge` pipeline at create; |
| - per-channel rules are console-enforced, bake windows box-enforced through |
| - the `FirstSeen.Holds` ratchet. |
| - |
| -## Deploy reality (2026-08-02; convergence rewritten 2026-08-08) |
| - |
| -- Flagship (keymail AWS acct): console, kass, keymail, platform-site. Ship via |
| - `scripts/ship-app.sh` / `promote-app.sh` in the pinfra checkout — the signing |
| - key comes from SSM inside the script, never a terminal. |
| -- Tito production is self-hosted CARLOS on `carlos-tito-ie` (titogo AWS acct). |
| -- keymail.dev deploys via `carlos ship`/`promote` (account `bae`), NOT via its |
| - repo's legacy `deploy/deploy.sh` — see that repo's CLAUDE.md. |
| -- **Since main 1fba18f (PR #108, live both boxes 2026-08-08): convergence is |
| - seconds, not ticks.** Console-mediated writes bump `control/serial`; boxes |
| - poll it at 2s and run the full pass set. Direct-bucket promotes (pinfra |
| - scripts) don't bump but canary/* channels are name-scoped fast anyway. |
| - Blocked stages relay to a root pass (`carlos-stage.path` — must be armed; |
| - update-platform-binary.sh does it). `carlos deploy` is the member one-command |
| - form: ship → promote → wait until `X-Carlos-Version` on the URL reports the |
| - shipped build over 2xx. Verify deploys with that header (`curl -sI |
| - --resolve`), never a cached answer. `-I` is fine for the VERSION header, but |
| - it sends a HEAD — and HEAD is excluded from edge compression, so anything |
| - about `Content-Encoding` needs a GET (`curl -s -D - -o /tmp/b`) instead. |
| -- **Console restarts: manual until the route is unit-stamped** (#110, fixed |
| - in main 916cd6b, not yet rolled). Routes can now name the systemd unit that |
| - owns their process (`carlos route -registry /data/registry.db -host |
| - <console-host> -unit console.service`, run as carlos, not root); a stamped |
| - route is cycled by adoption itself, and a failed named-unit restart rolls |
| - the adoption back instead of stamping a lying header. Until a box runs |
| - 916cd6b+ AND its console row is stamped, the old rule stands: promote → |
| - adopt → `systemctl restart console.service`, and the header overclaims |
| - until the restart. Ordinary unit/exec tenants restart themselves either way. |
| -- **`carlos restart -app X`** (#113, merged 916cd6b, live after the same |
| - roll): member-facing process cycle with no version or config change — |
| - console door touches `apps/<acct>/<app>/restart-gen.json` + bumps the |
| - serial; boxes act on the serial move (exec children bounced in-process, |
| - unit-shaped routes via root `carlos adopt -restart-only` through |
| - `carlos-restart.path`, which update-platform-binary.sh arms since pinfra |
| - PR #10). Hibernating instances are never woken. Static routes carry |
| - `X-Carlos-Version` from the same roll (#112), so `carlos deploy -kind |
| - static -host <site>` verifies static sites too. |
| +Guidance for AI coding agents working in this repository lives in |
| +**AGENTS.md**. Claude Code: read AGENTS.md. Update AGENTS.md — not this |
| +file — going forward; this file is only a pointer so Claude Code finds |
| +it. |
diff --git a/domains_cli_test.go b/domains_cli_test.go| index bdf969f1f..5f8aa3fb7 100644 |
| --- a/domains_cli_test.go |
| +++ b/domains_cli_test.go |
| @@ -6,7 +6,7 @@ |
| // full verb (flag parsing, printing) against the same kind of fake console, |
| // credentials written straight to disk. Usage-error paths (log.Fatal, would |
| // kill an in-process test binary) run as a real subprocess, carlosTestEnv + |
| -// scratch cmd.Dir per CLAUDE.md's rule. |
| +// scratch cmd.Dir per AGENTS.md's rule. |
| package main |
| import ( |
| @@ -529,7 +529,7 @@ func TestRunDomainsAttachWildcardFlagSentOnlyWhenNamed(t *testing.T) { |
| // malshaped arguments as a real subprocess (buildCarlos) — the only way to |
| // observe a log.Fatal/os.Exit refusal without killing the test binary. |
| // carlosTestEnv masks CARLOS_CREDENTIALS/HOME/CARLOS_CONSOLE and cmd.Dir is |
| -// a scratch dir (CLAUDE.md's rule), so a case that DID try to dial out would |
| +// a scratch dir (AGENTS.md's rule), so a case that DID try to dial out would |
| // fail on "not logged in", not silently succeed. |
| func TestDomainsCLIUsageErrors(t *testing.T) { |
| bin := buildCarlos(t) |
diff --git a/fleets_cli_test.go b/fleets_cli_test.go| index 167ef85c2..3a0f257d9 100644 |
| --- a/fleets_cli_test.go |
| +++ b/fleets_cli_test.go |
| @@ -649,7 +649,7 @@ func TestRunAppsPlaceHitsRightPathAndClears(t *testing.T) { |
| // a log.Fatal/os.Exit refusal without killing the test binary. Every case |
| // here must exit nonzero without ever reaching the network: carlosTestEnv |
| // masks CARLOS_CREDENTIALS/HOME/CARLOS_CONSOLE and cmd.Dir is a scratch dir |
| -// (CLAUDE.md's rule — a subprocess that inherited a real login has minted |
| +// (AGENTS.md's rule — a subprocess that inherited a real login has minted |
| // real accounts on the flagship before), so a case that DID try to dial out |
| // would fail on "not logged in", not silently succeed. |
| func TestFleetsAndPlaceCLIUsageErrors(t *testing.T) { |
diff --git a/internal/appconfig/syncexec.go b/internal/appconfig/syncexec.go| index 2c7f9a381..1c897955b 100644 |
| --- a/internal/appconfig/syncexec.go |
| +++ b/internal/appconfig/syncexec.go |
| @@ -184,7 +184,7 @@ func SyncExec(ctx context.Context, reg *registry.Registry, s release.Store, priv |
| // A value THIS binary doesn't understand, in a bundle that |
| // does state one: a statement, so it decides — treated as |
| // not opted in, loudly, once per bundle per pass, never a |
| - // hard failure (additive discipline, CLAUDE.md). |
| + // hard failure (additive discipline, AGENTS.md). |
| fmt.Printf(" %s/%s: config: exec_delivery %q in environment %q is not a value this binary understands — ignoring it (not opted in); upgrade carlos if this is expected\n", acct, app, eb.m.ExecDelivery, env) |
| c, decided = optedInRes{v: false, known: true}, true |
| } |
| @@ -233,7 +233,7 @@ func SyncExec(ctx context.Context, reg *registry.Registry, s release.Store, priv |
| default: |
| // A manifest a NEWER carlos wrote with a value THIS binary |
| // doesn't understand must not brick this box's sync (additive |
| - // discipline, CLAUDE.md) — ignore it, loudly, once, and treat |
| + // discipline, AGENTS.md) — ignore it, loudly, once, and treat |
| // the app as not opted in rather than refusing the whole pass. |
| fmt.Printf(" %s/%s: config: exec_delivery %q in the default bundle is not a value this binary understands — ignoring it (not opted in); upgrade carlos if this is expected\n", acct, app, def.m.ExecDelivery) |
| c = optedInRes{v: false, known: true} |
diff --git a/internal/appconfig/syncexec_test.go b/internal/appconfig/syncexec_test.go| index 9daa680cf..aab960ce0 100644 |
| --- a/internal/appconfig/syncexec_test.go |
| +++ b/internal/appconfig/syncexec_test.go |
| @@ -706,7 +706,7 @@ func TestSyncExecUnoptedAddrRouteIsSkipped(t *testing.T) { |
| } |
| // TestSyncExecUnrecognizedExecDeliveryValueIgnoredNotFatal covers additive |
| -// discipline (CLAUDE.md): a manifest a NEWER carlos wrote, carrying an |
| +// discipline (AGENTS.md): a manifest a NEWER carlos wrote, carrying an |
| // exec_delivery value this binary doesn't understand, must not brick this |
| // box's sync — the whole pass must still succeed, the unknown value must |
| // be treated as "not opted in" (not a fatal error, not silently treated |
diff --git a/internal/applog/record.go b/internal/applog/record.go| index 8e08f14c6..92ae49d69 100644 |
| --- a/internal/applog/record.go |
| +++ b/internal/applog/record.go |
| @@ -63,7 +63,7 @@ func levelRank(level string) int { |
| func ValidLevel(s string) bool { return levelRank(s) >= 0 } |
| // Record is one log line on the wire — one JSONL line inside a chunk. |
| -// Additive shape (CLAUDE.md rule): every field but TS and Msg is omitempty, |
| +// Additive shape (AGENTS.md rule): every field but TS and Msg is omitempty, |
| // unknown keys are ignored on read, and anything written before a shape |
| // change must still parse. TS is the box clock in UTC; cross-box order is |
| // best-effort by TS with (Box, Seq) as tiebreak — per-box order is the only |
diff --git a/internal/applog/record_test.go b/internal/applog/record_test.go| index ee89c54ae..98d176924 100644 |
| --- a/internal/applog/record_test.go |
| +++ b/internal/applog/record_test.go |
| @@ -11,7 +11,7 @@ import ( |
| // and msg is omitempty, and a record written by a future revision with keys |
| // this one has never heard of still parses. Live chunks exist in production |
| // buckets from the moment this ships — anything written before a change |
| -// must still parse (CLAUDE.md wire-shape rule). |
| +// must still parse (AGENTS.md wire-shape rule). |
| func TestRecordWireShapeIsAdditive(t *testing.T) { |
| r := Record{ |
| TS: time.Date(2026, 8, 10, 14, 3, 11, 482913000, time.UTC), |
diff --git a/internal/console/apiservices.go b/internal/console/apiservices.go| index c7a6127ae..2447b6053 100644 |
| --- a/internal/console/apiservices.go |
| +++ b/internal/console/apiservices.go |
| @@ -118,7 +118,7 @@ func (s *Server) apiOwnerAccount(w http.ResponseWriter, r *http.Request) (Princi |
| // actually be admitted to. A hand-written list in a help string would be |
| // right on the day it was written and wrong on the day a door moved. |
| // |
| -// This exists because of CLAUDE.md's no-infra rule read as a rule about |
| +// This exists because of AGENTS.md's no-infra rule read as a rule about |
| // comprehension rather than only about access: an operator must be able to |
| // see what they granted without reading a spec. |
| func (s *Server) doorsFor(role svcrole.Role) []string { |
diff --git a/internal/console/apiservices_test.go b/internal/console/apiservices_test.go| index 873d31fd8..f896bb8cc 100644 |
| --- a/internal/console/apiservices_test.go |
| +++ b/internal/console/apiservices_test.go |
| @@ -263,7 +263,7 @@ func TestMintRefusesADuplicateName(t *testing.T) { |
| } |
| // TestCreateAnswersWithTheDoorsItGranted — an operator must be able to see |
| -// what they gave away without reading a spec (CLAUDE.md's no-infra rule |
| +// what they gave away without reading a spec (AGENTS.md's no-infra rule |
| // applied to comprehension, not just access). The list is derived from the |
| // same map the gate reads, so it cannot drift from what the gate allows. |
| func TestCreateAnswersWithTheDoorsItGranted(t *testing.T) { |
diff --git a/internal/console/apistore.go b/internal/console/apistore.go| index 631d7a81a..d49849e92 100644 |
| --- a/internal/console/apistore.go |
| +++ b/internal/console/apistore.go |
| @@ -105,7 +105,7 @@ func storeEffectivePrefix(rec *objstore.Record) string { |
| // it is here because without it `status` cannot answer the question a waiting |
| // member actually has: is my declaration queued behind an operator, or does |
| // this deployment have no object storage at all and never will? The |
| -// no-infra-access rule (CLAUDE.md) says the command alone must complete the |
| +// no-infra-access rule (AGENTS.md) says the command alone must complete the |
| // job, and "declared, nothing happening" with no way to tell those apart |
| // fails it. Disclosure-wise it is the same class as secrets_pubkey and the |
| // apps domain: a deployment-wide fact about the platform, carrying nothing |
diff --git a/internal/console/economicspage_test.go b/internal/console/economicspage_test.go| index a45cf2e53..f2cd2a16a 100644 |
| --- a/internal/console/economicspage_test.go |
| +++ b/internal/console/economicspage_test.go |
| @@ -683,7 +683,7 @@ func TestEconomicsPagePooledMarginIsDash(t *testing.T) { |
| // (customer hardware, zero cost to the platform — PriceAccount's cost |
| // formula has no fleet term at all), and a number that looks like a |
| // modelling error gets an explanation somewhere on the page (dashboard-truth |
| -// rule, CLAUDE.md). The title= tooltip on the Margin header is invisible on |
| +// rule, AGENTS.md). The title= tooltip on the Margin header is invisible on |
| // touch/keyboard, so both notes ALSO need a visible one-line legend under |
| // each table — this asserts the tooltip AND the legend paragraph, on both |
| // pages, not just "the text appears somewhere" (which the tooltip alone |
diff --git a/internal/console/ladder_test.go b/internal/console/ladder_test.go| index 592e87e16..de333e509 100644 |
| --- a/internal/console/ladder_test.go |
| +++ b/internal/console/ladder_test.go |
| @@ -452,7 +452,7 @@ func TestLadderEnvironmentAndProdWiredVisibleToNonOwner(t *testing.T) { |
| // A canary promoted moments ago — exactly what every real promotion through |
| // this console produces, Promote always stamping "now" — must carry no |
| -// expired badge. This is the case CLAUDE.md's own console route is in on |
| +// expired badge. This is the case AGENTS.md's own console route is in on |
| // every deploy (canary/console, re-promoted each time): a false positive |
| // here would badge the console's own production traffic as neglected. |
| func TestLadderFreshCanaryCarriesNoExpiredBadge(t *testing.T) { |
diff --git a/internal/status/status_test.go b/internal/status/status_test.go| index 85790bde9..03ae43667 100644 |
| --- a/internal/status/status_test.go |
| +++ b/internal/status/status_test.go |
| @@ -250,7 +250,7 @@ func TestCollectBoxOmitsMetricsWhenAllUnknown(t *testing.T) { |
| // TestBoxBackCompatParse: yesterday's Box shape (no metrics key) must parse |
| // today, and a FUTURE box's unknown keys (inside or alongside metrics) must |
| -// not break today's parse either — additive wire shape, per CLAUDE.md's |
| +// not break today's parse either — additive wire shape, per AGENTS.md's |
| // "consumer = everything that PARSES the shape" rule. |
| func TestBoxBackCompatParse(t *testing.T) { |
| yesterday := []byte(`{"generated_at":"2026-08-03T10:00:00Z","label":"ada-stargazing", |
diff --git a/services_cli_test.go b/services_cli_test.go| index 3a51719c4..c03d213ce 100644 |
| --- a/services_cli_test.go |
| +++ b/services_cli_test.go |
| @@ -19,7 +19,7 @@ |
| // |
| // EVERY test here that shells out to the built carlos binary builds its env |
| // through carlosTestEnv (masks CARLOS_CREDENTIALS/HOME/CARLOS_CONSOLE) and |
| -// sets cmd.Dir to its own scratch dir — CLAUDE.md's rule, restated in this |
| +// sets cmd.Dir to its own scratch dir — AGENTS.md's rule, restated in this |
| // task's brief: a subprocess that inherited a real login has minted real |
| // accounts on the flagship before. |
| package main |
| @@ -748,7 +748,7 @@ func TestRunServicesRevokeMatchedFalseDoesNotPrintSuccess(t *testing.T) { |
| // log.Fatal/os.Exit refusal without killing the test binary. Every case |
| // here must exit nonzero without ever reaching the network: |
| // carlosTestEnv masks CARLOS_CREDENTIALS/HOME/CARLOS_CONSOLE and cmd.Dir is |
| -// a scratch dir (CLAUDE.md's rule — a subprocess that inherited a real login |
| +// a scratch dir (AGENTS.md's rule — a subprocess that inherited a real login |
| // has minted real accounts on the flagship before), so a case that DID try |
| // to dial out would fail on "not logged in", not silently succeed. |
| func TestServicesCLIUsageErrors(t *testing.T) { |
diff --git a/store.go b/store.go| index bdca699e5..c1d99e10b 100644 |
| --- a/store.go |
| +++ b/store.go |
| @@ -245,7 +245,7 @@ func runStoreRotate(args []string) { |
| // that fact is added too: a member cannot see CARLOS_STORE_BACKEND for |
| // themselves, and "declared, nothing happening" with no way to tell a queued |
| // request from a deployment that will never grant one fails the no-infra- |
| -// access rule (CLAUDE.md) just as surely as a missing CLI command would. |
| +// access rule (AGENTS.md) just as surely as a missing CLI command would. |
| func printStoreCreate(w io.Writer, res apiStoreReply) { |
| c := colorFor(w) |
| prefix := "" |
diff --git a/store_test.go b/store_test.go| index cf1f6af97..9aa983c76 100644 |
| --- a/store_test.go |
| +++ b/store_test.go |
| @@ -499,7 +499,7 @@ func TestPrintStoreStatusDeclaredNotProvisioned(t *testing.T) { |
| // ---- runStore dispatch / refusals (real binary) ----------------------------- |
| // TestStoreVerbDispatchAndRefusals drives the real binary through |
| -// carlosTestEnv with a scratch cmd.Dir — the repo rule (CLAUDE.md) — so a |
| +// carlosTestEnv with a scratch cmd.Dir — the repo rule (AGENTS.md) — so a |
| // logged-in laptop's real credential and console can never be what this test |
| // exercises: unknown sub-verb, missing -app on each sub-verb, and direct |
| // bucket mode refused with store's own sentence. |