@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.
- package/AGENTS.md +310 -0
- package/README.md +344 -0
- package/bin/ai-dev.ts +7 -0
- package/bin/elves.ts +2847 -0
- package/bin/shims/timeout +113 -0
- package/claude/CLAUDE.md +71 -0
- package/dist/github/scripts/github.js +3428 -0
- package/dist/spec/scripts/architecture.js +628 -0
- package/dist/spec/scripts/eslint-config.js +54 -0
- package/dist/spec/scripts/harness-qzb932c8.js +959 -0
- package/dist/spec/scripts/harness-v9s1v9w3.js +16045 -0
- package/dist/spec/scripts/harness.js +53 -0
- package/dist/spec/scripts/knip-hints.cjs +34 -0
- package/docs/ci-concurrency.md +82 -0
- package/docs/cloud-sessions.md +204 -0
- package/docs/judgment.md +81 -0
- package/docs/parallel-checkouts.md +220 -0
- package/docs/pr-checks.md +117 -0
- package/docs/testing.md +304 -0
- package/package.json +70 -1
- package/skills/ci-wait/SKILL.md +180 -0
- package/skills/ci-wait/scripts/wait-ci.sh +377 -0
- package/skills/dashboard/SKILL.md +212 -0
- package/skills/dashboard/scripts/dashboard.ts +912 -0
- package/skills/dashboard/scripts/sessions.ts +772 -0
- package/skills/est/SKILL.md +222 -0
- package/skills/est/reference.md +381 -0
- package/skills/est/scripts/est.ts +4867 -0
- package/skills/github/SKILL.md +206 -0
- package/skills/github/locales/en.json +449 -0
- package/skills/github/locales/ru.json +449 -0
- package/skills/github/reference.md +1073 -0
- package/skills/github/scripts/github.ts +4390 -0
- package/skills/github/scripts/locale.ts +495 -0
- package/skills/github/scripts/package.json +3 -0
- package/skills/slot/SKILL.md +145 -0
- package/skills/slot/scripts/package.json +3 -0
- package/skills/slot/scripts/slot.ts +279 -0
- package/skills/spec/SKILL.md +247 -0
- package/skills/spec/canon.md +219 -0
- package/skills/spec/checks.md +709 -0
- package/skills/spec/ci.md +378 -0
- package/skills/spec/migration.md +125 -0
- package/skills/spec/reference.md +889 -0
- package/skills/spec/scripts/architecture.ts +839 -0
- package/skills/spec/scripts/c4.ts +184 -0
- package/skills/spec/scripts/eslint-config.ts +82 -0
- package/skills/spec/scripts/harness.ts +1483 -0
- package/skills/spec/scripts/knip-hints.cjs +34 -0
- package/skills/spec/scripts/knip-hints.d.cts +17 -0
- package/skills/spec/scripts/package-run.ts +22 -0
- package/skills/spec/scripts/package.json +3 -0
- package/skills/spec/scripts/spec-break.ts +232 -0
- package/skills/spec/scripts/spec-claims.ts +198 -0
- package/skills/spec/scripts/spec-diff.ts +1111 -0
- package/skills/spec/scripts/spec-doc.ts +744 -0
- package/skills/spec/scripts/spec-exceptions.ts +233 -0
- package/skills/spec/scripts/spec-publish.ts +214 -0
- package/skills/spec/scripts/spec-run.ts +235 -0
- package/skills/spec/scripts/spec-shard.ts +267 -0
- package/skills/spec/scripts/speclib.ts +826 -0
- package/skills/spec/scripts/vendor/babel-parser.LICENSE +19 -0
- package/skills/spec/scripts/vendor/babel-parser.cjs +14615 -0
- package/skills/spec/scripts/vendor/babel-parser.d.cts +3 -0
- package/skills/task-env/SKILL.md +229 -0
- package/skills/task-env/proxy/compose.yaml +48 -0
- package/skills/task-env/reference.md +246 -0
- package/skills/task-env/scripts/task-env.ts +981 -0
|
@@ -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.
|