# devkit Shared [devenv](https://devenv.sh) module: my personal dev tools (`bug`, `secrets`, `local-code-review`) and a jujutsu toolchain. Importing devkit keeps every project's toolchain identical and lets a single edit here propagate to all of them. Shell-side helpers (prompt indicator, nested-shell warning) live in `fish/` and are installed once per machine. ## Use it in a project devenv.yaml: ```yaml inputs: nixpkgs: url: github:cachix/devenv-nixpkgs/rolling devkit: url: git+https://knot.rtw.run/rtw.run/devkit imports: - devkit ``` That's the whole wiring. devkit pins the tools (`bug`, `secrets`, `local-code-review`) in its own `flake.lock` and hands them to the project through the `devkit` input, so a project declares no tool inputs of its own and its `devenv.lock` records them as devkit's. (devenv#2205: an imported input's devenv.yaml isn't evaluated, which is why the pins live in devkit's flake, not its devenv.yaml.) A project's own `devenv.nix` only needs its project-specific packages/env. To try an unreleased tool in one project, declare that tool's input in the project's devenv.yaml: a project-level input wins over devkit's pin. Disable a tool with, e.g., `devkit.bug.enable = false;` (used by a project that ships its own wrapper, like sovrn). ### bug agent skill When `bug` is enabled, entering the shell copies the `SKILL.md` shipped with the pinned `bug` package to `~/.agents/skills/working-with-bug/` (Claude) and `~/.agents/skills/superpowers/working-with-bug/` (OpenCode with superpowers). The file is rewritten only when it differs, so `devenv update bug` followed by the next `devenv shell` is all it takes to update the skill. It is a copy, not a symlink, so garbage collection can't leave it dangling. Turn it off with `devkit.bug.skill.enable = false;` or change targets with `devkit.bug.skill.dirs`. Pins of `bug` older than v1.3.0 don't ship the skill and are skipped. ### jj: bugs sync with the code; no Claude attribution; no git worktrees devkit puts a `jj` wrapper (`jj/jj`) ahead of jujutsu on the shell's PATH. Every command runs unchanged, plus: - **Bug sync.** In a repo where `bug init` ran, a successful `jj git push` (not `--dry-run`) runs `bug sync` to the same remote (`--remote`, else jj's `git.push`, else `origin`), and a successful `jj git fetch` runs `bug sync --pull` from each fetched remote (`--remote` patterns, `--all-remotes`, `git.fetch`, `origin`). The exit status is jj's: a failed sync only warns. Off with `devkit.bug.syncOnPush = false;`, or once with `DEVKIT_JJ_NO_SYNC=1`. - **Attribution guard.** A command whose `-m`/`--message`/`--stdin` message has a `Claude-Session:`, `Co-Authored-By: …Claude` or "Generated with Claude Code" line is refused before it runs (exit 1, nothing written). After commit/describe/new/split/squash/metaedit, and before a push, mutable or unpushed changes carrying one fail the command with their change ids. No opt-out; the patterns are at the top of `jj/jj`. - **No git worktrees.** `jj workspace add` is refused (exit 1, nothing created) unless it has `--no-colocate`: in a colocated repo the new workspace would be a git worktree, which sets `extensions.relativeWorktrees` in `.git/config`, and bug can't open the repo after that. `workspace add NAME` passes it for you. Only inside the devenv shell; CI and other shells still need an explicit `bug sync`. ### devkit agent skill Entering the shell also installs `skills/working-with-devkit/SKILL.md`, the agents' catalogue of what devkit provides, the version that added each piece, and where shared code goes, to `~/.agents/skills/working-with-devkit/` and `~/.agents/skills/superpowers/working-with-devkit/` (rewritten only when it differs). Claude reads `~/.claude/skills`, so link it there as for working-with-bug. Options: `devkit.skill.enable`, `devkit.skill.dirs`. The skill describes `main`; add each new building block to it, with its version. The shell exports the release as `$DEVKIT_VERSION`; bump `version` in `flake.nix` with each tag. ### Parallel checkouts: workspace Every project makes its extra checkouts the same way, as jj workspaces: ```sh workspace add NAME [--from REV] [--slot | --no-slot] # ../-NAME; prints its root workspace list [--porcelain] # NAME SLOT ROOT workspace remove NAME [--abandon] # stop, forget, delete workspace check # refuse a missing or shared slot ``` `add` always passes `--no-colocate`: a git worktree would enable `extensions.relativeWorktrees` in the shared `.git/config`, which bug can't read. So a workspace has no `.git` (jj only; bug finds the repo from any workspace). The new change starts on the main checkout's `@-`, or on `--from REV`. `add` also links the workspace's Claude memory folder to the main checkout's, because Claude Code keys memory by directory and doesn't recognise a workspace without `.git` as the same repo. **Port slots**, for projects whose dev processes listen on ports. The main checkout is slot 0; a workspace gets the lowest free slot 1-9, stored in its gitignored `devenv.local.nix` (`{ devkit.workspace.slot = N; }`). The project derives every dev port from it: ```nix devkit.workspace.slots.enable = true; # add gives slots by default env.MYAPP_PORT = toString (config.devkit.workspace.port 8080); # 8080 + 100 × slot ``` and its dev command runs `workspace check` before starting processes. Without `slots.enable` a workspace gets a slot only with `--slot`; `--no-slot` never gets one (`list` shows `-`, and `check` refuses there). The shell exports `$DEVKIT_WORKSPACE_SLOT` (unset without a slot). Slot ports are fixed, so scripts, generated config files and tests outside devenv's processes agree on them; devenv's own `processes..ports.

