npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@lcabrera/devkit

v0.7.0

Published

Materialises this repository's agent setup — skills, path rules, subagent definitions and the contracts they bind to — into a consumer repository, and reports what has diverged.

Downloads

865

Readme

@lcabrera/devkit

Materialises this repository's agent setup — skills, path rules, subagent definitions, and the workflows, hooks, templates and registers that make them run — into a consumer repository, and reports what has diverged.

Published from lcabrera-stack, which is also its first consumer (ADR-081).

It has no build script, and that is not an omission. The publishing contract there builds every package whose sources are TypeScript, because a .ts file inside node_modules cannot be loaded at all. This package's sources are .mjs — already loadable — so there is nothing to compile and exports can point straight at what ships. Adding a build step would put a dist between the bin and the assets it reads for no gain. Verify what a consumer receives by packing and reading the tarball, not by reading the manifest; files carries a negated pattern that only pnpm honours.

Why the setup is two packages

It arrives as this package and @lcabrera/repo-standards, split by how a consumer gets the file rather than by topic.

Prose is discovered by path — an agent reads a directory — so it has to be copied into your tree, where you can then edit it. A gate is invoked by name; it is code, and copying code puts it outside node's resolution graph, where no upgrade, peer check or dedup can ever reach it. Either mechanism applied to both halves gets one of them wrong: one package resolved from node_modules puts no file where a skill is looked for, and one package that copies everything vendors the gates into a fork nobody can update.

Versioning separates them again. Prose changes constantly, while a gate carries a machine contract its callers pin on, so a single package would make every wording fix a version bump for those callers and every contract break a major for the package that ships a paragraph.

Neither half needs the other to be useful: repo-standards is the gates without the prose, and this package alone is prose naming commands you supply yourself.

Why a command rather than an import

This material is discovered by path: an agent reads the skills directory, and agents without a skill mechanism read the files directly. A package sitting in node_modules puts nothing where any of them look, so the files have to be copied into the consumer's tree — and a copy with no record of what it wrote cannot ever take an upstream fix without destroying local work.

The record is what makes it distribution rather than copy-paste. Every materialised file is hashed into .devkit-manifest.json, and each subsequent run classifies it:

| State | What happens | | -------------------- | ------------------------------------------------------------------------------------ | | added / restored | written — the consumer does not have it | | updated | written — untouched locally, and the package has moved on | | current | nothing written; adopted into the record | | modified | left alone — edited locally, and reported on every run | | acknowledged | left alone — an edit you said you meant; reported only under --verbose | | conflict | left alone — an unmanaged file already occupies that path; acknowledgeable | | retired | deleted — recorded, unedited, and no rung of this version ships it any more | | kept | left alone — the same, but edited, unreadable or not a file; the line says which | | outside | left alone — a recorded path that resolves outside the repository | | unresolved | refused — a {{commands.*}} placeholder has no answer | | unmet | refused — a requires: key is unset, or a peer: range is unanswered |

A local edit is a supported state, not a defect. It survives every sync, which is what stops a consumer forking the kit to change one line.

Where two rungs a profile holds place a file at the same path, the higher rung's file is the one planned, so a tree moving up a rung has that file updated or, if edited, modified. A file retires when no rung of this version ships it, or when a rung the profile includes declares that it retires the file; running at a lower profile leaves a higher rung's files where they are, and a lower rung's file that a higher rung retires is placed again.

Nothing is retired from an install whose own asset set is empty or missing a group: the run says which, exits non-zero, and leaves every recorded file in place. Reinstall the package and re-run.

The two refusals are never written and never recorded. Recording one would make the next run read the file's absence as a deletion the consumer chose, which is the one way a refused file could quietly stop being reported.

What counts as self-contained

devkit closure asks what a directory needs that it does not contain, and the unit it answers against is the package, not the directory. A skill pointing at a contract document, or at a sibling skill that ships alongside it, is fine — both arrive. Judging containment per directory instead would push every shared reference toward being copied into each directory that needs it, which is the duplication this whole mechanism exists to remove.

A command reached through {{commands.*}} counts as answered too — the tools your commands block invokes are added to the baseline for the run. Otherwise parameterising a command, the very thing that makes a file portable, would make the closure gate fail.

It reports escapes by kind, because they fail differently for a consumer: a link is a file they will not have, a command is a tool their shell may not resolve, an import is a module their install will not provide, a bin is an executable their install will not place, a secret is a value only their repository settings can supply, and a requires is a config key outside what devkit.config.json is for — so no consumer could set it, however reliably it resolves in the repository that wrote the file.

