A Rust workspace (edition 2024): a root devkit binary package whose subcommands cover the CLI surface, plus the separate devkitd daemon and the library crates, coordinating many concurrent local dev sessions, human and agent, on one machine. The engine is project-agnostic; every project-specific detail lives in devkit.toml. README.md orients; the user-facing reference is docs/commands.md (every subcommand's resolution rules and gates), docs/configuration.md (the devkit.toml shape), docs/install.md, docs/agents.md, and docs/completions.md.
cargo build --release # devkit, devkitd → target/release
cargo install --path . # install devkit, devkitd into ~/.cargo/bin
cargo nextest run --workspace --no-fail-fast # full gate — must stay green
cargo clippy --workspace --all-targets -- -D warnings # zero-warning policy
cargo nextest run -p devkit-ports --test registry # multiprocess flock race testCI runs the gate with cargo nextest run, so run it that way locally too: it
reports every failing test in one pass instead of stopping at the first failing
executable, and is faster because it schedules across test binaries instead of
running them one after another. Install it with cargo install cargo-nextest --locked. It skips doctests, which CI covers in a separate
cargo test --workspace --doc step.
Run all three before committing: CI runs them on every push and PR, and a push to main also drives release-please. Format with cargo fmt --all (the --check above only verifies) using the stable toolchain CI uses, so formatting matches.
The workspace root is the devkit binary package; it and devkitd install together via cargo install --path .. The library crates are members.
| Unit | Role |
|---|---|
crates/devkit-config |
lib: the devkit.toml shape — layer discovery and merge, ${VAR} expansion and layer-relative path resolution, per-leaf provenance, and the JsonSchema derives devkit schema renders. A leaf crate with no internal library dependencies (its dev-dependencies pull in devkit-common for git fixtures in tests only), so devkit-common and devkit-ports both depend on it |
crates/devkit-common |
shared lib: git, the single door every git invocation in the workspace goes through, scrubbing the environment variables that could redirect a call at another repository or inject config into it; config, the single door every config resolution goes through, which is what lets the shared rayon pool be sized from [parallelism] in one place; paths, secrets, cmd (a generic subprocess-capture helper, plus gh wrappers) with github and gitfetch, worktree plus the record it reads (.devkit/issue.toml) and gitignore, slug, template, ui (tables/links) with livetable and progress (TTY-only spinners), tracker (the Tracker seam and its linear, github and none implementations), slack, store (flock'd JSON documents), supervise, sys (the platform boundary), timing, report, and a daemon client behind the daemon feature |
crates/devkit-ports |
lib: doppler (yaml), apps (catalog), load (config + catalog), registry (flock'd port store), run (server lifecycle), strays (servers outside the registry), daemon, task (canned oneshot resolution/exec) |
crates/devkit-locks |
file-lock registry: model + flock'd JSON store |
crates/devkit-issue |
lib: read-only issue triage facade — status (worktree + PR + tracker state with the finished verdict) and prs (PR triage); serializable, no rendering, no mutations |
crates/devkit-mcp |
lib: stdio MCP server (jsonrpc, action registry, ports/locks/devrun/issue handlers) over the port + lock facades, the devkit-ports::run server-lifecycle facade, and the devkit-issue triage facade |
crates/devkit-docs |
lib: version-correct library checkouts — manifest (global docs.toml + devkit.toml [docs]), importer-graph resolution (pnpm/bun/npm/Cargo/uv) matched to git tags, hard-error failure modes instead of a silent default-branch fallback (opt in per run with --allow-default-branch), bare-clone cache with ref-named worktrees (/ encoded as ~) under a reserved-stem-checked cache root, flock'd reference registry with reference-based prune, per-checkout pins that roll up a workspace root's members (JS lockfiles only — cargo and uv name members in a manifest) and union in the reference registry's rows for this project, and 0.12.x cache migration that moves the layout but hard-errors on a meta.toml it cannot parse, naming every such library in one run |
src/bin/devkit/ |
the merged CLI: auth (validate + store Linear/Slack tokens; auth github instead reports the identity behind the token GH_TOKEN/GITHUB_TOKEN/gh auth token resolve, storing nothing and refusing a --token), doctor, brief (session-hook project summary, silent outside a devkit project), schema (JSON Schema for devkit.toml, derived from the config types; schema init points a config at it, writing a fully-commented starter when absent; schema/devkit-config.json is committed, a test fails with a diff when it drifts, DEVKIT_UPDATE_SCHEMA=1 cargo test rewrites it, and release-please attaches it to each GitHub Release), install-links, config (the resolved config and its layers: show, apps, tasks; bare devkit config is show), and the operational subcommands — ports (port registry), run (supervised dev-server runner: env, supervise, baseline, task; reap kills servers started outside it), issue (issue lifecycle: setup, pr (status, checkout), status, end, sync-includes, prs, dashboard, review), locks (advisory file locks), docs (docs cache: add, rm, list, sync, path, info, forget, prune), and mcp (stdio MCP server exposing the port + lock facades to coding agents). Each operational subcommand is also reachable under a short name of its own (portm, devrun, issue, lockm, docm, devkit-mcp) through a hardlink devkit install-links creates beside the binary |
src/bin/devkitd |
supervisor daemon serving both the port registry (ports.sock) and the lock registry (locks.sock), authoritative in memory, write-through to the files, gated by devkitd.lock; bin gated by the daemon feature (on by default) |
devkit and its ports, run, issue, locks, and docs subcommands each expose a completions <shell> subcommand. The shell argument is devkit::completions::Shell, not clap_complete::Shell: the latter is closed and has no nushell variant, so the shared enum adds one and forwards each variant to whichever crate owns that generator (clap_complete for five, the first-party clap_complete_nushell for nushell). Its value strings match clap_complete::Shell's, so adding a shell must not rename an existing one.
Every completion script goes out through completions::emit, which runs Generator::try_generate rather than clap_complete::generate. The latter panics when the write fails, so a reader that closes the pipe early (… | head) crashes the writer; emit treats a broken pipe as the reader being done. devkit completions --all emits one script per name through the same call, and reads the set of names off the command tree (any SHIMS entry whose subcommand has a completions of its own) rather than from a second list. --all is a concatenation, so a shell whose script carries a file-level prologue needs that prologue lifted out and emitted once: PowerShell rejects a using statement that follows any other statement, and its generator repeats the same two at the head of every script. Shell::prologue_prefix names the lines to hoist, and no other generator has any. Help text reaches these scripts verbatim, so it stays ASCII. Windows PowerShell 5.1 reads a BOM-less UTF-8 .ps1 as cp1252, where the trailing byte of an em dash, ellipsis or arrow becomes a curly quote that PowerShell accepts as a string delimiter, closing a help string early.
- Reserve before bind.
registry::alloc_onewrites a pid-less reservation row before any process binds the port; this is what prevents the allocation race across concurrent callers.record_pidthen upserts the pid — and re-inserts the row if it was pruned in the gap, so a live process is never left untracked (otherwisedevrun downcan't stop it). RESERVATION_GRACE_SECS(300) must exceeddevrun's readiness timeout (120s) so a reservation cannot expire while its own server is still coming up. Don't lower it below the timeout.with_lockholds an exclusive advisory lock for the whole read-modify-write. Keep work inside it minimal; avoid slow/network calls under the lock.devrun downstops then releases without pruning first — a still-running server whose reservation looks stale must still receive SIGTERM.- Cross-worktree
devrun downis TTY-gated, except for the worktree's own baseline. A selection touching a holder other than the current worktree is refused unless stdin is an interactive terminal (cmd_downinsrc/bin/devkit/run/mod.rs), and is otherwise reachable only via the named scope flags--all/--others/--holder— so an agent (no PTY) cannot stop another worktree's servers. The one exception is the baseline this worktree is the sole referencer of: its own record names that baseline, the path carries a baseline marker, and no other worktree's record names it. That baseline sits in the default scope and needs no terminal, because stopping it reaches nobody else; a shared baseline stays foreign to every referencer. Every way of failing to establish sole reference — an unreadable record, a worktree that cannot be classified, a scan that errors, a marker that cannot be read — resolves to "foreign", so the gate holds. The exception applies whatever scope produced the selection, so denying--all/--others/--holderby name no longer covers the own baseline, and covers everything else as before. The MCPdevrun.downhandler stays root-scoped and never gains a cross-holder arg. devrun reapis TTY-gated with no bypass, and never on MCP. Reap kills servers running outside the registry; it always requires an interactive terminal (no--yes/--force/env path), so an agent without a PTY cannot trigger it. Only read-only detection is exposed to agents —devrun status's untracked section,devkit doctor'sdevrun_straysrow, and theports.straysMCP action. No mutating reap/kill handler is ever added to the MCP surface.- The supervisor table — not the registry row — decides crash vs. stop. A child the
devkitdsupervision thread reaps is a crash and is restarted (within the crash-loop budget); an intentionalDownremoves the key from the table before signalling the child, so a stopped server is never reaped as a crash. Don't make the restart decision readports.json/d.ports— a concurrent prune would race it. - A non-crash restart goes through the crash path, not its own. When the health probe (
DEVKIT_DAEMON_HEALTH_PROBE_SECS> 0) judges a server hung, or the memory action (memory_action = "restart") finds one overmemory_limit_mbformemory_limit_ticksticks, it only SIGTERMs the server; the supervision tick then reaps and respawns it within the crash-loop budget. Neither path gets its own respawn — two respawners would race on the same key. The memory path peeks the budget (can_restart) before killing so the kill is skipped once exhausted (warn and leave alive), but the budget is recorded only inrestart(), so a restart counts exactly once. - A hard-cap breach is a crash, not a restart path.
memory.max+memory.oom.group=1OOM-kills the supervised leaf; the reap → crash → respawn path handles it within the crash-loop budget. No dedicated restart path exists for the hard cap — the same rule already established for health-probe and the soft memory restart. - Cap setup is fail-open. Any cgroup error (mkdir denied,
memory.maxwrite fails, fd open fails) logs once and proceeds with an uncapped spawn; it never blocks or kills a server. A broken cgroup configuration degrades to the softmemory_actionpath. memory_max_mbsits abovememory_limit_mb. The soft poll-based action (memory_action = "restart") is the graceful first responder; the kernel cap (memory_max_mb) is the backstop. Setmemory_max_mbhigher thanmemory_limit_mbso the soft restart gets to act first.- A
prddoppler launch is rejected.launchis run verbatim, so devkit guards at launch time: for a launch whose program isdoppler, it resolves the config from-c/--config, elseDOPPLER_CONFIG, elsedoppler configure get config --scope <app dir>, and refuses to start a server when that resolves toprdor cannot be resolved. The guard lives inrun::assert_not_prd, called fromrun::launch, so it coversdevrun, the MCPdevrun.up, and both the daemon and direct spawn paths.run::assert_not_prdis also called during task resolution (task::resolve_command), so adevrun taskcommand step gets the same guard. upis idempotent for a live server. Bothrun::launch(direct path) and the daemon'sSupervisehandler skip the spawn when the (holder, app, role) row already has a live pid, reporting the existing server instead. A duplicate spawn would fail to bind, and on the daemon path would repoint the supervision table at the doomed pid. Sequence-taskupsteps rely on this.- Sequence steps re-resolve at execution time; the upfront pass never gates.
task::resolvevalidates every step before anything spawns, but its rendered plans are for validation and--dry-rundisplay only — execution callstask::resolve_stepper command step (fresh allocation + render,require_liveenforced) immediately before spawning it. Don't execute the upfront plans: a build step longer thanRESERVATION_GRACE_SECSwould let a t=0 reservation expire and desync later steps. And don't enforcerequire_livein the upfront pass — a gated app may be brought up by an earlierupstep of the same sequence. A CLI-pathrequire_livegate failure can leave behind a grace-bounded pid-less reservation from the upfront validation pass; this is the reserve-before-bind row the error's suggesteddevrun up <app>reuses, not a leak. - Parallel work goes through
devkit_common::pool. One boundedrayon::ThreadPoolserves the whole workspace, sized byDEVKIT_THREADS, then[parallelism] threads, then 4. Reaching forpar_iteror jwalk's default parallelism directly gets rayon's global pool instead, with its own width and no coordination with this one — several agent sessions run devkit at once on a machine.pool::installandpool::jwalk_parallelismboth degrade to serial when already inside the pool, because a bounded pool re-entered from its own worker can leave a nested walk waiting on a thread that never frees. Evaluatepool::jwalk_parallelismon the thread that builds and drains the walk, not from insidepool::install: called from inside the pool it sees itself as already nested and returnsSerial, silently making the whole walk single-threaded. - A baseline is deleted only after the caller's own reference is gone.
issue endremoves the worktree first, then counts references, because a scan that ran while the departing worktree still existed would always find at least one and never reclaim anything. The reference is thebaselinefield in each worktree's.devkit/issue.toml. - Nothing is provably unreferenced while any worktree is unreadable.
References::unreadablecollects worktrees whose record does not parse and treesdiscover_allcould not classify. Which baseline each names is unknown, so every consumer — the prune sweep, thedoctorrow — reports that state rather than treating the baselines as abandoned. A three-valued classification guards every one of these paths (BaselineState,MarkerState,GitdirState); a two-valued bool that folds an I/O error into yes or no is the recurring defect here, and on the deletion path it deletes somebody else's checkout. - The directory lock always precedes a slot lock, never the reverse. Both waits are deliberately unbounded: a bootstrap can take minutes and the caller wants the tree, not a timeout. Taking them in the other order deadlocks a sweep against a bootstrap. Two opens of one lock file are two open file descriptions, so these block within a single process too — which is why the locked wrapper and the unlocked body are separate functions rather than one.
- A directory whose marker cannot be trusted is rebuilt in place, not skipped. A slot whose
.devkit/baseline.tomlis absent is not devkit's to reclaim, but it is devkit's to rebuild when that fork point is needed again, and a marker that exists but does not parse reads the same way: an interrupted bootstrap, rebuilt where it stands. What a rebuild is refused over is the reference, never the marker —ensurescans the referencers under the slot lock and declines when a worktree other than the caller names the path, or when the scan could not read some worktree other than the tree being rebuilt. Both exclusions are load-bearing: the caller's own reference would stop it repairing the baseline it pins, and a baseline whose marker cannot be read is undecidable to the scan and so lists itself. The three deletion sites that do consult a marker — the two sweeps and the abandoned-server release — declineBaselineState::Unknown, a marker that can be neither read nor ruled out; the rebuild consults none, because the referencer scan already covers it.
- Commits follow Conventional Commits. Follow the active workflow skill's commit cadence (a design/plan skill commits its own artifact; per-task execution commits per task).
- TDD: write the failing test first;
cargo nextest run --workspace --no-fail-fastis the merge gate. - Test scratch comes from
tempfile:tempfile::tempdir()for a directory, a path joined onto one for a file. Never build a scratch path by hand fromstd::env::temp_dir()— a hand-built path outlives the test and fills/tmp.TempDirdeletes its tree on drop, so bind it for as long as the path is used: a helper that returns a path derived from a guard must hand back the guard too, or the directory is gone before the caller reads it. - A test that drives a
gh-backed command usestests/common/ghfake.rs, which puts theghfakeexample binary on the run'sPATHunder the namegh. It is a compiled binary and not a script becauseCommand::new("gh")on Windows resolves the exact name plus.exeand consults no PATHEXT, so a.cmdor.batwould never be found and every such test would be Unix-only. It stays an example rather than a workspace member:cargo test --no-runbuilds examples, and skips the bins of a member crate that has no tests of its own. anyhoweverywhere — its.context()chain and backtrace are the error-reporting mechanism. Each binary installsreport::install_panic_hookfor crash diagnostics;RUST_BACKTRACE=1adds a backtrace to both errors and panics.- App conventions are config-driven, never hardcoded: the URL-providing app is marked
provides_url; per-app prep files come fromprep_files; the apps directory isdefaults.apps_dir. Example-specific values live in the personal config at~/.config/devkit/config.toml(outside the repo; seedocs/configuration.md). Role(Issue/Baseline) is defined once indevkit-ports::registrywithValueEnum+Display;devrun's CLI uses a separateRoleSelector(addsBoth). No_ => Issuecatch-alls — map roles exhaustively.- The issue tracker is the
Trackertrait indevkit-common::tracker.tracker::resolvepicks the implementation: a resolvableLINEAR_API_KEYmeans Linear, else a github.comoriginremote means GitHub, elseNoneTracker, whose empty answers are howissuedegrades. The GitHub arm is built fromRepos::issues— resolve is the only place aGithubTrackeris constructed, which is what lets itsreadyreport on the token alone — and without an issues repository it falls back toNoneTracker, undeclared, with the failure inreason.resolvetakes an explicit kind that wins over detection;[tracker] kindis where it comes from. It returns aResolved, whosedeclaredflag says whether the project named this tracker or devkit fell back to it — the finished verdict skips the issue-state gate only for a declaredTrackerKind::None, because devkit finding no tracker is silence, not an answer.reasonis prose except for one load-bearing part: a reason produced by detection carries theDETECTEDprefix, andunbuilt_reason(free function, plus theResolvedmethod) reads its absence on an undeclaredNoneas "the project named a tracker devkit could not build". That is what keeps such a project from being told to name one — so keep the prefix on every detection arm. Config loading belongs to the callers that have it — theissuesubcommand'scrate::issue::tracker::select(which returns theReposalongside, since the two come from one config load) and the MCPissue.statusaction — and a config that does not load degrades to detection rather than failing the command.devkit-issuereads no config:status::gather_localdetects with repositories defaulted fromoriginalone, and every other caller injects its tracker viastatus::gather_with. - GitHub repositories come from
[github] issues_repo/pr_repoviadevkit_common::github::Repos, each key resolving independently, defaulting to a github.comoriginremote, and required only where it is used.Reposis threaded to every GitHub operation rather than re-derived;[github]and[preserve.<name>]are the two config tables withdeny_unknown_fields, because in both a silently ignored typo changes behavior without any diagnostic: a wrong[github]key resolves a different repository than the project declared, and a wrong[preserve]key leaves an entry fail-open while the user believes its files are protected. Repository-scopedghcalls go throughcmd::gh_json_in/cmd::gh_capture, which append--repo github.com/<slug>to every argument vector so an ambientGH_REPOorGH_HOSTcannot redirect one. StateKind(Triage/Backlog/Unstarted/Started/Completed/Canceled) is the state vocabulary every tracker maps onto — match it exhaustively, no_ =>arms. OnlyCompletedandCanceledare closed.- CI runs the
testjob (andclippy) on ubuntu, macos, and windows. Tests that spawn or reap processes must poll for the expected state, not sleep a fixed interval — a loaded Windows runner exits a child later than a short fixed sleep allows. - Every user-facing verb is a
devkitsubcommand — credential setup and diagnosis (auth,doctor) alongside the operational subcommands (ports,run,issue,locks,docs,mcp). Each operational subcommand is also reachable under a short name of its own (portm,devrun,issue,lockm,docm,devkit-mcp) through a hardlinkdevkit install-linkscreates beside the binary.configis a top-level readout, not arunsubcommand, because the config tree describes the whole of devkit.devkitdstays a separate binary becausedevkitd_bin()finds it as a sibling file andinstall-servicewrites its path into a systemd unit. Token reads resolve throughdevkit-common::secrets(env →secrets.toml), never fromconfig.toml. - Timing:
issue/devrunaccept--timing[=trace]/--timing-log <FILE>(orDEVKIT_TIMING). Timing wraps the shared IO primitives (cmd::capture,github,tracker::linear::send,slack) viadevkit-common::timing; a global tracing layer aggregates flat spans by op and prints a stderr summary on exit.devkitdcarries the same spans but has no activation flag yet.
The primary clone (C:/Users/Lev/Git/lev/devkit) stays on main. Feature work never checks out a branch in it — every branch lives in its own worktree under ../devkit-worktrees/:
- Start work with
git worktree add ../devkit-worktrees/<name> -b <branch> main, notgit checkout -b <branch>in the primary clone. Several agent sessions share this repo at once; an in-place checkout moves the branch under all of them and corrupts the others' view of HEAD. - Land finished work by fast-forwarding
mainfrom outside its worktree (git -C <primary> switch main && git merge --ff-only <branch>, orgit fetch . <branch>:mainwhilemainis checked out nowhere), thengit worktree removethe worktree. - If you ever find the primary clone on a non-
mainbranch, stop and restore it (git switch main, re-home the stray branch in a worktree) before doing anything else. Thepost-checkoutguard hook warns when this happens.
When multiple sessions share one checkout, claim files before editing them with the lockm binary instead of writing ad-hoc .lock files:
lockm acquire <paths…> --as <stable-session-id>before editing; it exits1with the current holder if any path is taken — branch on that.lockm release <paths…> --as <same-id>(orlockm release --all --as <id>) when done.- Always pass a consistent
--as <id>(or set$DEVKIT_SESSION) so acquire and release refer to the same holder.
Go through registry::{alloc, record_pid, release, release_ports, snapshot, prune, listening_view, status_table, status_table_with, status_table_linked} — they keep liveness syscalls (bind/stat/kill) out of the exclusive lock. Don't reintroduce probing inside with_lock. This facade is also the seam the devkitd daemon plugs into.
When a devkitd daemon is running it is the authoritative registry for both the port and lock registries: it loads ports.json and locks.json into memory under devkitd.lock (held exclusive for its life), serves reads from memory over two sockets (ports.sock for ports, locks.sock for locks), and writes through to the respective files on each mutation. Direct callers take devkitd.lock shared before any write (FlockStore / registry::with_lock) and hard-error (DaemonHoldsLock) if the daemon holds it — so a non-daemon binary can never modify the files behind a live daemon. Reads are ungated. devkit-locks exposes the same Store seam as devkit-ports: FlockStore is the direct flock-guarded path; MemoryStore is the daemon path.
The ports holder is the worktree root path, not a minted session token: registry::holder_alive(holder) is Path::new(holder).exists(), so a holder is judged live by whether its directory still exists. This is what makes a worktree's ports auto-reclaim on git worktree remove — the holder path vanishes and prune frees the rows. (Locks instead use a session-token holder with TTL/pid liveness; the two registries intentionally differ.) Cross-worktree, an agent addresses each worktree's allocations by that worktree's root path.