.allocate` picks the next free port at evaluation instead, which can land on another port's base and differs between runs. Two of a project's base ports collide when they differ by 100, 200, … or 900 (8080 and 8180 meet in slot 1; 8080 and 8081 never do), and a base port must be at most 64635. `devkit.workspace.port` fails evaluation above 65535. Slots only separate one project's workspaces; two projects that use the same base port still clash. **remove** refuses (exit 4) while the workspace holds work nothing else does: uncommitted edits, or changes that no bookmark, trunk or other workspace reaches. `--abandon` abandons those changes instead (a workspace stacked on this one keeps what it builds on). Exit codes for scripts: 2 usage, 3 no such workspace, 4 refused, 5 already exists, 6 no free slot. `devkit.workspace.enable = false;` leaves it out. ### Delegating to OpenCode subagents: delegate (opt-in, beta) ```nix devkit.delegate.enable = true; # the only setting needed; the rest have defaults: devkit.delegate = { check = "just test"; # preflight on trunk, the subagent, and land; "" = none model = "opencode-go/deepseek-v4.1-flash"; # always passed, and verified timeout = "20m"; # per run trunk = "main"; # local bookmark tasks start on and land moves sandbox = { enable = true; extraArgs = [ ]; # more bwrap arguments, one per item }; postLand = { fix = ""; # e.g. "just update-deps-hash"; folded into the landed change check = ""; # e.g. "just packages"; what the sandbox can't run }; }; ``` A coordinating agent (Claude, in the main checkout) splits work into small tasks and hands each one to an OpenCode subagent in its own workspace, then reviews the results and lands the accepted ones on trunk one at a time, **each task as one change**: ```sh delegate start SLUG [--on REV] [--slot] [--no-preflight] < spec.md # workspace t-SLUG; prints its root delegate run SLUG [--model M] [--timeout T] [< fix.md] # one fresh session = one round; report on stdout delegate status [--files] # tasks, rounds, behind/conflicted, file counts, overlaps delegate diff SLUG [--last] [--stat|--summary|--name-only] [[--] PATH...] delegate report SLUG [--round N] # a round's report, kept in the repo delegate land SLUG -m MSG # rebase, check, squash, move the bookmark, post-land delegate drop SLUG delegate commit -m MSG [PATH...] # commit your own working copy on trunk delegate preflight [--force] # does the check pass on trunk in the sandbox? ``` delegate keeps no state of its own. A task is a `t-SLUG` workspace made by `workspace`; its root change holds only the spec as its description, ending with a `Delegate-Task: t-SLUG` trailer; each round is a change of its own on top, whose description keeps the round's fix and the subagent's report; a running task holds a flock in `$XDG_RUNTIME_DIR/delegate`. `land` rebases onto the local trunk bookmark (`devkit.delegate.trunk`, default `main`, not `trunk()`, which usually tracks the remote), runs the check, squashes the spec and every round into one change with your message, moves the bookmark (it never pushes), moves your working copy along when it's an empty change on the old trunk, runs `postLand.fix` there and folds its changes into the landed change, rebases the other idle tasks, removes the workspace and runs `postLand.check` last. The post-land steps run in the main checkout outside the sandbox, because only it has the git repo nix needs. Your working copy must be directly on trunk; edits in it are carried along (rebased, never landed) unless they touch the task's files, in which case land with post-land steps refuses up front. Only the files the fix changes are folded. A round is always sealed with the subagent's last message as its report, even when delegate is interrupted (or, if it was killed outright, at the next `run`). Edits you make by hand in a task's workspace between rounds become a change of their own (`edits by hand between rounds`, with a `Delegate-Edits: t-SLUG` trailer) at the next `run`, so the round lists only the subagent's changes; land squashes them in with the rest. The settings are baked into the shell's `delegate`; when the project's devenv files changed since, it re-runs itself in a fresh `devenv shell`. A round that changes what the devenv shell is built from (a `.nix` file, `devenv.yaml`, `devenv.lock`, `flake.lock`) is followed by a check that the task's shell still evaluates (the subagent's own shell predates its edits); if it doesn't, the round says `Shell broken:`, and the next round runs without the shell, told to fix it first. Failure output (a check, a broken shell, a post-land step) is shown as its last 30 lines, without colour codes, with the path of the full log. File lists show renames as `R old -> new`, and overlaps count both paths. Exit codes: 1 error, 2 usage, 3 conflicts or no such task, 4 check failed or the task's devenv shell doesn't evaluate, 5 the check fails on trunk itself in the sandbox, 6 landed but a post-land step failed or couldn't run, 75 running, 124 timed out, 130/143 interrupted. **Preflight.** The first `start` on a trunk commit runs the check in the new task's workspace before anything else, and refuses (exit 5, workspace removed) when it fails there: no task could pass it. The result is cached per trunk commit and configuration; a land records a pass for the commit it makes, so the next start needn't run it. `--no-preflight` is for a task that's meant to fix the failure. Each round runs `opencode run --agent delegate --auto --standalone` with the spec, fixed rules and the fix on stdin, in the task workspace's `devenv shell`, inside **bwrap** (`devkit.delegate.sandbox.enable`, on by default). The sandbox is a guardrail against a subagent writing outside its task or touching the repo, not a way to hide context: the filesystem is read-only except the workspace, a private `/tmp` and the shared language caches; the projects directory (the main checkout, sibling projects) is readable; `$HOME` is hidden except OpenCode's config, data, state and cache and those caches; the repo's other workspaces are hidden; and `.jj`/`.git` in the workspace and the main checkout are empty directories, so jj and git find no repository even through `sh -c`. The network stays open for the model. `--standalone` matters: plain `opencode run` is a client, and its tools would run in the background server, outside the sandbox. Add binds a build needs with `devkit.delegate.sandbox.extraArgs`. The language caches are fixed, for Go, Gleam, JavaScript (pnpm) and ReScript: `go env GOMODCACHE`/`GOCACHE` (exported into the sandbox, so every task shares the coordinator's caches even where `languages.go` puts `GOPATH` inside each checkout), `$XDG_CACHE_HOME/gleam`, `…/rebar3`, `…/pnpm`, `…/node/corepack` and `$XDG_DATA_HOME/pnpm/store`, each bound writable when it exists. Before every run, `delegate` installs (or refreshes) its OpenCode agent at `~/.config/opencode/agents/delegate.md`, so `--agent delegate` is always devkit's definition. Its permissions deny jj, git, curl, wget, web fetch and search, sub-agents and questions (so nothing waits for an answer). Those rules are a guardrail; the sandbox is the boundary. **The model is always explicit.** Every run passes `--model` (`delegate run --model`, else `devkit.delegate.model`) and never falls back to OpenCode's default, which OpenCode may change. After the run, delegate reads the session back and fails (exit 1) if any reply came from another model; the round's changes are kept (its description says `Untrusted:`), but treat them as untrusted. Tests: `bash delegate/test`. ### SSH with a security key (opt-in) ```nix devkit.ssh.enable = true; # in the project's devenv.nix ``` adds three tools that hold one authenticated SSH master per host, so a YubiKey-backed host needs one touch, and every later `ssh`, `scp`, `rsync` or `git` to it reuses the connection: ```sh ssh-open [-t TTL] HOST... # open (jump hosts first), then show status ssh-status [HOST...] # exit 1 if any is closed ssh-close [HOST...] # no HOST: everything ssh-open opened ``` Hosts need `ControlMaster auto` and `ControlPath ~/.ssh/cm-%C` in `~/.ssh/config`; `-t` overrides `ControlPersist` (`yes` = until closed). Without a terminal (an agent's shell), `ssh-open` uses `ssh-touch-notify` as `SSH_ASKPASS`, so the touch request shows as a desktop notification and an agent can open the session itself. A PIN prompt can't be answered that way: it goes to `$DEVKIT_SSH_ASKPASS` or a known graphical askpass, else fails. The tools use the system's `ssh`, not a pinned one. ## Updating A tool release: bump it here, tag devkit, then in each project ```sh devenv update devkit # moves every tool to devkit's pins ``` and commit `devenv.lock`. For live, unpinned edits while iterating on devkit, temporarily point `devkit.url` at a sibling clone (`url: path:../devkit`); edits then take effect on the next `devenv shell` with no update (and the update check below is off). ### The update check On shell entry devkit compares the release a project pins (its `version` in `flake.nix`) with the newest `vX.Y.Z` tag, and when a newer one is out, says so: ``` devkit: you have v1.6.0, v1.7.1 is available: run `devenv update devkit` and commit devenv.lock ``` Releases bump `version` first and are tagged once ready, so a pin between the two (a pre-release) is quiet until its release is tagged, then hears: ``` devkit: you have a v1.7.0 pre-release (3b5d0937), v1.7.0 is released: run `devenv update devkit` and commit devenv.lock ``` Commits on `main` past the newest release don't prompt. Shell entry never waits on the network: it reads the tags as last seen (`~/.cache/devkit/releases`, a ` ` table shared by every project), and when that is over a day old a detached job asks the knot again (one `git ls-remote`, one at a time, 15 s at most) for the next shell. Offline it says nothing. It's said once per shell: a shell nested in one that said it (and every shell `delegate` starts) stays quiet. Turn it off with `devkit.updateCheck.enable = false;`; it is off by itself for a devkit input without a revision (a local `path:` checkout). ## New project scaffold `devenv init` has no template flag (devenv #1718); its `--from` flag only affects `devenv shell`/`test`/`build`. Use the Nix flake template instead: ```sh mkdir ~/projects/newthing && cd ~/projects/newthing nix flake init --refresh -t git+https://knot.rtw.run/rtw.run/devkit ``` (Local clone: `nix flake init -t path:/home/btburke/projects/devkit`.) `--refresh` picks up the latest template commit; without it nix reuses the cached branch ref for `tarball-ttl` (default 1h). `proj init` always passes it. ## Fish helpers Locally, fish pins the shell devenv launches so a stale `$SHELL` (editor or tmux pane) cannot drop you into another shell: ```fish # ~/.config/fish/config.fish set -gx DEVENV_SHELL_TYPE fish ``` This stays out of the templated `devenv.yaml` on purpose: CI runners have no fish and should keep using bash. Three functions in `fish/functions/` make the active environment visible: - `fish_right_prompt` — `[devenv:]` next to the prompt while a devenv shell is active. Yellow `!` when the cwd has left `DEVENV_ROOT`, red `≠` when the cwd holds another project's `devenv.nix`, `×N` when shells are nested. - `devenv` — wraps the CLI and warns (does not block) when an interactive `devenv shell` would nest. `devenv shell ` and `devenv up` etc. are untouched. Silence the warning with `DEVENV_ALLOW_NESTED=1`. - `devenv-where` — prints the active root, profile, nesting depth, and whether the nearest `devenv.nix` is the active one. Install by symlinking into fish's autoload directory (or wire them into your dotfiles): ```sh mkdir -p ~/.config/fish/functions for f in fish_right_prompt devenv devenv-where; do ln -sf ~/projects/devkit/fish/functions/$f.fish ~/.config/fish/functions/$f.fish done ``` `proj init` also scaffolds projects; see below. ## Scaffolding with proj `fish/functions/proj.fish` defines a `proj` function wrapping the scaffold: ```sh proj init # scaffold in the current directory proj init newthing # mkdir, cd, then scaffold ``` Install by symlinking into fish's autoload directory (or wire it into your dotfiles): ```sh mkdir -p ~/.config/fish/functions ln -sf ~/projects/devkit/fish/functions/proj.fish ~/.config/fish/functions/proj.fish ``` ## Gotchas - Not for the tool repos themselves (`bug`, `secrets`, `local-code-review`): importing devkit there would be circular. - devkit's own `devenv.yaml` is only for standalone use in this directory; it is ignored when devkit is imported (devenv#2205). It takes the tools from this flake (`devkit: path:.`), as a project does. - `enterShell` cannot configure an interactive shell: devenv 2.4 runs it non-interactively before spawning the shell, so functions, `bind`, and `shopt` there have no effect. Put shell config in the shell's own rc file instead.