Reading a file is what makes those answers worth anything, and not every shipped file is markdown. A workflow's actions, step scripts and secret expressions, the executables a hook or a step invokes out of the install's bin directory, and the paths a definition in your paths.agents directory names in its frontmatter or its plain prose are all read. Without them a file with no markdown structure reports the same clean pass as one that is genuinely self-contained.

Two of those answer themselves. An executable a gate task already names is placed by this kit, so it is not an escape; neither is a secret that something after its own || answers, nor the token the platform sets itself.

Run it against materialised output, never against assets/. A shipped file's links are written for where the file lands, so resolving them from the asset tree produces confident nonsense.

Installing

npm install --save-dev @lcabrera/devkit @lcabrera/repo-standards

@lcabrera/repo-standards is optional and worth having: it carries the gate binaries the seeded workflows and hooks invoke. Without it the prose still materialises, and init writes no task pointing at a binary you do not have.

To install an unreleased change, or to see what a consumer actually receives, install the packed tarballs instead:

pnpm pack --pack-destination /tmp/kit   # in each package directory
npm install --save-dev /tmp/kit/*.tgz   # in the consumer

pnpm, not npm — pnpm rewrites workspace:* and catalog: specifiers to real ranges at pack time, and an npm pack tarball carries the literal strings, which resolve for nobody.

Starting a repository that does not exist yet

devkit create <directory> [--profile <name>] [--no-install] [--no-db]

Or, without knowing this package's name first:

pnpm create lcabrera-stack <directory> [--profile <name>] [--no-install] [--no-db]

create makes the directory, runs git init on the trunk branch this kit's gates expect, writes a minimal manifest, sets the repository up exactly as init does, and commits the result. What comes out is a repository with a history, not a directory you still have to turn into one.

With no --profile, create places the full rung: a workspace with an application, its configs and its tasks, and a local Postgres the application reads. init and sync read the rung from devkit.config.json when there is no flag, and fall back to agent only when the config names none, because they write into a repository that already has its own workspace. create records the rung it used in devkit.config.json, so a later sync or doctor without the flag works against the same rung. Pass --profile monorepo, --profile agent or --profile repo for a smaller tree.

After the commit, create finishes the setup itself:

  1. At the full rung it copies the compose environment template, the .env.example in docker/local, to the .env beside it, which git ignores, naming the compose project after the repository. The other values stay the template's placeholders, and the local database is created with them.
  2. It installs, with vp install when vp is on your PATH, otherwise with the package manager that ran it (pnpm create runs it with pnpm).
  3. When the tree has the database tasks and Docker is running, it runs db:up, waits until Postgres answers on the configured port (90 seconds at most), then runs db:seed.

The run then prints only the steps it did not do. After a full run that is cd into the directory and the dev task. A step this machine cannot take is named with the command to run later, and the run still exits 0: no vp and no package manager it can name, no docker on the PATH, or a Docker that is not running. A step that ran and failed exits 1. The repository is in place and committed either way, so the advice is to finish it, never to create it again. --no-install skips the install and the database, and --no-db skips only the database.

A printed install step is a plain install with the package manager (not the lockfile-bound commands.install, since there is no lockfile yet). A directory name that is not a plain path is named, not printed as a cd, since no single quoting style suits every shell. vp is the Vite+ CLI and is installed once per machine (viteplus.dev). Inside the new repository devkit is a dev dependency, not a global command, so run it through the devkit:check and devkit:sync tasks, not as a bare devkit.

It refuses the following, and each refusal names what to do instead:

| Refused | Why | | ----------------------------------- | ----------------------------------------------------------------------------------------- | | A target that is not empty | create writes a whole tree and cannot tell your project from an abandoned attempt | | A target inside a git repository | the inner tree would sit under the outer repository's index and gates | | A name something else already holds | a file, or a link to nothing, occupying the name is not a directory create can write to | | A directory it cannot list | an unreadable directory and an empty one are indistinguishable, and it must not guess | | No target, or more than one | create makes one repository, and which one has to be said | | A profile that is not on the ladder | the same refusal every command makes — an unknown profile places nothing, silently | | An option it does not take | --profile=<name> is dropped by the parser, so it would run the default rung and exit 0 | | A machine with no git | create makes a git repository, so this cannot be discovered after the directory exists |

The first two point at devkit init, which is the command for a repository that already exists. Nothing overrides them: init's refusals and these are the two halves of one rule, and a flag that got past either would put this kit's files somewhere it cannot record or restore them.

The manifest create writes declares the toolchain the tree calls: @lcabrera/devkit and @lcabrera/repo-standards, at every rung, because every rung owns gate tasks that run the gate runtime's binaries. Each is written as a floor with a bound below the next major, never as a workspace: specifier. The floor for @lcabrera/devkit is the version of the kit running create, read from its own manifest, and the floor for @lcabrera/repo-standards is the version released with that kit. Because the tree is about to be installed from that manifest, create wires every gate task the rung owns whose binary the manifest declares, even though nothing is installed yet. One install is the only step left: after it the hooks, the workflows and the gate tasks find their binaries, and devkit init --upgrade has no task left to add. init in a repository that already exists still decides by what is installed.

Within a day of a release, that install fails unless you lift pnpm's delay. pnpm installs no version younger than its minimum release age, which defaults to a day, and every @lcabrera/* floor in the created tree is the version released with the kit that wrote it. Until that release is a day old, no version the floors admit is old enough to install. Lift the delay for the first install:

pnpm_config_minimum_release_age=0 pnpm install

After the day has passed, a plain install works.

Setting up a repository

devkit init [--profile <name>] [--force] [--upgrade]

init is sync plus the wiring a repository does not have yet: it writes devkit.config.json with a command map inferred from your lockfile, adds the gate tasks whose binaries are actually installed, and then materialises the selected profile. With no --profile and no profile in an existing config, that is agent.

Wiring them is the part only init does. From then on every run reconciles them: a task still holding what this kit wrote is updated, a task you changed is kept and reported, and a task this kit stops shipping is removed. A task whose binary is not installed here is only ever withheld from a manifest that does not already carry it — what is on this machine decides what may be wired, not what belongs in the file. commands:verify and deps:audit are withheld on a second condition, and are wired only where the blueprint is: commands:verify reads the command reference this kit ships, and that document names the blueprint's tasks; deps:audit reads the audit report of the toolchain the blueprint declares.

It refuses rather than proceeding when the repository is already set up — a config or a manifest already present means sync is the command you want, and it is the one that knows to leave your edits alone. Nothing overrides the check that this is a git repository, since the manifest is a tracked file and the hooks are only ever run by git.

Two flags get past that refusal, and they are not interchangeable:

| | --upgrade | --force | | --------------------------------- | ------------------ | --------------- | | A command you corrected | kept, and reported | re-inferred | | A config key a newer version adds | added | added | | Another package's block | kept | kept | | Your ci block, edited | kept, and reported | rewritten |

--upgrade is the one you want after upgrading this package. A new version can infer config an older one did not — that is how the CI setup hook arrived — and sync will not add it, because devkit.config.json is yours. --upgrade fills in only what is missing and says which of your values it left alone.

That report is the point, not a courtesy. The CI setup steps pin their actions by commit sha, so a version that ships a new one — a supply-chain fix being the likely reason — changes nothing for a consumer holding their own ci block. The run prints the steps it would have set up, beside the ones it kept, so that difference is visible rather than inferred.

--force rewrites the config from the current inference. It is for starting over, not for upgrading: this command tells you to check the commands it guessed and correct the wrong ones, and --force is what silently un-corrects them.

It fails when the run did not set the repository up: any file held back for an unanswered {{commands.*}} placeholder, or a profile that placed nothing at all, exits non-zero and says which command keys to add. A partial materialisation that exited 0 would read afterwards as a working repository whose CI workflows are simply absent.

The inferred commands are a starting point, not a verdict — init names the runner it guessed so you can correct it. Check them before you rely on them. The one exception is a key that stands for a task the run itself wires: under Vite+, test runs the blueprint's test:all and audit runs deps:audit wherever the manifest holds them after the run, and falls back to the runner's own guess where it does not.

Commands

devkit create <directory> [--profile <name>] [--no-install] [--no-db]   # make a repository that does not exist yet
devkit init [--profile <name>] [--force]   # set up a repository that has none of this
devkit sync [--profile <name>]        # materialise into the current repository
devkit doctor [--profile <name>] [--check] [--verbose]   # report divergence; --check makes it fail
devkit doctor --accept <path> --reason "<why>"   # this edit is deliberate
devkit closure [--profile <name>] <dir> [<dir>...]   # what does this directory need that it lacks
devkit closure [--profile <name>] --shipped          # the same, for everything the package places

In this repository they are also vp run devkit:sync, vp run devkit:doctor and vp run devkit:closure.

--shipped without a profile checks every profile in turn, and that is the form worth running. Checking only the one this repository happens to use leaves the rest measured by nothing, and it is the only way to catch a file in the small profile pointing at one the large profile places: that reference resolves for a consumer who took everything and dangles for the one who did not, so it can only be seen by checking the smaller set on its own.

Profiles

A profile is a rung on a ladder, and each rung contains the one below it. A file lands on the lowest rung whose preconditions it can assume, and a rung without a gate of its own is a flag, not a rung.

| Rung | What it places | | ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | agent | What an agent reads: skills, path rules, subagent definitions, the contracts and coordination register they bind to, and the decision home's template and README. | | repo | All of that, plus what CI and git run: the workflows, the git hooks, the pull-request and issue templates, and COMMANDS.md. | | monorepo | All of that, plus the workspace itself: the pnpm workspace file and its catalog, the Node pin and engine band, the root lint/format config, the Biome config, a tsconfig roster with the generator wired, and a React Router application rendering a table through the published packages. | | full | All of that, plus a local database behind the application: a Postgres compose file and its environment template, the enterprise_orders DDL with a demo-sized seed, the seed runner, database tests gated on SMOKE_DB, four db:* root tasks, and a page that sorts, filters, groups, drills, pages and deletes in SQL. |

A consumer who wants the prose and keeps their own process takes agent and receives none of the scaffolding. repo is a governed single-package repository. monorepo is a workspace that installs, lints, formats, type-checks, tests, builds and serves a page on the command after the one that made it. full is that workspace with a database it can create, seed and test against, which is described below.

A rung that places no group of its own prints a line saying which rung it places as, rather than reporting files it did not add. Every rung places one today, so no run prints it.

What the monorepo rung emits

devkit create my-repo --profile monorepo
cd my-repo && pnpm install

The install is not optional and is not a convenience: the tree is written before anything is on disk, so the root task block names binaries the manifest declares and nothing has fetched yet. The install also runs prepare, which points git at the hooks and writes every tsconfig in the tree. create already pointed git at them, so the hook half is for a clone: the hooks-path.mjs script the rung places sets core.hooksPath to the paths.hooks directory when the install root is the top of a git work tree, the directory is there, the clone has no core.hooksPath of its own, and CI is unset. A CI job is left alone because a workflow that commits or pushes from its checkout would otherwise run the whole pre-push gate inside itself. A paths.hooks the config readers refuse fails the install. A hooks directory that resolves outside the repository is not pointed at, and a core.hooksPath already set to it is unset. In every other case it does nothing and the install passes. It imports only Node's own modules, so an install without this package still runs it, and it runs git from the fixed install directories first, then PATH, skipping any node_modules directory an install puts on PATH. No tsconfig here is written by hand — you edit the roster (tsconfig.entries.ts, in the workspace the rung places for it) and the generator writes the JSON; a hand edit survives exactly until the next regeneration reverts it. That includes the application's own project file, and it has to: the type-aware linter finds a file's configuration by walking up for that exact name, while a stub referencing a config the generator has not written yet fails the very install that would write it.

One of the workspaces it places is an application, and it is there to be run rather than read. The root manifest runs it, so from the repository root:

vp run dev     # the development server
vp run build   # a production build, in apps/web/build
vp run start   # serve that build; then open http://localhost:3000

Each root task hands off to the application's own task of the same name. dev is a script in its manifest; build and start are declared in its Vite config, which only the runner reads — so call the application's tasks through vp run, never through the package manager's own run. vp here is the runner the created repository declares as a dependency; without it on your PATH, prefix each line with pnpm exec.

It is React Router in framework mode with one page route and one action route. The page renders a table from rows the module holds — no server, no database, no fetch.

Every column it declares turns sorting and filtering off, and that is a deliberate part of the example rather than an omission. Both are resolved by whatever answers the read, not in the browser, and a page assembled from a module answers the same rows to every request — so a header offering a sort would take a click and change nothing. What the grid offers instead is what this rung can answer: pinning, hiding, column widths, column order, the settings panel and the theme. Deleting the two flags from a column is what turns them back on, and doing that belongs with a loader that reads a page it can sort (ADR-121).

Every @lcabrera/* package it names is declared as a semver range and resolved from the registry, which is the point of it: what renders there is the published surface, with none of the authoring repository's wiring available to make up a difference. Everything else it depends on resolves through the catalog.

The action route is not optional decoration, and deleting it costs no build error. The component library persists a grid's own state — a sort, a column width, a pinned column, a global preference, the theme — by submitting it to one fixed path, and it re-exports the handler that answers there; the route is that re-export. Without it the application still builds and serves, and the first pin or column resize then submits to a path the router cannot match, which the page's error boundary answers by replacing the table. The page route also exports the library's revalidation predicate, which keeps a state write that changed no search parameter from re-running the loader.

Three settings in that workspace's Vite config are load-bearing and travel together, because the component library publishes TypeScript source rather than a build. StyleX has to see the library's own files to emit their styles, so the plugin is given an alias resolved from the installed package; the client bundler must not pre-bundle those files past the plugin; and the server build must not externalise them, because Node refuses to strip types under node_modules and the failure then lands when the server starts rather than when it builds.

The ranges are written as a floor and a bound at the next major (>=0.7.0 <1.0.0) rather than as a caret. Below 1.0.0 a caret stops at the next minor, so a released minor of one of these packages would fall outside a caret range the day it shipped and the created repository would quietly resolve the version before it. The floor is the version released with this kit, so a repository created by a release cannot resolve an older one. The one-day window described under Starting a repository that does not exist yet applies to it.

All of that is the create path. The root manifest is the one file this rung does not materialise, because it carries the repository's own name — so sync and init never write the dependencies or the engine pin into a repository that already exists, and prepare is part of that manifest.

The task block is the exception: it is reconciled rather than copied. Every run merges it key by key against the record of what this kit last wrote there, exactly as it does the gate tasks. A task it wrote and you have not touched is updated in place, a task you changed is reported and kept as you have it, a task it no longer ships is removed, and one it has added since arrives — beside your own tasks, which it never touches. What no run but create does is establish this block: a manifest holding none of it is left alone, so a repository that never took it does not acquire tasks naming binaries it does not declare.

Raising an existing repository to this rung therefore takes a second step, and nothing tells you so: every file lands as added, doctor --check reports everything up to date, and the install succeeds. What is left behind is the workspace the rung placed, and both of its tasks are broken. typecheck points at a tsconfig.app.json nothing wrote, because prepare is what writes those. test cannot resolve @lcabrera/vite-config or vite-plus from the root vite.config.ts, because only the root manifest declares them.

devkit init --upgrade does not close it, and the reason is not that the binaries are missing: with vp and devkit both installed it still adds only devkit:check and devkit:sync. The workspace task block belongs to no rung of the gate-task table at all, so no set of installed binaries reaches it. Run devkit create into a scratch directory with this profile and copy the scripts, devDependencies, engines and packageManager fields out of its root package.json into yours; that one step fixes both tasks. The copied tasks hold exactly what this kit ships, which is what it reads as its own, so from then on they are reconciled like any other consumer's.

Three files carry the engine guarantee and only work together: .node-version holds the exact version, the root manifest's engines.node holds the band an install may proceed in, and engineStrict in pnpm-workspace.yaml is what makes the package manager refuse rather than warn. The band is deliberately wider than the pin, so a patch release does not hard-fail every install before someone moves it.

The catalog is the one place a version is declared for the packages you author. The named groups under catalogs: are this kit's, and a later sync updates them. Your own dependencies go in the default catalog:, which is where vp add <pkg> writes them: the record does not cover that block, so adding one leaves doctor --check green and the rest of the file still takes updates. Adding to a named group is an edit to the kit's part of the file, and it is reported like any other. A version repeated in prose is a second declaration nothing keeps in step. The typescript-config workspace the rung places is the exception, and deliberately: it pins its dependencies outright, so it installs into a tree whose own pnpm-workspace.yaml was kept on a conflict and declares no catalogs. Its ranges and the catalog's are held equal by a test in this package.

Your first install fetches an ESLint toolchain this rung does not use, and it is worth knowing why before you go looking for the config that wants it. There is none: lint:all runs Oxlint and Biome, and no ESLint config is placed. @lcabrera/vite-config also publishes shareable ESLint flat configs, and it declares their plugins as required peers of the package rather than of those subpaths, which npm and pnpm give it no way to express. pnpm installs missing peers by default, so eslint, typescript-eslint and its plugins arrive with it; grep your pnpm-lock.yaml for eslint after the first install to see what that comes to on your tree. Nothing here imports them and nothing breaks: the two subpaths this rung uses pull in vite-plus types and nothing else. Set autoInstallPeers: false in pnpm-workspace.yaml if you would rather not carry them, and take an unmet-peer warning on each resolving install instead.

full used to be the name of what is now repo. A config naming full from before the rename still resolves, to the top rung — which now places the workspace blueprint and the database lane as well, so such a run materialises more than it did before the rename. The changelog records the rename. If the harness is what you meant, set repo.

Set it in devkit.config.json rather than passing --profile. Every command takes the flag, and that is how the commands get out of step: sync the wider profile by flag, let CI run doctor --check without it, and every file outside the configured profile is dropped from the plan before anything counts it — so a deleted hook reports no drift. The flag is for a one-off; the config is what keeps the question from being asked two ways.

Two things a sync cannot do for you, because neither is a file:

git config core.hooksPath .githooks

points git at the seeded hooks — without it they sit there and never run. create sets it for the repository it makes, and from the monorepo rung up so does every install, through prepare; init and sync leave git config alone. And the seeded workflows read .node-version, so a repository without one fails its first run on the setup step. That is deliberate: failing there is loud, where silently using whatever Node the runner happened to have is not.

The hooks arrive executable, and the mode is decided by the group the asset sits in rather than read off the shipped file. It has to be: pnpm pack writes every entry 0644, so an installed copy of this package holds no executable file at all. Reading the mode from disk worked in this repository — workspace:* resolves the source directory, where the bit is set — and produced inert hooks for every consumer, which git skips without a word. The packed-tarball gate now asserts it, because no test run from a workspace can.

What the full rung adds

devkit create my-repo     # installs, starts Postgres and seeds it
cd my-repo
vp run --filter web test:smoke
vp run dev

With --no-install, the steps create leaves are the ones it would have run:

cd my-repo && vp install
vp run db:seed    # start Postgres, create the database, load the demo table
vp run dev

It places everything the monorepo rung does, and a local database behind the application. It needs Docker to run that database. Nothing else, psql included, has to be installed on the host.

  • docker-compose.yml, in docker/local, starts one Postgres container, bound to the loopback interface. Every value it reads comes from the .env beside it. The credentials have no default, so a missing one stops compose and names the variable.
  • .env.example, beside it, is the template for that file, and it holds only placeholders. The workspace .gitignore ignores .env and .env.* and keeps .env.example, so the real file is never committed. create writes the real file from it, with COMPOSE_PROJECT_NAME, which names the container and its volume, set to the repository's name. Postgres reads the credentials only when it first creates its volume, so changing them later means db:down and removing that volume too.
  • setup_enterprise_orders.sql, in the application's db directory, creates the enterprise_orders table and fills it with 1,000 rows. Every value derives from the row number, so every seed writes the same table, and the seed takes well under a second. For a load test, raise the generate_series bound in that file. Nothing else in it depends on the number.
  • seed-db.mjs, in the application's scripts directory, is its seed task. It uses pg directly, creates DB_NAME when it is missing, and applies the DDL in one transaction, so a seed that fails leaves the previous table in place. It lives in the application rather than in @lcabrera/server, which is a library of queries and does not read files.
  • enterpriseOrders.smoke.test.ts, beside the DDL, reads that table back through @lcabrera/server, which the application now declares. It gates itself on SMOKE_DB. A plain test run, including test:all and CI, skips it. test:smoke sets the variable and loads the same environment file.
  • The page at / reads that table. Its loader passes the request's sort, filters and grouping to @lcabrera/server's table-page reader, so Postgres resolves each one, and every column the read accepts offers sorting and filtering. A resource route answers each further page from a keyset cursor. A group row opens that group's rows in a dialog, and each row's menu deletes the row through an action, after which the page reads again. The route's own tests run under test:all with the reader mocked. One more, gated on SMOKE_DB, holds each answer to plain SQL over the seeded table.

The root manifest gains db:up, db:down, db:status and db:seed. They are tagged to this rung, so create writes them only here. A tree that took the monorepo blueprint gains them on its first sync at this profile.

Some files are replaced rather than added. The page route's column declarations, row type, loader, page reader, view, route table and its two tests replace the monorepo ones at the same paths. The module of rows the monorepo page renders from, and the test of the reader that served it, are retired: a tree moved up removes them when it has not edited them. The application's package.json is the monorepo one plus the server package, the driver and the two tasks. Its vite.config.ts is the monorepo one plus the environment file beside the compose file, which the development server loads and start sources, so the page reaches the database without exporting anything first. COMMANDS.md is the shipped command reference plus a section documenting the database tasks. It has to be replaced: the created tree's commands:verify fails on a task the reference does not document, and also on a documented task the tree does not have, so one reference cannot serve both rungs. A tree moved up to this rung takes both files when it has not edited them. An edited one is reported as modified and kept.

Acknowledging a deliberate edit

A locally modified file is reported on every run, for ever. That is the right default — never overwriting your edit is the whole point of the record — but it makes a permanent customisation indistinguishable from a stale accident, and it leaves doctor --check red in CI for a repository that meant every line of it.

devkit doctor --accept .claude/rules/routes-data.md \
  --reason "our loaders are tRPC, not React Router"

That records the edit in .devkit-accepted.json, which is a tracked file: commit it, the same way you commit the manifest. Afterwards the default report omits that file and --check passes; devkit doctor --verbose still lists it with the reason you gave.

--accept takes one file at a time, refuses a path the report does not currently call modified or conflict, and refuses a missing or blank --reason. An acknowledgement nobody had to justify is the one that rots into a line nobody dares delete.

A conflict is acknowledgeable for the same reason a modified file is, and acknowledging one is not adopting it: the package's version is still never written over yours. A repository that authored its own version of a file before adopting the kit holds that state permanently and legitimately, and without a way to say so, devkit doctor --check can never be green there — which makes it useless as a gate, since a check that is always red is read exactly like one that is always green.

The entry is keyed to the file's on-disk hash, not just its path, and that is what makes it safe: edit the file again and the hash no longer matches, so it is reported as locally modified again — no command to run, and no way to forget. Reverting back to the acknowledged content quiets it again. To withdraw an acknowledgement, delete its entry from .devkit-accepted.json.

An acknowledged file is never written and never recorded. Recording the edited content's hash would make the next run compare the package against your edit rather than against the copy sync last wrote, so updated and current would swap places for that file. One consequence follows from that and is worth knowing: an acknowledgement quiets the file even when the package's own copy moves on. --verbose is how you find what is being held back.

--accept takes a file, and a task is not a file. A task in the block that a run left alone — one you rewrote, or one of your own under a name this kit ships — is divergence, so doctor --check counts it and fails, and there is no way to say you meant it. Restore the command this kit ships to quiet it, or run doctor without --check where you keep the override. Counting it is still the lesser harm: the alternative is a check that prints your changed task and exits zero.

Configuration

devkit.config.json at the consumer root, all of it optional:

{
  "profile": "agent",
  "paths": {
    "agents": ".claude/agents",
    "coordination": "docs/coordination",
    "decisions": "docs/decisions",
    "docs": "docs/agents",
    "full": ".",
    "hooks": ".githooks",
    "root": ".",
    "rules": ".claude/rules",
    "skills": ".github/skills",
    "templates": ".github",
    "workflows": ".github/workflows",
    "workspace": "."
  },
  "commands": {
    "install": "vp install",
    "check": "vp run check:push",
    "run": "vp run",
    "test": "vp run test:changed",
    "audit": "vp run deps:audit"
  }
}

Every paths key is a group of shipped files, and hooks defaults to .githooks rather than to any one toolchain's hook directory: git runs whatever core.hooksPath names, so naming the directory a particular runner owns would put the seeds where a consumer on another runner never looks. hooks must be a non-empty path inside the repository, relative to its root: an empty string, a value that is not a string, an absolute path, one that climbs out with .., or one starting with a prefix git expands (~, %(prefix), :(optional)) is refused by every command and by the prepare script. Every other paths key follows the same rule, may also be the repository root (.), and may not contain a .. segment even where it would stay inside: each base names its directory by one spelling, so two keys cannot reach one file by two. Every command refuses a config that breaks it and names the key.

commands answers the placeholders a shipped file carries. A skill's procedure travels but the command carrying out each step does not, so the file says {{commands.install}} and this supplies the rest. run is the one that is a prefix rather than a command — how this repository runs a task by name — and the shipped command reference spells every task through it, since a task name means nothing without it. A file whose placeholders cannot all be answered is not written — materialising {{commands.install}} verbatim would hand a reader something that looks like a command and is not one.

This is the consumer's data, deliberately kept out of the files being shipped — the same split the toolchain packages made. A shipped file may reference only something inside its own package, a bin from a declared peer, or a key from here. devkit closure is what checks that.

Giving a workflow the toolchain it needs

A shipped workflow starts on an empty runner, so {{commands.install}} is only runnable there if the tool it names is already present. That is not something the command itself can express: vp install is exactly right in your terminal and impossible on a fresh runner, because vp is a project dependency and installing it is the step that was about to run.

Every shipped workflow therefore enables corepack — which supplies the package manager packageManager pins, at that version — and leaves one hook for the runners corepack cannot reach:

{
  "ci": {
    "setup": [
      "- name: Set up Vite+",
      "  uses: voidzero-dev/setup-vp@<sha>",
      "  with:",
      "    run-install: false"
    ]
  }
}

init fills this in for the runners that need it and leaves it out for the rest, so most repositories never see the key. Leaving it out is the normal case and says nothing; writing it wrongly is an error — ci.setup must be an array of strings, and anything else fails here, naming the entry. Resolved quietly to "no steps" it would delete the hook from every workflow and each job would fail at {{commands.install}} instead, looking exactly like a repository that declared no setup at all. The value is YAML lines, indented into place wherever a workflow carries {{ci.setup}} — verbatim, because a step schema in JSON would only ever render back into YAML while bounding what you can express to whatever this package anticipated.

Unlike a command, an absent value is the ordinary case: it resolves to no steps rather than to a missing key, so a file is never held back for it.

Declaring what a file cannot run without

A placeholder is only half the story: a file can depend on a config key while never interpolating it, and that dependency is invisible to the substitution above. requires: in the file's own frontmatter makes it visible.

---
name: epic
requires: [config.commands.install, config.paths.docs]
---

A file declaring a key the consumer has not set is not written, and is reported naming the key — the same refusal an unanswered placeholder gets, and for the same reason. Set the key and the next sync writes it.

Only config.-prefixed entries are read. requires: already means other things in frontmatter written for people — a shipped reference file uses it for a library version range — and those are left alone.

Write the list however you like: a flow array, a block sequence, or a lone scalar for the single-key case, quoted or not, with notes and blank lines wherever YAML allows them. Restyling a declaration from one spelling into another is not a behaviour change, and a spelling that read as no declaration would be — it would put the file in a consumer who cannot satisfy it, silently.

Declaring the runtime a file needs

A skill's prose shells out to bins that live in a peer package, and prose and bins skew: a consumer can upgrade the runtime, or never install it, without ever re-running sync. peer: states the range the file was written against.

---
name: epic
peer: '@lcabrera/repo-standards@<1.0.0'
---

One entry per package, spelled name@range — the same name@range a package manager takes, split at the last @ so a scoped name survives. Every spelling requires: accepts works here too, and a name with no range means "installed, at any version". The range is evaluated by semver, which is why this package has a runtime dependency at all: a hand-rolled comparator inside a compatibility gate is wrong in exactly the way the gate exists to catch.

The peer is optional, so a consumer who wants the prose and none of the gates just does not install it. A file is not written when the peer is absent and not written when the installed version falls outside the range; the report says which, because one is install and the other is upgrade. Each distinct peer is resolved once per run, so sync and doctor can never disagree about what is installed.

It is declared in this package's peerDependencies as @lcabrera/repo-standards: >=0.1.0 <1.0.0, and the example above bounds the same pre-1.0 line for the same reason. The two packages are versioned independently, so a narrower bound starts refusing a file the moment one of them moves without the other — and below 1.0.0 a caret is narrower than it looks, since ^0.2.0 does not admit 0.3.0. Read it as the syntax and not as advice on what to pin: a range is right only if the consumer's tree answers it, and devkit doctor is what says when it does not.

The range is written out rather than spelled workspace:*, which is the form this repository uses everywhere it consumes the package. pnpm substitutes the workspace protocol at pack time, and for peerDependencies too — workspace:* would publish as an exact pin on whatever version happened to be current, so the first release that moved only one of the two would leave every consumer with an unmet peer. This package still resolves the workspace copy locally; it declares it as a devDependency to do that, which is the same split @lcabrera/ui makes for react.

What ships

The Profiles table above is the shape of it, and devkit closure --shipped is the count per profile. That command reads the plan rather than your tree, so it answers the same in a repository that has never synced and in one that is already in step. A sync or doctor report is not that list: it names only what the run wrote or held back, so a repository up to date with the package reports nothing at all. Why each skill, rule and subagent definition got the verdict it did, including the ones deliberately kept back, is recorded in CLASSIFICATION.md, which stays in the source repository and is not part of this install.