@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
Maintainers
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 consumerpnpm, 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:
- At the
fullrung it copies the compose environment template, the.env.examplein docker/local, to the.envbeside 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. - It installs, with
vp installwhenvpis on your PATH, otherwise with the package manager that ran it (pnpm createruns it with pnpm). - 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 runsdb: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 installAfter 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 placesIn 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 installThe 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:3000Each 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 .githookspoints 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 devWith --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 devIt 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.envbeside 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.gitignoreignores.envand.env.*and keeps.env.example, so the real file is never committed.createwrites the real file from it, withCOMPOSE_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 meansdb:downand removing that volume too.setup_enterprise_orders.sql, in the application'sdbdirectory, creates theenterprise_orderstable 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 thegenerate_seriesbound in that file. Nothing else in it depends on the number.seed-db.mjs, in the application'sscriptsdirectory, is itsseedtask. It usespgdirectly, createsDB_NAMEwhen 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 onSMOKE_DB. A plain test run, includingtest:alland CI, skips it.test:smokesets 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 undertest:allwith the reader mocked. One more, gated onSMOKE_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.
