@miroshnik/elves 0.0.0 → 2026.1018.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/AGENTS.md +310 -0
  2. package/README.md +344 -0
  3. package/bin/ai-dev.ts +7 -0
  4. package/bin/elves.ts +2847 -0
  5. package/bin/shims/timeout +113 -0
  6. package/claude/CLAUDE.md +71 -0
  7. package/dist/github/scripts/github.js +3428 -0
  8. package/dist/spec/scripts/architecture.js +628 -0
  9. package/dist/spec/scripts/eslint-config.js +54 -0
  10. package/dist/spec/scripts/harness-qzb932c8.js +959 -0
  11. package/dist/spec/scripts/harness-v9s1v9w3.js +16045 -0
  12. package/dist/spec/scripts/harness.js +53 -0
  13. package/dist/spec/scripts/knip-hints.cjs +34 -0
  14. package/docs/ci-concurrency.md +82 -0
  15. package/docs/cloud-sessions.md +204 -0
  16. package/docs/judgment.md +81 -0
  17. package/docs/parallel-checkouts.md +220 -0
  18. package/docs/pr-checks.md +117 -0
  19. package/docs/testing.md +304 -0
  20. package/package.json +70 -1
  21. package/skills/ci-wait/SKILL.md +180 -0
  22. package/skills/ci-wait/scripts/wait-ci.sh +377 -0
  23. package/skills/dashboard/SKILL.md +212 -0
  24. package/skills/dashboard/scripts/dashboard.ts +912 -0
  25. package/skills/dashboard/scripts/sessions.ts +772 -0
  26. package/skills/est/SKILL.md +222 -0
  27. package/skills/est/reference.md +381 -0
  28. package/skills/est/scripts/est.ts +4867 -0
  29. package/skills/github/SKILL.md +206 -0
  30. package/skills/github/locales/en.json +449 -0
  31. package/skills/github/locales/ru.json +449 -0
  32. package/skills/github/reference.md +1073 -0
  33. package/skills/github/scripts/github.ts +4390 -0
  34. package/skills/github/scripts/locale.ts +495 -0
  35. package/skills/github/scripts/package.json +3 -0
  36. package/skills/slot/SKILL.md +145 -0
  37. package/skills/slot/scripts/package.json +3 -0
  38. package/skills/slot/scripts/slot.ts +279 -0
  39. package/skills/spec/SKILL.md +247 -0
  40. package/skills/spec/canon.md +219 -0
  41. package/skills/spec/checks.md +709 -0
  42. package/skills/spec/ci.md +378 -0
  43. package/skills/spec/migration.md +125 -0
  44. package/skills/spec/reference.md +889 -0
  45. package/skills/spec/scripts/architecture.ts +839 -0
  46. package/skills/spec/scripts/c4.ts +184 -0
  47. package/skills/spec/scripts/eslint-config.ts +82 -0
  48. package/skills/spec/scripts/harness.ts +1483 -0
  49. package/skills/spec/scripts/knip-hints.cjs +34 -0
  50. package/skills/spec/scripts/knip-hints.d.cts +17 -0
  51. package/skills/spec/scripts/package-run.ts +22 -0
  52. package/skills/spec/scripts/package.json +3 -0
  53. package/skills/spec/scripts/spec-break.ts +232 -0
  54. package/skills/spec/scripts/spec-claims.ts +198 -0
  55. package/skills/spec/scripts/spec-diff.ts +1111 -0
  56. package/skills/spec/scripts/spec-doc.ts +744 -0
  57. package/skills/spec/scripts/spec-exceptions.ts +233 -0
  58. package/skills/spec/scripts/spec-publish.ts +214 -0
  59. package/skills/spec/scripts/spec-run.ts +235 -0
  60. package/skills/spec/scripts/spec-shard.ts +267 -0
  61. package/skills/spec/scripts/speclib.ts +826 -0
  62. package/skills/spec/scripts/vendor/babel-parser.LICENSE +19 -0
  63. package/skills/spec/scripts/vendor/babel-parser.cjs +14615 -0
  64. package/skills/spec/scripts/vendor/babel-parser.d.cts +3 -0
  65. package/skills/task-env/SKILL.md +229 -0
  66. package/skills/task-env/proxy/compose.yaml +48 -0
  67. package/skills/task-env/reference.md +246 -0
  68. package/skills/task-env/scripts/task-env.ts +981 -0
@@ -0,0 +1,3 @@
1
+ // Types of what `speclib.ts` takes from `babel-parser.cjs`: without them a project's TypeScript with `checkJs` would
2
+ // check the file.
3
+ export declare function parse(source: string, options?: object): unknown;
@@ -0,0 +1,229 @@
1
+ ---
2
+ name: task-env
3
+ description: "A task's environment on the machine — its own Docker Compose stack `<project>-<N>`, the e2e stack on clean volumes, and the machine's proxy (Traefik in Docker) that gives task containers the addresses `http://<service>.<N>.<project>.localhost` without host ports — different hosts, different cookies: parallel sessions don't log each other out; `task-env check` holds the compose file to the task isolation rules. When — you run the task's app, its services or e2e locally; a task needs a database, a queue or another service; Docker is out of network subnets or closed tasks' stacks hang on the machine; `check -g` or the session start says the proxy isn't running; `check` printed ❌ or ○ lines about the compose file; a task's `*.localhost` address doesn't open; you add Traefik labels or edit a compose file; two sessions fight over a port, a container or a volume; port 80 is busy; asked how task addresses work, about Secure cookies or the sandbox — even if 'proxy' or 'isolation' wasn't said."
4
+ allowed-tools: Bash(bun *skills/task-env/scripts/task-env.ts *)
5
+ ---
6
+
7
+ # task-env — a task's environment: its own Compose stack, addresses `*.localhost` through one proxy
8
+
9
+ Parallel sessions of one project run their apps at once. On `localhost` with
10
+ different ports they share cookies — cookies don't tell ports apart — and an
11
+ agent's browser and the owner's log each other out. A host per app solves it:
12
+ `*.localhost` resolves to the loopback and is a secure context in browsers,
13
+ and a different host has its own cookies. One proxy per machine — Traefik in
14
+ Docker — routes these hosts to the containers of task stacks by their labels:
15
+ no host port per app, no port search. The scheme and the reasons — epic
16
+ "Environment" in Elves (`tests/capabilities/task-env/task-env.md`).
17
+
18
+ ```bash
19
+ bun --no-env-file <skill dir>/scripts/task-env.ts up [--profile <p>]... # the task's stack by the branch, its addresses
20
+ bun --no-env-file <skill dir>/scripts/task-env.ts e2e [--profile <p>]... [-- <command>] # the e2e stack anew, the command with its addresses
21
+ bun --no-env-file <skill dir>/scripts/task-env.ts down [<N>] # the task's stacks with their volumes
22
+ bun --no-env-file <skill dir>/scripts/task-env.ts prune # stacks of the project's closed tasks
23
+ bun --no-env-file <skill dir>/scripts/task-env.ts proxy up # start it (or bring it to the compose file), wait until healthy
24
+ bun --no-env-file <skill dir>/scripts/task-env.ts proxy status # 0 — running, 1 — not running (the start command), 2 — no Docker
25
+ bun --no-env-file <skill dir>/scripts/task-env.ts proxy down # stop and remove it with its network
26
+ bun --no-env-file <skill dir>/scripts/task-env.ts check [<dir>] # the compose file against the isolation rules: 0 — hold, 1 — ❌ lines, 2 — not checked
27
+ ```
28
+
29
+ Each command's details and examples — `--help` after it; the details behind this file — `reference.md` next to it.
30
+ Run the script outside the sandbox — section "Docker and the sandbox".
31
+
32
+ The skill's `check -g` line: `proxy *.localhost: running (elves-proxy)`,
33
+ `not running — start: …` or `Docker unavailable — <reason>`; no Docker on the
34
+ machine — no line. It doesn't change the code of `check`: the proxy isn't the
35
+ flow falling behind.
36
+
37
+ ## The task's stack
38
+
39
+ The task's apps, databases, queues and mail live in the project's compose file; `up` brings a copy of it up as the
40
+ task's own Compose project, and nothing of it is shared with other tasks.
41
+
42
+ | what | value |
43
+ |---|---|
44
+ | task | the number from the branch `[<area>/]<type>/<N>-<slug>`; no number (`main`, a worktree branch before the rename) — the main checkout's stack |
45
+ | `<project>` | the repository of `origin` (the last part of its URL without `.git`), no remote — the main checkout's directory; lowercase letters, digits and `-`. Not the directory Compose takes by default — a worktree's is random |
46
+ | stack | Compose project `<project>-<N>`, e2e — `<project>-<N>-e2e`, without a task — `<project>`, `<project>-e2e` |
47
+ | profiles | `--profile <name>` (repeated) goes to Compose: a heavy service in `profiles` runs only in a task that asks for it |
48
+ | addresses | HTTP services of `env.http` — `http://<service>.<N>.<project>.localhost` (the proxy's port when it isn't 80); a published port — `127.0.0.1:<host port> → <port>` |
49
+
50
+ The settings — `env.http` in the project's `.agents/elves.json` (an install from before the rename — in
51
+ `.agents/ai-dev.json` until `update` moves it), a service → its HTTP port inside the container (`install` and `update`
52
+ keep the field): in a compose file a database's port doesn't differ from an app's.
53
+
54
+ ```json
55
+ { "env": { "http": { "web": 3000, "admin": 5173 } } }
56
+ ```
57
+
58
+ A service of `env.http` that isn't in the compose file — a `!` line, not an error. No `env.http` — no labels, no
59
+ proxy, only published ports.
60
+
61
+ **The app's own address** — from the compose file: `up` and `e2e` give Compose's process the address of each
62
+ `env.http` service in `TASK_ENV_<SERVICE>_URL` (the names the e2e command gets), and the compose file passes them to the app — a base URL,
63
+ an OAuth redirect URI, links in emails — with a default for `docker compose` without `task-env` (CI, by hand):
64
+
65
+ ```yaml
66
+ services:
67
+ web:
68
+ environment:
69
+ APP_URL: ${TASK_ENV_WEB_URL:-http://localhost:3000}
70
+ NEXTAUTH_URL: ${TASK_ENV_WEB_URL:-http://localhost:3000}
71
+ worker:
72
+ environment:
73
+ LINK_BASE_URL: ${TASK_ENV_WEB_URL:-http://localhost:3000} # another service's address too
74
+ ```
75
+
76
+ The e2e stack gets its own addresses (`http://web.e2e.42.shop.localhost`), not the dev ones of the task. The
77
+ variables live only in the environment of the script's Compose process — no `.env`, no file in the project:
78
+ `docker compose up` by hand gets the default. A public variable baked in at build time (`NEXT_PUBLIC_*`, `VITE_*`)
79
+ goes into `build.args` the same way: Compose interpolates them too.
80
+
81
+ **The override** — the proxy's labels and network for the `env.http` services (a database isn't published), on
82
+ Compose's stdin, not a file; `up` starts the proxy first if it isn't running — reference.md, "Override and compose
83
+ files"; the labels — reference.md, "Labels of a task stack's service".
84
+
85
+ **e2e** — `<project>-<N>-e2e`, apart from the dev stack of the task: `down --volumes` and `up` again on every run, so
86
+ the run starts on clean volumes; then the command runs on the host (the current directory, the script's environment)
87
+ with the addresses of the e2e stack:
88
+
89
+ | variable | value |
90
+ |---|---|
91
+ | `TASK_ENV_PROJECT` | the e2e stack's Compose project: `docker compose -p "$TASK_ENV_PROJECT" exec db …` |
92
+ | `TASK_ENV_<SERVICE>_URL` | the HTTP service's address: `http://web.e2e.42.shop.localhost` |
93
+ | `TASK_ENV_<SERVICE>_<PORT>` | a published port: `TASK_ENV_DB_5432=127.0.0.1:55012`; not tcp — `_<PORT>_UDP` |
94
+
95
+ `<SERVICE>` — the service in capitals, other characters `_`. The exit code is the command's; without a command
96
+ `e2e` only brings the stack up. The stack stays until the next run: a failed run is examined on it
97
+ (`docker compose -p <project>-<N>-e2e logs`).
98
+
99
+ **down** — both stacks of the task with their volumes; without a number — the branch's task; other tasks' stacks and
100
+ the main checkout's aren't touched; `task close` of the `github` skill calls it — reference.md, "Taking a task's
101
+ stacks down". **prune** — the stacks of the project's closed tasks left on the machine go with their volumes, without
102
+ a question: a closed task's stack is nobody's; what is left — reference.md, "Stacks of closed tasks". **A stack that
103
+ didn't come up** — code 1, each failed service with its state and log — reference.md, "A stack that didn't come up".
104
+ Exit codes — reference.md, "Exit codes".
105
+
106
+ ### Network subnets
107
+
108
+ Every stack is a network of its own, and Docker Desktop's default address pools hold about 30 networks on the
109
+ machine: stacks of other projects (worktrees named by the directory) and forgotten task stacks use them up, and `up`
110
+ fails with `all predefined address pools have been fully subnetted`. The fix — take down stacks nobody needs
111
+ (`task-env prune` — the project's closed tasks, other stacks — `docker compose ls`) or give Docker smaller subnets, the
112
+ owner's step: Docker Desktop → Settings → Docker Engine,
113
+
114
+ ```json
115
+ { "default-address-pools": [{ "base": "172.16.0.0/12", "size": 24 }] }
116
+ ```
117
+
118
+ — 4096 networks of 254 addresses.
119
+
120
+ ## The proxy
121
+
122
+ `proxy/compose.yaml` next to this file — the Compose project `elves-proxy`: Traefik publishes only containers with
123
+ `traefik.enable=true` on `127.0.0.1:80` — not on the local network — and comes back with Docker (`proxy down`
124
+ removes it for good). Routes follow Docker events — no reload; an unknown host — `404`. `proxy up` again after a
125
+ skill update brings the container to the new compose file. Its network, health and the API the dashboard reads
126
+ addresses from — reference.md, "The proxy".
127
+
128
+ **Before the rename** the proxy was `ai-dev-proxy`. `proxy up` (and `up`, which starts the proxy) takes its place:
129
+ removes its container, which holds the port. Its network the proxy doesn't join: a stack brought up before the rename
130
+ gets its addresses back by `up` again — reference.md, "The proxy before the rename".
131
+
132
+ ## Compose file isolation rules
133
+
134
+ The task's stack is the Docker Compose project `<project>-<N>`, and Compose
135
+ keeps stacks apart by the project name — containers, volumes and the network
136
+ get its prefix. Four entries in a compose file bypass the prefix and break
137
+ isolation silently; the project's `check` (and the session start hook) names
138
+ each with `task-env check`, a violation is code 1 of `check`.
139
+
140
+ | Rule | Breaks | Instead |
141
+ |---|---|---|
142
+ | no `container_name` | a second stack can't start: one container name per machine | nothing — Compose names containers by the project |
143
+ | no fixed host port (`5432:5432`, `127.0.0.1:5432:5432`) | a second stack can't start: the port is taken | `127.0.0.1::5432` (Docker picks the port) or no `ports`; from outside — Traefik or `docker compose port` |
144
+ | volumes and networks without `name:` and `external` | stacks share them: `down -v` of one task wipes another's data, services of different tasks see each other | nothing — Compose names them `<project>_<key>` |
145
+ | code by relative paths in `volumes` and `build` | the stack mounts and builds someone else's checkout, not its own worktree | a path relative to the compose file (`./src`) |
146
+
147
+ A bind mount of a machine resource — the Docker socket, `/etc/localtime`, a
148
+ device (a source in `/var/run`, `/run`, `/etc`, `/dev`, `/proc`, `/sys`) — is
149
+ not code and is fine; `~/…` is an absolute path.
150
+
151
+ `task-env check` reads `docker compose config`, not the YAML, so every form of writing is covered, and needs no Docker
152
+ daemon — reference.md, "What the compose file check reads".
153
+
154
+ ### What the agent does
155
+
156
+ | Moment | Action |
157
+ |---|---|
158
+ | The project's `check` (the session start hook) printed `❌ <file>: …` | each line names the subject (a service, or a volume or network with the services using it), the rule and the fix; fixing it is a task of its own in the project, not the current one |
159
+ | `○ <file>: not checked — …` | the check couldn't run (no bun, no `docker` or its `compose` plugin, the config doesn't parse — the line has the reason): nothing is known about violations, it isn't an error |
160
+ | You edit a compose file | `task-env check` before the commit |
161
+
162
+ ## Settings
163
+
164
+ - `ELVES_PROXY` — the proxy's name: its Compose project and network
165
+ (default `elves-proxy`); `ELVES_PROXY_PORT` — its port on `127.0.0.1`
166
+ (default 80; unset — `AI_DEV_PROXY_PORT`, its name before the rename, if a
167
+ profile still sets it). Tests run their own proxy with them next to the
168
+ machine's one. Port 80 busy — another port, and the addresses carry it:
169
+ `http://web.42.shop.localhost:8080`. The defaults live only in the script
170
+ (`proxyName`, `proxyPort`): it passes them to the compose file explicitly,
171
+ the file has `${…:?}` — `docker compose -f proxy/compose.yaml` by hand needs
172
+ them set (`composeEnv`); `check -g` of the installer imports `proxyName`.
173
+
174
+ ## Checked on the machine (2026-10-13)
175
+
176
+ In short; versions, measurements and how — reference.md, "Checked on the machine".
177
+
178
+ - Port 80 on macOS without sudo — Docker Desktop publishes `127.0.0.1:80`.
179
+ - `Secure` and `__Host-` cookies over `http://*.localhost` work in Chromium, through the proxy too.
180
+ - The Claude Code sandbox doesn't reach the addresses by default — section "Local ports and task addresses".
181
+ - An `excludedCommands` entry takes a Bash call out only when it covers every command in it; none takes out a script
182
+ run by `bun` (Claude Code's matcher), a script under `bash` — yes.
183
+ - HMR of an app in a container with the worktree's code works through the proxy.
184
+
185
+ ## Docker and the sandbox
186
+
187
+ Docker doesn't work inside the Claude Code sandbox: the sandbox denies its
188
+ socket (`permission denied while trying to connect to the docker API`).
189
+
190
+ - **This script**, like any skill script run by `bun`, — an unsandboxed
191
+ Bash call (`dangerouslyDisableSandbox`): an `excludedCommands` entry
192
+ doesn't take it out (above, section "Checked on the machine"). In the sandbox the script exits with code 2
193
+ and names it — the cue to run the command again outside.
194
+ - **Docker itself**, which the agent calls directly (`docker compose -p
195
+ <stack> logs`, `exec`, `ps`), — the owner takes it out in
196
+ `~/.claude/settings.json`: `sandbox.excludedCommands`, entries in the
197
+ `Bash(...)` rule syntax; each command of the call must be covered, so a
198
+ call without pipes and redirections.
199
+
200
+ ```json
201
+ { "sandbox": { "excludedCommands": ["docker compose *", "docker ps *"] } }
202
+ ```
203
+
204
+ A command outside the sandbox goes through the regular permissions (in auto
205
+ mode — the classifier), and access to Docker is access to the whole machine.
206
+ The e2e command is a child of the script and runs outside the sandbox with
207
+ it.
208
+
209
+ ### Local ports and task addresses
210
+
211
+ Without the setting the sandbox denies every connection to `127.0.0.1` and
212
+ every listen on it. The owner turns it on in `~/.claude/settings.json`:
213
+
214
+ ```json
215
+ { "sandbox": { "network": { "allowLocalBinding": true } } }
216
+ ```
217
+
218
+ - **A task's apps from sandboxed commands** — curl, a test runner, a script
219
+ reach `http://<service>.<N>.<project>.localhost` through the proxy without
220
+ leaving the sandbox; without the setting only an unsandboxed command or the
221
+ agent's browser pane does.
222
+ - **Tests that open a local port** — the dashboard's (its server, a fake proxy
223
+ API) and this skill's (a fake Traefik API) — run in the sandbox.
224
+ - **The dashboard's server** listens in the sandbox and reads the proxy's
225
+ routes, but the dashboard itself still runs outside it: the `gh` it starts
226
+ fails TLS there (the `dashboard` skill, section "Pitfalls").
227
+
228
+ It opens the loopback only: Docker stays outside the sandbox (above), other
229
+ hosts — by the sandbox's allowlist.
@@ -0,0 +1,48 @@
1
+ # The machine's proxy (the task-env skill, SKILL.md, «The proxy»): one Traefik per machine routes
2
+ # http://<service>.<N>.<project>.localhost to containers of task stacks by their labels. Started by
3
+ # `task-env proxy up`. The name — the Compose project and the network — and the port come from ELVES_PROXY and
4
+ # ELVES_PROXY_PORT, which scripts/task-env.ts passes explicitly (`composeEnv`): their defaults live only there, so the
5
+ # file has none (`${…:?}`) and doesn't come up without the script.
6
+ name: ${ELVES_PROXY:?set by task-env.ts}
7
+ services:
8
+ traefik:
9
+ image: traefik:v3.7
10
+ command:
11
+ - --global.checkNewVersion=false
12
+ - --global.sendAnonymousUsage=false
13
+ - --entryPoints.web.address=:80
14
+ - --providers.docker=true
15
+ # a container is published only with traefik.enable=true
16
+ - --providers.docker.exposedByDefault=false
17
+ # task containers sit in several networks: a task stack's service names the proxy's one by the label
18
+ # traefik.docker.network; a container without the label is reached through the proxy's own
19
+ - --providers.docker.network=${ELVES_PROXY:?set by task-env.ts}
20
+ # the API — the routes for the dashboard: on the web entry point by the host <name>.localhost (labels below),
21
+ # without Traefik's own UI
22
+ - --api=true
23
+ - --api.dashboard=false
24
+ # /ping on the internal entry point :8080 — for the healthcheck, not published
25
+ - --ping=true
26
+ ports:
27
+ # the loopback only: task apps aren't visible on the local network
28
+ - 127.0.0.1:${ELVES_PROXY_PORT:?set by task-env.ts}:80
29
+ labels:
30
+ # the router is named after the proxy: a test proxy sees this container's labels too, and routers of one name
31
+ # with different rules Traefik would drop
32
+ - traefik.enable=true
33
+ - traefik.http.routers.${ELVES_PROXY:?set by task-env.ts}-api.rule=Host(`${ELVES_PROXY:?set by task-env.ts}.localhost`)
34
+ - traefik.http.routers.${ELVES_PROXY:?set by task-env.ts}-api.entrypoints=web
35
+ - traefik.http.routers.${ELVES_PROXY:?set by task-env.ts}-api.service=api@internal
36
+ volumes:
37
+ - /var/run/docker.sock:/var/run/docker.sock:ro
38
+ networks: [proxy]
39
+ restart: unless-stopped
40
+ healthcheck:
41
+ test: [CMD, traefik, healthcheck, --ping]
42
+ interval: 30s
43
+ start_period: 30s
44
+ start_interval: 1s
45
+ networks:
46
+ proxy:
47
+ # a fixed name: task stacks join it as external
48
+ name: ${ELVES_PROXY:?set by task-env.ts}
@@ -0,0 +1,246 @@
1
+ # task-env — reference
2
+
3
+ The details behind `SKILL.md` of this skill: what `up` passes to Compose, how `down`, `prune` and a stack that didn't
4
+ come up report, exit codes, the proxy's container and API, the labels of a task stack's service, what the compose file
5
+ check reads and what was checked on the machine. When to run what and the rules — `SKILL.md`; each command's syntax, exit codes and
6
+ examples — `task-env <command> --help`.
7
+
8
+ ## Override and compose files
9
+
10
+ | what | value |
11
+ |---|---|
12
+ | compose files | the project's (`compose.yaml` … `docker-compose.yml`, Compose's names), its override file (`compose.override.yml` … — Compose takes none of them by itself once `-f` is given) and the task's override on Compose's stdin (`-f -`) |
13
+ | up | `up --detach --wait --build --remove-orphans`: built images are the worktree's code, `--wait` returns when services are running and healthy (at most 300 s) |
14
+
15
+ **The override** is the only thing the script adds and it isn't a file: the proxy's labels and network for the
16
+ `env.http` services (below, section "Labels of a task stack's service"), nothing to the others — a database isn't published.
17
+ A `compose.override.yaml` would be the project's own, and `check` would name its external network. The proxy creates
18
+ the network, so `up` starts the proxy first if it isn't running (as `proxy up`); a running one isn't touched.
19
+
20
+ ## Taking a task's stacks down
21
+
22
+ **down** — `down --volumes --remove-orphans` of `<project>-<N>` and `<project>-<N>-e2e` by the Compose project, from
23
+ `/` without the compose file: the worktree may be gone already, and other profiles' services go too. Other tasks'
24
+ stacks and the main checkout's aren't touched. `down` without a number — the branch's task. `task close` of the
25
+ `github` skill calls it in the task's checkout. A line per stack:
26
+
27
+ ```
28
+ + stack shop-42: down, with its volumes
29
+ ○ stacks shop-42, shop-42-e2e: none on the machine
30
+ ! stack shop-42: not removed — <Docker's reason>
31
+ ```
32
+
33
+ Only the stacks that are on the machine are taken down — found by the label of containers, volumes and networks
34
+ (`com.docker.compose.project`), not by `docker compose ls`: a stack left with volumes only (`down` without `-v`,
35
+ containers removed by hand) isn't in its list. No compose file in the checkout — `○ no compose file … — the project
36
+ has no task stacks`, Docker isn't called: `task close` of a project without Compose doesn't depend on Docker running.
37
+ No docker on the machine — an `○` line; Docker doesn't answer — a `!` line with the reason.
38
+
39
+ ## Stacks of closed tasks — prune
40
+
41
+ **prune** — the stacks of the project's closed tasks left on the machine (orphans: a task closed without `task
42
+ close`, a close that couldn't reach Docker, a task that removed the compose file): every stack `<project>-<N>` and
43
+ `<project>-<N>-e2e` on the machine, the task's state by GitHub (`gh api repos/<owner>/<repo>/issues/<N>`, the
44
+ repository — the checkout's `origin`). Closed — the stacks go with their volumes right away, without a question: a
45
+ closed task's stack is nobody's.
46
+
47
+ ```
48
+ ○ stack shop-7: no task #7 in acme/shop — left
49
+ + stack shop-41: down, with its volumes — task #41 is closed
50
+ ○ stack shop-42: task #42 is open — left
51
+ ○ stack shop-9: its containers are from /work/shop-9 — another project, left
52
+ ```
53
+
54
+ Left: an open task's stack, the main checkout's (`<project>`, `<project>-e2e`), a number that isn't an issue (a PR
55
+ or none), a state that couldn't be checked (a `!` line), and a stack whose containers ran in an existing directory of
56
+ another project — the repository `shop-9` has the main stack `shop-9`, the same name as task #9's stack of `shop`.
57
+
58
+ ## A stack that didn't come up
59
+
60
+ Code 1 and what failed: the first line is Compose's reason, then each failed service (not running, exited not with 0,
61
+ unhealthy) with its state, the last healthcheck's output and the last 20 lines of its log; nothing failed in
62
+ containers (an image isn't pulled, a build broke) — the end of Compose's output.
63
+
64
+ ```
65
+ stack shop-42: not up — container shop-42-api-1 exited (3)
66
+ ✗ api: exited (exit 3)
67
+ | Error: connect ECONNREFUSED db:5432
68
+ ```
69
+
70
+ ## Exit codes
71
+
72
+ `up` — 0 up, 1 not up, 2 not tried (no compose file, broken settings, Docker unavailable, `docker compose config`
73
+ fails); `e2e` — the command's, otherwise as `up`; `down` and `prune` — 0, 2 if a stack isn't removed, Docker is
74
+ unavailable or (`prune`) a task's state isn't checked; `proxy status` — 0 running, 1 not running (the start command),
75
+ 2 no Docker; `check` — 0 the rules hold or no compose file, 1 violations, 2 not checked.
76
+
77
+ ## The proxy — container, routes, API
78
+
79
+ `proxy/compose.yaml` of the skill — the Compose project `elves-proxy`:
80
+
81
+ | what | value |
82
+ |---|---|
83
+ | image | `traefik:v3.7` |
84
+ | provider | Docker, `exposedByDefault: false` — a container without `traefik.enable=true` isn't published |
85
+ | network | `elves-proxy`: the proxy reaches a task stack's service through it — the service names it by the label `traefik.docker.network` |
86
+ | default network | `providers.docker.network` — for a container without the label: `elves-proxy`, the proxy's own |
87
+ | entry point | `web` — HTTP on `127.0.0.1:80`; not on all interfaces: task apps aren't visible on the local network |
88
+ | restart | `unless-stopped` — comes back with Docker; `proxy down` removes it for good |
89
+ | health | `traefik healthcheck --ping`: `proxy up` returns when the proxy serves |
90
+ | API | Traefik's routes on the `web` entry point by the host `elves-proxy.localhost` — a router `elves-proxy-api` of the proxy's own labels to `api@internal`; no UI |
91
+
92
+ Routes follow Docker events: a container appears in them when it starts and
93
+ leaves them when it stops — no reload. An unknown host — `404` from Traefik.
94
+ `proxy up` again after a skill update brings the container to the new compose
95
+ file.
96
+
97
+ The API is how the dashboard sees a task's addresses: `proxyHosts` of the
98
+ script reads `GET /api/http/routers` (page by page) on the proxy's port with
99
+ the host `<name>.localhost` and returns the hosts of the enabled routers of
100
+ `web`; the proxy isn't running — none. Not the Docker socket: access to
101
+ Docker is access to the whole machine, while the routes over HTTP need only
102
+ the loopback and answer the same in the sandbox and outside it. Not
103
+ a published `:8080`: a second host port is one more setting and one more
104
+ conflict, while `web` is on `127.0.0.1` already; a site that pointed its name
105
+ at `127.0.0.1` (DNS rebinding) comes with its own host and gets `404`. The
106
+ router is named after the proxy: a test proxy sees the labels of the
107
+ machine's one, and routers of one name with different rules Traefik would
108
+ drop.
109
+
110
+ ## The proxy before the rename
111
+
112
+ Before the rename of the flow (ai-dev → Elves) the proxy was the Compose
113
+ project `ai-dev-proxy` with the network of that name. `proxy up` — and `up`,
114
+ which starts a proxy that isn't running — takes its place:
115
+
116
+ - **its container goes** — it holds the port; the new one is created first
117
+ (the image pulled), so a failed pull leaves the former proxy serving:
118
+ `proxy ai-dev-proxy: removed — the proxy before the rename; elves-proxy
119
+ takes its port`;
120
+ - **its network isn't joined** — the proxy is in its own network only, even
121
+ with `ai-dev-proxy` on the machine; a stack brought up before the rename,
122
+ left in `ai-dev-proxy` alone, gets its addresses back by `up` again: the
123
+ override puts its HTTP services into `elves-proxy`;
124
+ - **`check`** holds a network `ai-dev-proxy` written in a project's compose
125
+ file (`networks: [default, ai-dev-proxy]`, `external: true`) to the rule of
126
+ any external network; the fix — remove it, `up` adds the proxy's network
127
+ itself;
128
+ - **the port** — `ELVES_PROXY_PORT`, unset — `AI_DEV_PROXY_PORT` of the
129
+ profile; **the settings** — `.agents/elves.json`, before `update` moves it
130
+ — `.agents/ai-dev.json`.
131
+
132
+ The former proxy is `ai-dev-proxy` only for the proxy of the default name;
133
+ a proxy of another name (a test's) has `<name>-former`, so a test never
134
+ touches the machine's proxy.
135
+
136
+ ## Labels of a task stack's service
137
+
138
+ The task stack's override (not the project's compose file) gives each HTTP
139
+ service the labels and the proxy network; `proxyLabels` of the script builds
140
+ them, `up` and `e2e` pass them to Compose on stdin:
141
+
142
+ ```yaml
143
+ services:
144
+ web:
145
+ networks: [default, elves-proxy]
146
+ labels:
147
+ traefik.enable: "true"
148
+ traefik.docker.network: elves-proxy
149
+ traefik.http.routers.<stack>-web.rule: Host(`web.<N>.<project>.localhost`)
150
+ traefik.http.routers.<stack>-web.entrypoints: web
151
+ traefik.http.routers.<stack>-web.service: <stack>-web
152
+ traefik.http.services.<stack>-web.loadbalancer.server.port: "3000"
153
+ networks:
154
+ elves-proxy:
155
+ external: true
156
+ ```
157
+
158
+ The override is the script's, not the project's: a compose file that writes
159
+ it by hand shares the proxy's network as `external`, and `check` names it.
160
+
161
+ - `<stack>` — the stack's Compose project: `<project>-<N>`, `<project>-<N>-e2e`,
162
+ `<project>` without a task. The router and the service are named
163
+ `<stack>-<service>` — unique on the machine; replicas (`--scale`) share the
164
+ labels, Traefik balances between them.
165
+ - The address: `<service>.<N>.<project>.localhost`, e2e —
166
+ `<service>.e2e.<N>.<project>.localhost`, without a task —
167
+ `<service>.<project>.localhost`. `<project>` — the repository name in
168
+ lowercase letters, digits and `-` (`hostProject` of the script); a task's
169
+ host is parsed by `taskAddress`, its URL with the proxy's port — `addressUrl`.
170
+ - The port — the service's HTTP port inside the container; nothing is
171
+ published on the host.
172
+ - The network is `external`: the proxy creates it, so `proxy up` goes before
173
+ the task stack — `up` and `e2e` start it themselves.
174
+ - `traefik.docker.network` — the network the proxy reaches the service
175
+ through: the service is in its own networks too, and the label names the
176
+ proxy's one, so the pick doesn't depend on the provider's default network.
177
+ Without the label a container outside that default gets from Traefik "the
178
+ first" of its networks in Go's map order — a random one per configuration,
179
+ and a stack in its `default` and the proxy's network would answer or not by
180
+ chance.
181
+ - Networks: the service's own as Compose normalized them (one without
182
+ `networks` is in `default`) plus the proxy's — listed networks replace the
183
+ implicit `default`, and a key with null keeps the project's aliases.
184
+
185
+ ## What the compose file check reads
186
+
187
+ Not the YAML itself but `docker compose config --format json` in the
188
+ project directory: Compose has already brought the short and long syntax, the
189
+ override file, variables and `extends` to one form. With
190
+ `--no-path-resolution` (otherwise relative paths come out absolute) and
191
+ `--profile '*'` (otherwise services in `profiles` aren't shown). Volumes and
192
+ networks Compose names `<project>_<key>` by itself — only another name or
193
+ `external` is a violation. The Docker daemon isn't needed, the command works
194
+ in an agent's sandbox. The compose file is looked for under Compose's names in
195
+ its order: `compose.yaml`, `compose.yml`, `docker-compose.yaml`,
196
+ `docker-compose.yml`.
197
+
198
+ ## Checked on the machine (2026-10-13)
199
+
200
+ - **Port 80 on macOS without sudo** — yes: Docker Desktop (engine 29.8) publishes
201
+ `127.0.0.1:80` for a user process; `proxy up` to healthy in ~5 s.
202
+ - **`Secure` cookies over `http://*.localhost` in Chromium** (Playwright 1.63,
203
+ Chromium 153) — work: `isSecureContext` is true, the browser keeps and sends
204
+ back `Secure`, `Secure; HttpOnly` and `__Host-` cookies set by the server and
205
+ by `document.cookie`, through the proxy on port 80 too. Chromium resolves
206
+ `*.localhost` itself; `[::1]` gets no answer (the port is IPv4 only) and it
207
+ goes to `127.0.0.1`.
208
+ - **The Claude Code sandbox** doesn't reach the addresses by default: a direct
209
+ connection to the loopback is denied (`Operation not permitted`, `EPERM`
210
+ in Bun), and `*.localhost` falls under `localhost` in the sandbox's
211
+ `NO_PROXY` (curl matches it as a suffix), so the request doesn't go
212
+ through the sandbox proxy either. What works: the command outside the
213
+ sandbox (`SKILL.md`, "Docker and the sandbox"); the agent's browser
214
+ pane — it is outside the sandbox; through the sandbox proxy with an empty
215
+ `NO_PROXY` and the host in the allowlist (`200` measured).
216
+ - **`sandbox.network.allowLocalBinding: true`** (desktop app; the edit
217
+ applied to the running session without a restart) — sandboxed commands
218
+ reach the loopback: a task's address `http://web.<N>.<project>.localhost:<port>`
219
+ answers `200` to curl and to Bun `fetch`, the proxy's API gives its routes,
220
+ a closed port is `ECONNREFUSED` instead of `EPERM`; a command also listens
221
+ on a local port and connects to it. What it is for — `SKILL.md`, "Local
222
+ ports and task addresses".
223
+ - **`sandbox.excludedCommands`** (Claude Code in the desktop app): an edit
224
+ of `~/.claude/settings.json` applies to the running session without a
225
+ restart; an entry takes a Bash call out of the sandbox only when it covers
226
+ every command in it — `docker ps | head`, `…; echo` or a redirection to a
227
+ file keep the call sandboxed (the Claude Code docs say the same).
228
+ `docker compose *`, `docker ps *` and `gh *` work. No entry takes out a
229
+ script run by `bun`: `bun …/task-env.ts proxy status` stayed sandboxed
230
+ with the entry written with `~` and with the absolute path, `bun …/github.ts project check` — with the absolute
231
+ path through the `~/.agents/skills` symlink, the call's text matching the
232
+ entry exactly. The cause is Claude Code's matcher, not the path (2.1.295;
233
+ the docs list the call shapes that stay sandboxed only "among others"):
234
+ `bun` leaves the sandbox only when its first argument that isn't a flag is
235
+ a subcommand of its own — `install`, `add`, `test` and the like, and `bun
236
+ run` not even then; a file to run is code it doesn't exempt. A script
237
+ under `bash` is taken out: `bash ~/.agents/skills/ci-wait/scripts/wait-ci.sh
238
+ *` works with the absolute path through the symlink. Checking an entry: in
239
+ auto mode Claude Code refuses `allowed_domains` on a call that would run
240
+ outside the sandbox, before running it.
241
+ - **HMR of an app in a container with the worktree's code** — works: Vite 8.3
242
+ (`node:24-alpine`, the worktree bind-mounted to `/app`, `node_modules` in a
243
+ volume) on Docker Desktop (engine 29.8) through the proxy on port 80 — a CSS
244
+ change on the host reached the page in Chromium without a reload: file
245
+ events of the bind mount reach the container, the HMR WebSocket passes
246
+ Traefik, and Vite allows `*.localhost` hosts by default.