Maintaining the lt Stack (all base repos)
The seven base repos are maintained and released in dependency order. Canonical
source is github.com/lenneTech; releasing needs a
local clone of each, and that checkout path differs per machine — locate it instead
of assuming one:
find "$HOME" -maxdepth 5 -type d -name nest-server-starter -not -path '*/node_modules/*' 2>/dev/null
If a repo is not checked out anywhere, clone it into the same workspace directory as
its siblings. Target end state: every repo current, check green everywhere, npm
packages published, templates tagged — proven by a full
/lt-dev:fullstack:smoke-test run.
Dependency graph (dictates the order)
Wave 1 (parallelizable): nuxt-extensions nest-server lt-monorepo cli
│ │
▼ (wait for npm publish!)
Wave 2 (parallelizable): nuxt-base-starter nest-server-starter
│
▼
Validation: /lt-dev:fullstack:smoke-test (exercises ALL repos live)
│
▼
Wave 3 (only on findings): patch fixes → re-release affected repos
Rule: A starter is only updated once its npm package actually resolves on
npm, not when the GitHub release exists — the publish.yml action takes minutes.
Ask the version-specific endpoint, not npm view. npm view and
https://registry.npmjs.org/<pkg> read the same CDN-cached packument, and it
lags minutes behind a publish:
curl -s -o /dev/null -w '%{http_code}' https://registry.npmjs.org/<pkg>/<version>
# 200 = resolvable now. Alternative: cache-bust with -H 'Cache-Control: no-cache' plus a query param.
Measured 2026-09-04: a session polled npm view for ten minutes and read "not
published yet" while @lenne.tech/nest-server@11.41.0 had been up the whole
time — the version-specific endpoint answered 200 immediately. From the
caller's side "not there yet" and "the instrument is reading a cache" look
identical, which is why the endpoint is the one to ask.
Running the waves across parallel sessions
The waves above say "parallelizable" and that is meant literally: one Claude Code session per repo, in its own terminal, is how a stack release actually goes fast. Wave 1 holds four independent repos and Wave 2 two more, so the wall-clock floor is one repo's release, not six in sequence.
That only works if the sessions coordinate on four things. All of it runs on the coordinating-peer-sessions protocol, and the split by repository is exactly the boundary that protocol asks for: no two sessions ever touch one working tree.
1. Claim a repo before starting it. Record it in the ledger first, then announce it:
bash ${CLAUDE_PLUGIN_ROOT}/scripts/peer-ledger.sh read # who already owns what
bash ${CLAUDE_PLUGIN_ROOT}/scripts/peer-ledger.sh claim "repo:nest-server" "Wave 1 release"
The ledger part matters more here than anywhere else: a release round runs for hours, sessions come and go, and a claim that lives only in a message is lost the moment its terminal closes. claim refuses a repo another live session holds and names it; a claim whose session died reads [stale] and is free to take, so a crash never strands a repo. Release with peer-ledger.sh release "repo:nest-server" "<version>" when the repo is out. Without any of this, two sessions release nest-server and the second mints a version over the first.
[CLAIM] nest-server — taking the Wave 1 release for this repo.
Betrifft: the stack release; nobody else should version or publish it.
Nötig: pick a different Wave 1 repo.
Frei: when I report READY with the published version.
2. Signal Wave 2 with READY, do not make it poll. The wait between waves is a real npm propagation delay, and the session waiting on it has no way to know the moment it clears except by asking the registry again and again. The publishing session knows exactly when. It sends one READY with the version:
[READY] @lenne.tech/nuxt-extensions@5.4.1 is published and resolvable on the registry.
Betrifft: nuxt-base-starter — the Wave 2 dependency bump can start.
Nötig: pnpm add @lenne.tech/nuxt-extensions@5.4.1 and continue.
The Wave 2 session may also subscribe with notify_when_idle on the Wave 1 session instead of polling. Either way, nobody sits in a sleep loop against the registry.
3. Send SOLVED for anything environmental. The empirical pitfalls in this skill are almost all machine-wide, not repo-specific: a remote read that silently returned nothing, a registry that has not propagated, a CI runner queue backing up, a toolchain version that broke. The first session to diagnose one has already paid for it, and the other five are walking into the same wall. One SOLVED and they do not.
[SOLVED] `git ls-remote` fails on stderr with empty stdout, so tag checks read as "no tags".
Betrifft: every repo in this release round; your tag verification lies the same way.
Nötig: check the exit code, or read over HTTPS — see the push-channel rule.
4. Report the finish with LANDED, so the smoke test starts once. The validation gate runs after every repo is out. Whoever finishes last starts it; the others say so and stop.
What does not go over messages: which repos exist and in which order they go (this skill says so), and which version a repo is on (the registry and the tags say so). And no session assigns another one a repo. The user decides who takes what, or each session claims a free one and says which.
Cross-cutting rules (all repos)
Wire-critical packages move in BOTH framework repos, in one go. Some
dependencies are not one repo's business — they are one protocol with two
ends, and nest-server and nuxt-extensions each own one end. better-auth is
the case that taught us; treat any package both frameworks import the same
way.
Both declare it as a peer with a range that is byte-identical in the two
manifests, and narrow: >=<lowest known-good> <<next minor>, never ^.
better-auth breaks in MINOR releases, so a caret invites the same split one
release later. Read the current range out of the manifests rather than from
here — it moves, and a version written into this skill is a version that goes
stale:
node -e 'for (const p of ["nest-server","nuxt-extensions"]) console.log(p, require(`${process.env.HOME}/code/lenneTech/${p}/package.json`).peerDependencies["better-auth"])'
Bumping it means: raise the range in nest-server AND nuxt-extensions, pin the
new version in nest-server-starter AND nuxt-base-starter, release all four.
Never half of it, not even "just to unblock the frontend". Raising only the
LOWER bound is a bump too — it is what says which patch is known-good, and a
lower bound that disagrees between the two repos is the same defect as a
version split, one release earlier.
The floor is not a matter of taste: it must satisfy the SIBLING peers too.
A package like better-auth ships with companions (@better-auth/passkey,
@better-auth/core) that peer-require a version of it in turn. A floor below
what a companion demands blesses a pairing that cannot actually install —
>=1.7.0 allowed better-auth 1.7.0 next to passkey 1.7.1, which passkey
itself rejects (^1.7.1). The range was not wrong in general; it admitted
exactly one invalid cross-combination, which is precisely the kind that no
install in either repo would ever hit. Derive the floor from the companion's
manifest, not from intuition:
node -e 'console.log(require("./node_modules/@better-auth/passkey/package.json").peerDependencies)'
nuxt-extensions asserts this mechanically in test/peer-dependency-ranges.test.ts
(floor vs. the companion's requirement, and every range against the devDependency
actually tested against). Worth copying wherever a peer range has companions.
What a split costs, measured: nest-server pinned better-auth 1.6.26 as a hard
dependency while nuxt-extensions declared a peer, so the app moved to 1.7.1
and the api could not follow. 1.7 gives twoFactor.enable a discriminated
result carrying method, which 1.6.26 never sends — every 2FA activation in
every fullstack project failed, with a generic client error and nothing
unusual in the server log. Both repos' check was green the whole time:
each was internally consistent, and only the assembled workspace has both
halves.
Three guards catch it today, and none of them replaces this rule — they catch
the mistake, the rule prevents it:
When the manifests are merged (lt CLI hoist-workspace-pnpm-config.ts,
since 1.45.0). The hoist is last-writer-wins, which is right for root-vs-sub
and a trap between siblings. Reported: two sub-projects setting one key
differently in the same run; the incremental case (add-api then add-app,
where the earlier run already hoisted and emptied its source); and a repo
contradicting ITSELF across package.json#pnpm and its own
pnpm-workspace.yaml. allowBuilds is not merely reported but merged
deny-wins — a warning does not stop an install script that one project
explicitly refused, and overrides stays report-only because there is no
safe direction for a version.
In the assembled workspace (lt-monorepo/scripts/check-workspace-consistency.mjs,
since 3.10.0). Fails when api and app resolve a wire-critical package
differently, when the two frameworks promise different peer ranges, or when a
member has the package installed but pins it nowhere — autoInstallPeers
defaults to true, so an unpinned peer agrees today and is free to drift on
the next install.
In behaviour (nuxt-base-starter/.github/workflows/test.yml, job
e2e-auth, green since 2.22.3). Boots MongoDB and nest-server-starter and
runs the auth suite against a real API. The only layer that sees a broken
contract rather than a version diff — the other one compares declarations,
and declarations were green throughout the split.
Two things it needs that are easy to miss, both found by its own first runs:
the template's dependencies must be installed separately (it has its own
lockfile, and its check chain starts with cross-env — one of its own
devDependencies), and an empty database routes every visitor to /auth/setup,
so the first admin has to be created via POST /system-setup/init before the
suite runs.
The smoke test is the end-to-end gate: it builds a real fullstack project, so
it is the one place a wire split shows up as a failing flow rather than as a
version diff.
No change → no release. The ONLY case that skips a release is a repo
where truly NOTHING changed (working tree clean AND no commits since the
last released version) — never mint a version that contains no changes at
all. ANY actual repo change — dependency bumps, code, scripts, lockfile,
tooling pins — justifies a new version; do not second-guess whether a
change is "release-worthy". An unchanged repo is reported as
"already current — no release" and skipped.
The reference is the PUBLISHED ARTIFACT: for npm packages, commits that
cannot reach the tarball (outside the package.json files set — e.g.
.claude/agent-memory/**, CI config) do not trigger a release of their
own; verify with npm pack --dry-run when unsure. Such commits simply
ride along with the next real release. For templates the artifact is the
repo itself, so every commit counts.
Push channel: ask the script, which decides it functionally:
bash "${CLAUDE_PLUGIN_ROOT}/scripts/check-push-channel.sh" <repo-root>
# -> ssh<TAB>github.com<TAB>authenticated as: Hi kaihaase! …
# -> https<TAB>github.com<TAB>no SSH authentication (…)
On https, push via HTTPS:
git -c credential.helper='!gh auth git-credential' push https://github.com/lenneTech/<repo>.git <branch>.
gh release create is unaffected either way.
Do NOT use ssh-add -l for this. It is the obvious test and it is wrong here — measured
2026-08-23, where it reported "The agent has no identities" while ssh -T git@github.com
authenticated fine and had done all along. The reason: ~/.ssh/config routes SSH to the
1Password agent via
Host *
IdentityAgent "~/Library/Group Containers/2BUA8C4S2C.com.1password/t/agent.sock"
but IdentityAgent is an ssh(1) option and ssh-add does not read ~/.ssh/config at
all — it only ever talks to the agent in $SSH_AUTH_SOCK, which on macOS points at the
(empty) launchd agent. So ssh-add -l interrogates an agent SSH never uses. On any
1Password/IdentityAgent setup it is a permanent false negative, and every HTTPS fallback it
triggered was unnecessary.
To inspect the keys SSH really has, aim ssh-add at the configured agent instead:
SSH_AUTH_SOCK="$HOME/Library/Group Containers/2BUA8C4S2C.com.1password/t/agent.sock" ssh-add -l.
But prefer the functional check above — it stays correct regardless of how the agent is wired.
The same broken agent makes READ commands lie, not just pushes. When the agent
cannot sign, git ls-remote exits 128 and prints its complaint on stderr while
stdout stays empty. A pipeline that only reads stdout — git ls-remote --tags origin | wc -l, or a grep for one tag — therefore reports "the remote has no tags" and
"that tag does not exist", which reads exactly like a missing release rather than a
failed connection. Measured 2026-08-23: two sessions independently concluded a release
tag had not been pushed; over HTTPS the remote had all 132 tags including that one, and
git fetch --quiet had been failing silently the whole time, so the remote-tracking
refs were stale on top of it.
So for any remote read: check the exit code, or use HTTPS from the start.
git -c credential.helper='!gh auth git-credential' ls-remote --tags https://github.com/lenneTech/<repo>.git
Never conclude "not on the remote" from an empty result you did not prove was a
successful call. git fetch is the same trap wearing a quieter coat — with --quiet
it fails without a visible word, and every later origin/<branch> comparison silently
answers from stale local refs.
And once the call DOES succeed, do not count its lines. ls-remote --tags prints two
refs per annotated tag — refs/tags/X and the peeled refs/tags/X^{} — so wc -l
reported 249 where this repo has 132 tags. Compare names, not line counts:
… ls-remote --tags <url> | sed 's|.*refs/tags/||; s|\^{}$||' | sort -u
The two failure modes stack: a broken agent turns a real list into "nothing", and a
line count turns a matching list into a phantom difference. Both end as a confident
claim about a remote nobody actually read.
Dependency maintenance: per repo via the /lt-dev:maintenance:maintain
command (FULL) — it raises the lenne.tech frameworks first (npm + vendor
core), aligns their pinned ecosystem, and only then hands off to the
lt-dev:npm-package-maintainer agent (skill maintaining-npm-packages) for
the surrounding packages, iterating check to green. Framework-first is not
optional: a CVE inside a framework-pinned dependency cannot be fixed with an
override, only by raising the framework. Maintenance never commits (the
orchestrator commits and releases in a controlled way). For a repo that IS a
framework (nest-server, nuxt-extensions) the framework phase is a no-op and
it degrades to plain package maintenance.
Never force-push/squash where the flow does not call for it; the
nest-server PR is merged explicitly WITHOUT squash (merge commit).
Version convention for npm packages: set the version manually in
package.json, then pnpm i/npm i (lockfile!), commit message exactly
NEW_VERSION: COMMIT_MESSAGE (e.g. 1.11.0: update deps, fix X).
Commit message: the CONVENTION is non-negotiable — NEW_VERSION: MESSAGE
for npm packages, conventional-commits for templates (so
commit-and-tag-version derives the bump), and the fixed
Updated to nest-server version <X.Y.Z> for a nest-server-starter version
bump. /lt-dev:git:commit-message is the recommended helper for crafting the
descriptive part — but it is a helper, not a gate: skip it when the change
already dictates an obvious, convention-compliant message (a focused one-line
fix, or the fixed starter message). How you arrive at the wording is free;
the convention is not.
Language: every published artifact — release notes, commit messages,
PR bodies, migration guides, descriptions — is written in English.
Release notes are for CONSUMERS, not for the log. Audience: developers
who use the release in their projects. Structure: (1) what is this? (one
sentence, e.g. "Maintenance release — no API changes"), (2) how do I
update? (copy-paste command), (3) do I need to do anything? (concrete
checks with before/after — the most important part), (4) optional "Under
the hood" in 1–2 sentences. NEVER in the notes: raw package version lists,
test counts / "checks green" status, internal override surgery — that
belongs in the CHANGELOG / migration guide. Link the migration guide
instead of duplicating it. NEVER include time estimates ("takes ~5
minutes") in release texts or migration guides — they are usually wrong;
describe the effort qualitatively ("no code changes for most projects").
Tag convention: gh release list shows the repo's pattern
(nuxt-extensions/nest-server/cli: bare X.Y.Z; the templates tag vX.Y.Z
through their release scripts) — follow the existing pattern.
Recipes per repo
nuxt-extensions (npm package @lenne.tech/nuxt-extensions)
Picking the number: a breaking change is a MINOR here, never a major.
Same rule as nest-server below, different anchor: the MAJOR digit tracks the Nuxt
major this module targets — 1.x is Nuxt 4 — so it moves when, and only when, Nuxt
moves. Everything of our own ships in a minor, breaking changes included: a removed
composable, a changed option shape, a narrowed peer range.
This half was unwritten until 1.15.0 narrowed better-auth from >=1.0.0 to
>=1.7.1 <1.8.0 — an install-breaking change for anyone below the floor, shipped as a
minor with nothing stating why the digit stayed. Strict semver would call that a major;
the anchor is what overrides it, and the anchor only holds if it is written down.
The ### Breaking CHANGELOG heading plus a migration-guides/ entry carry the warning
instead. Say up front that the minor contains breaking changes and why the digit stays.
- Maintenance (
/lt-dev:maintenance:maintain) → pnpm i → pnpm run check green.
- New version in
package.json, pnpm i.
git add . && git commit -am 'NEW_VERSION: MESSAGE' → push (main).
gh release create for NEW_VERSION → publish.yml publishes to npm.
nest-server (npm package @lenne.tech/nest-server, branch develop)
Picking the number: a breaking change is a MINOR here, never a major.
The MAJOR digit tracks the NestJS major this package targets — 11.x is NestJS 11 — so it
moves when, and only when, NestJS moves. Everything of our own ships in a minor, breaking
changes included: removed APIs, changed signatures, a dependency turned into a required
peer. Do not "promote" a breaking change to a major because semver would elsewhere; that
would decouple the digit from NestJS and cost the meaning it carries.
The consumer half of this rule is already in nest-server-updating ("Minor = Major, treat
it as such"). This is the producing half — and the one that is easy to get backwards while
writing the release, because the change genuinely IS breaking.
The migration guide (step 3) is what carries the weight instead: say up front that the
minor contains breaking changes and why the digit stays, so nobody reads the version number
as a promise it does not make.
- Work on
develop. Maintenance (/lt-dev:maintenance:maintain) → pnpm i → pnpm run check green.
- New version in
package.json, pnpm i.
- Migration guide: create
migration-guides/<old>-to-<new>.md following
TEMPLATE.md — even for dependency-only releases (short: "no code changes
required").
- Commit
NEW_VERSION: MESSAGE → push develop.
- PR develop→main:
gh pr create -B main -H develop → wait for CI
(gh pr checks --watch) → gh pr merge --merge (no squash).
gh release create on main for NEW_VERSION → publish.yml → npm.
publish.yml runs two jobs in parallel, and only one of them gates the release.
| Job |
Blocks the release? |
What it proves |
publish |
yes |
the artifact: audit, full suite, TDZ guard, build, consumer gate, then npm |
regression-evidence |
no |
that the ~57 registered regression tests still observe their defects |
The evidence job cost ~13 of the former ~18 minutes and held every release behind it. What it
protects is the freshness of the safety net, not the correctness of the artifact: a regression
test that has gone vacuous does not make the package wrong, it makes a FUTURE regression harder
to catch. Worth fixing promptly, rarely worth blocking a release on. So it now runs alongside.
That trade is only honest because a red run cannot be missed, and it takes all three of these:
- the workflow run is marked failed (the publish has already happened by then),
- an issue labelled
regression-evidence is opened automatically,
/lt-dev:publish reads the last conclusion in its preflight (step 1b) and reports it before
starting the next release.
Remove any one and this becomes a job nobody reads — which is strictly worse than the old
blocking version, because it still looks like a safety net. If you ever make another gate
non-blocking, port all three or leave it blocking.
Carrying a red evidence run into the next release is a legitimate decision; making it silently
is not. Name the failing mutation, then decide.
lt-monorepo (template, not an npm package)
- Maintenance (
/lt-dev:maintenance:maintain) → pnpm run check green.
git add . && git commit -am 'MESSAGE'.
pnpm run release[:minor|:major] (commit-and-tag-version) →
git push --follow-tags origin main (HTTPS fallback applies — the release
script does NOT push by itself here).
lt CLI (npm package @lenne.tech/cli)
Maintenance (/lt-dev:maintenance:maintain) → npm run check green (note: npm, not pnpm; the
audit gate aborts on ANY finding — fix via overrides + the //overrides
doc object, see cli/CLAUDE.md).
pnpm run check here does not just fail, it leaves a mess. This repo is
npm-based (package-lock.json). pnpm runs its own install first, dies on
ERR_PNPM_IGNORED_BUILDS (@lenne.tech/npm-package-helper, bcrypt,
unrs-resolver) — and by then has written a pnpm-lock.yaml and a stub
pnpm-workspace.yaml that have no business in this repo. Delete both if the
wrong command ran; the failure is loud, the two files are not.
New version in package.json, npm i.
Commit NEW_VERSION: MESSAGE → push main → gh release create → npm.
npm test must report 0 skipped (repo policy).
nuxt-base-starter (template; consumes nuxt-extensions)
Wait until @lenne.tech/nuxt-extensions@<version> resolves — version-specific
endpoint, see the propagation rule above (npm view reads a lagging cache).
Bump the dependency in nuxt-base-template/package.json.
Maintenance (/lt-dev:maintenance:maintain) → repo root: pnpm i + pnpm run check; additionally
cd nuxt-base-template && pnpm i && pnpm run check.
Optional but recommended before UI-lib bumps: pnpm run test:e2e in the
template (Playwright is NOT part of check).
git add . → commit (message from diff analysis) → version via
pnpm exec standard-version --release-as <patch|minor|major> → then push the
commit and the tag in two steps, NOT via pnpm run release:
git push origin main
git push origin refs/tags/vX.Y.Z
Pick the channel with the push-channel rule above — do not assume HTTPS.
pnpm run release (root package.json) appends git push --follow-tags origin main, and that combined push was refused at v2.25.0 (2026-09-02).
Why it was refused is unmeasured. This skill used to blame an empty SSH
agent — but that is the exact false negative ssh-add -l produces here, and on
2026-09-04 the functional check reported ssh … push normally with two keys in
the 1Password agent. A force-push guard on --follow-tags is the other
candidate and is equally unproven: there is no deny rule and no push hook in
~/.claude/settings.json, so it would have to be the built-in harness
protection. The two-step push worked at v2.25.0 and v2.25.1 — use it, and leave
the cause open instead of repeating a guess.
Then stop — the GitHub release makes itself. A workflow reacts to the tag
push and creates the release. A follow-up gh release create vX.Y.Z fails with
HTTP 422: Release.tag_name already exists — within seconds and reliably, so it
is the workflow having won, not a race and not an error. Verify with
gh release view vX.Y.Z plus both workflows (Release, Tests) green, never
from the exit code of your own create call: it reports failure on a release
that exists, and a session reading that as "the release did not happen" will
try to fix a release that is already live. Measured 2026-09-04 on v2.25.1.
nest-server-starter (template; consumes nest-server)
Wait until @lenne.tech/nest-server@<version> resolves — version-specific
endpoint, see the propagation rule above (npm view reads a lagging cache).
Set version AND @lenne.tech/nest-server in package.json to the new
nest-server version (starter version == nest-server version, lock-step).
spectaql.yml inherits version via the spectaql:sync step, so raising
only the dependency leaves the GraphQL docs advertising the previous
release — and nothing catches it: check passes with the two fields out of
sync. Verify by hand before committing the bump:
node -e "const p=require('./package.json');process.exit(p.version===p.dependencies['@lenne.tech/nest-server']?0:1)" && echo "version matches" || echo "MISMATCH"
Lock-step is a rule of the starter REPOSITORY, never of projects generated
from it. A generated project carries its own version and upgrades the
framework independently, so there the two fields are expected to differ.
That is also why this stays a manual check with no guard in
scripts/check.mjs: the check script ships with the template, so a guard
would travel into every generated project and fail there on a perfectly
correct state. Decided by Kai 2026-09-04 — do not "fix" the missing guard.
pnpm run update → apply the relevant migration guides from
nest-server/migration-guides/ → pnpm run check green. "Apply the
migration guide" is NOT only about code changes. A guide that says "no
code changes required for most projects" still routinely introduces new
opt-in configuration (env vars, Docker knobs) that the starter — as the
REFERENCE project consumers copy — must surface. So for every guide, also
check its "What's new / config" section against the starter's reference
config surfaces (.env.example, docker-entrypoint.sh, src/config.env.ts)
and document any new opt-in knob there (commented-out, default-off), even
when zero code lines change. A pure lock-step version bump is an incomplete
downstream update. Applies in publish-directly mode too (this is part of the
recipe, not the dependency-maintenance step that --skip-maintenance skips).
Maintenance (/lt-dev:maintenance:maintain) → pnpm run check again.
Commit: on a nest-server version change exactly
Updated to nest-server version <X.Y.Z>, otherwise a normal message →
push main.
Marketplace repos (claude-code public, claude-code-internal private)
Not part of the stack waves — they ship Claude Code plugins, not application
code, and nothing consumes them via npm. Both use the same one-step release,
always through the npm script, never a hand-made version edit or commit:
npm run version:patch "<commit message>" # or version:minor / version:major
scripts/bump-version.ts bumps package.json, .claude-plugin/marketplace.json
and every plugins/*/plugin.json to the same version, commits, tags vX.Y.Z
and pushes — a complete release, so run it only when everything is final. The
message is mandatory: it becomes the commit body and the tag annotation. Quote
it as one argument; npm run forwards it without a -- separator.
Release gate: claude plugin validate plugins/<name> per changed plugin
(plus /lt-dev:plugin:check when elements were added or restructured). No
check script, no smoke test, no npm propagation wait.
Version bumps are mandatory. Plugins run from the versioned cache
~/.claude/plugins/cache/<marketplace>/<plugin>/<version>/; without a bump the
same folder is overwritten, which costs rollback and traceability.
bump-version.ts stages the whole tree (git add .), so a peer's uncommitted
work rides along. This repo is worked in parallel more than most, because
stack-wide findings are supposed to land here, so foreign changes in the tree are
the normal case rather than an edge one. git:ship and dev-submit gate against
this; the publish path cannot, because the npm script owns the commit. So the gate
is manual and belongs before the bump:
bash ${CLAUDE_PLUGIN_ROOT}/scripts/change-provenance.sh
git stash push -m "held out of <version>" -- <foreign paths>
npm run version:minor "<message>"
git stash pop
Tell the affected sessions before the stash (CONFLICT) and after the pop
(READY) — the window is seconds, but a parallel writer turns it into a conflict.
Observed on 2026-09-01 during the 8.9.0 release: two foreign files were in the
tree, both finished, both describing versions that did not exist — nuxt-extensions
1.16.0 and nest-server 11.38.0 against npm's 1.15.1 and 11.37.0, plus lt CLI guards
absent from 1.44.0. Asking their authors (ORIGIN) is what surfaced it; the diffs
alone read as ready to ship.
Check the versions a documentation change references, not just the change.
Same release, one line further: the guard list already contained a claim about
lt fullstack init behaviour that shipped in 8.9.0 because only the foreign
addition had been verified, not the text it was added to. A skill that promises a
guard nobody can install sends its reader looking for something that is not there.
npm view <pkg> version for packages, ls-remote --tags for templates, and
git show <tag>:<path> to prove the tag actually contains what the text claims.
Secrets guard: scripts/scan-secrets.sh runs via pre-commit/pre-push and
aborts the release on findings — critical for the PUBLIC claude-code. Fix
findings, never bypass with --no-verify.
Push channel: claude-code → GitHub (SSH-agent check + HTTPS fallback as
above); claude-code-internal → gitlab.lenne.tech:intern/claude-code-internal,
where gh does not apply and no GitHub release is created.
Consumers: lt claude plugins refreshes the marketplace cache and updates
every plugin; a Claude Code restart applies it.
Single-repo fast path (/lt-dev:publish)
The same recipes serve a second entry point: publish ONE repo's changes
quickly and update only its downstream chain (nest-server →
nest-server-starter; nuxt-extensions → nuxt-base-starter). The target repo
is auto-detected from the current working directory (origin remote matched
against the six stack repos plus the two marketplace repos) or passed
explicitly. Differences to the full
cycle: uncommitted changes in the source repo are the payload (not a
preflight error — but stop on unrelated-looking files); maintenance is an
interactive gate — the command ASKS "publish directly" vs. "maintain first"
(/lt-dev:maintenance:maintain) rather than auto-running it; the smoke test is
opt-in instead of mandatory; and the chain ends after the direct consumers.
Everything else — the no-change gate, commit-message convention, release-note
conventions, propagation waits — applies unchanged.
Validation: smoke test as release gate
After wave 2 ALWAYS run /lt-dev:fullstack:smoke-test (full run incl.
TurboOps deploy + online checks + residue-free cleanup). Every finding is a
base-repo fix → patch the causing repo → run its recipe again (patch
release) → repeat the smoke-test phase until clean.
Important: the smoke test clones the templates from GitHub (main) —
fixes only take effect AFTER commit+push/release of the affected repo, never
from the local working tree.
Cleanliness (leave nothing behind)
- The smoke test cleans up its own systems (TurboOps, GitLab, local); report
the known policy leftovers (local Mongo DBs behind the confirmation hook,
server volumes behind the exec blocklist) as manual one-liners — do NOT
bypass the policies.
- Maintenance runs leave NO branches/stashes: pre-existing stashes stay
untouched, agents create none,
git stash list unchanged.
- Never leave a half release: tag without npm publish → check
gh run list --workflow publish.yml, re-run the action instead of
stacking a new tag.
Diagnosing a slow release: separate QUEUE from WORK
gh run list reports a run's duration as createdAt → updatedAt, which includes the time the job
spent waiting for a GitHub-hosted runner. Comparing releases on that number diagnoses the wrong
thing: a 2026-08-19 nest-server publish looked like a 44-minute outlier against a 17-minute norm and
was in fact 25m queue + 18m work — identical work to every neighbouring release, nothing in the
repo to fix, and nothing in the repo that could have fixed it.
Split them before drawing any conclusion:
for id in $(gh run list --workflow=publish.yml --limit 10 --json databaseId -q '.[].databaseId'); do
gh api repos/<owner>/<repo>/actions/runs/$id \
-q '"\((((.run_started_at|fromdate)-(.created_at|fromdate))/60)|floor)m queue + \((((.updated_at|fromdate)-(.run_started_at|fromdate))/60)|floor)m work \(.display_title[0:34])"'
done
Then attribute the work half to a step before optimising anything:
gh api repos/<owner>/<repo>/actions/jobs/<jobId> \
-q '.steps[] | select(.conclusion != null) | "\(((.completed_at|fromdate)-(.started_at|fromdate)))s\t\(.name)"' | sort -rn
Where a publish's time actually goes (nest-server)
Measured 2026-08-22, and counter-intuitive enough to be worth writing down:
| Step |
Share of an 18-minute publish |
Regression evidence (check:mutations) |
~13 min |
| Optimize and check (full suite + build) |
~2.5 min |
| Consumer gate (tarball into the starter) |
~1.5 min |
| The npm publish itself |
5 seconds |
The 3-minute publishes up to 11.33.1 became 17-minute ones at 11.34.0 — that is when the mutation
check joined the publish path. It is a deliberate cost, not a regression.
And the cost is not the tests. The specs behind all 29 e2e mutations add up to ~40 seconds; the
rest is vitest's cold start paid once per mutation, 49 times. That work is largely single-threaded
I/O and barely scales with cores — the registry measures 744s on a 12-core laptop and 777s on a
4-vCPU CI runner. So do not reach for a bigger runner first; it buys almost nothing here.
Parallelism does: check:mutations --jobs=4 measured 744s → 399s, with all 49 verdicts diffed
against a sequential run to prove the verdicts did not move.
Whatever you change here, that diff is the acceptance test. A faster gate that reports a different
verdict is not an optimisation — it is a broken safety net that now fails faster.
Pitfalls (empirical)
- check green ≠ release ready: nuxt-extensions has its own
release
script gates (format/lint/version:check/test:types/test) — verify them
before tagging.
- Same-day majors: pnpm 11's default 24h release-age gate may silently
write a
minimumReleaseAgeExclude entry for a fresh third-party major into
pnpm-workspace.yaml. Never commit such an entry into a template — defer
the update instead (the entry is dead weight once the package ages past the
gate).
- Starter lockfiles: after bumping a dependency in the template ALWAYS
run
pnpm i there too (the template has its OWN lockfile next to the repo
root's).
- Agent memory: follow the
managing-agent-memory
skill — it resolves the repo's commit policy (asking at most once, then
remembering the answer in .claude/settings.local.json) and curates the notes
before they are staged. Never leave them in unstaged limbo.
- Release scripts that push themselves (nuxt-base-starter
release): their
embedded git push --follow-tags was refused at v2.25.0 for a reason nobody has
measured — run the version tool directly and push commit and tag yourself, in
two steps, on the channel the push-channel rule picks.
- Husky/simple-git-hooks run on every commit (lint) — a red hook is a
real finding, never bypass with
-n.
Related
- Command
/lt-dev:maintenance:maintain — FULL per-repo maintenance
(frameworks first, then packages) run before each release; never commits.
- Command
/lt-dev:git:commit-message — recommended helper for crafting a
convention-following commit message (helper, not a mandatory gate).
- Skill
maintaining-npm-packages — the 5 maintenance modes (agents use FULL).
- Skill
running-check-script — iterate check until green.
- Command
/lt-dev:fullstack:smoke-test — the release gate.
- Skill
deploying-to-turboops — deploy contract + Trap 5 (Turbo-Dev Traefik).
- Skill
coordinating-peer-sessions — the message protocol behind the parallel
wave execution above (CLAIM per repo, READY between waves, SOLVED for
environmental findings).
1---2name: maintaining-lt-stack3description: Single source of truth for stack-wide maintenance and releases of the lt base repos ("Grund-Repos"): the dependency graph (nuxt-extensions to nuxt-base-starter, nest-server to nest-server-starter), the release recipe per repo including both marketplaces, npm propagation waits, the push-channel check and its HTTPS fallback, and the smoke test as release gate. Activates on "maintain stack", "release all repos", "stack release", "Grund-Repos aktualisieren", and behind /lt-dev:publish. NOT for a single npm package (use maintaining-npm-packages). NOT for nest-server upgrades inside customer projects (use nest-server-updating).4---56# Maintaining the lt Stack (all base repos)78The seven base repos are maintained and released in dependency order. Canonical9source is [github.com/lenneTech](https://github.com/lenneTech); releasing needs a10local clone of each, and that checkout path differs per machine — locate it instead11of assuming one:1213```bash14find "$HOME" -maxdepth 5 -type d -name nest-server-starter -not -path '*/node_modules/*' 2>/dev/null15```1617If a repo is not checked out anywhere, clone it into the same workspace directory as18its siblings. Target end state: every repo current, `check` green everywhere, npm19packages published, templates tagged — proven by a full20`/lt-dev:fullstack:smoke-test` run.2122## Dependency graph (dictates the order)2324```25Wave 1 (parallelizable): nuxt-extensions nest-server lt-monorepo cli26 │ │27 ▼ (wait for npm publish!)28Wave 2 (parallelizable): nuxt-base-starter nest-server-starter29 │30 ▼31Validation: /lt-dev:fullstack:smoke-test (exercises ALL repos live)32 │33 ▼34Wave 3 (only on findings): patch fixes → re-release affected repos35```3637**Rule:** A starter is only updated once its npm package actually resolves on38npm, not when the GitHub release exists — the publish.yml action takes minutes.3940**Ask the version-specific endpoint, not `npm view`.** `npm view` and41`https://registry.npmjs.org/<pkg>` read the same CDN-cached packument, and it42lags minutes behind a publish:4344```bash45curl -s -o /dev/null -w '%{http_code}' https://registry.npmjs.org/<pkg>/<version>46# 200 = resolvable now. Alternative: cache-bust with -H 'Cache-Control: no-cache' plus a query param.47```4849Measured 2026-09-04: a session polled `npm view` for ten minutes and read "not50published yet" while `@lenne.tech/nest-server@11.41.0` had been up the whole51time — the version-specific endpoint answered 200 immediately. From the52caller's side "not there yet" and "the instrument is reading a cache" look53identical, which is why the endpoint is the one to ask.5455## Running the waves across parallel sessions5657The waves above say "parallelizable" and that is meant literally: one Claude Code session per repo, in its own terminal, is how a stack release actually goes fast. Wave 1 holds four independent repos and Wave 2 two more, so the wall-clock floor is one repo's release, not six in sequence.5859That only works if the sessions coordinate on four things. All of it runs on the [`coordinating-peer-sessions`](${CLAUDE_SKILL_DIR}/../coordinating-peer-sessions/SKILL.md) protocol, and the split by repository is exactly the boundary that protocol asks for: no two sessions ever touch one working tree.6061**1. Claim a repo before starting it.** Record it in the ledger first, then announce it:6263```bash64bash ${CLAUDE_PLUGIN_ROOT}/scripts/peer-ledger.sh read # who already owns what65bash ${CLAUDE_PLUGIN_ROOT}/scripts/peer-ledger.sh claim "repo:nest-server" "Wave 1 release"66```6768The ledger part matters more here than anywhere else: a release round runs for hours, sessions come and go, and a claim that lives only in a message is lost the moment its terminal closes. `claim` refuses a repo another **live** session holds and names it; a claim whose session died reads `[stale]` and is free to take, so a crash never strands a repo. Release with `peer-ledger.sh release "repo:nest-server" "<version>"` when the repo is out. Without any of this, two sessions release `nest-server` and the second mints a version over the first.6970```71[CLAIM] nest-server — taking the Wave 1 release for this repo.72Betrifft: the stack release; nobody else should version or publish it.73Nötig: pick a different Wave 1 repo.74Frei: when I report READY with the published version.75```7677**2. Signal Wave 2 with `READY`, do not make it poll.** The wait between waves is a real npm propagation delay, and the session waiting on it has no way to know the moment it clears except by asking the registry again and again. The publishing session knows exactly when. It sends one `READY` with the version:7879```80[READY] @lenne.tech/nuxt-extensions@5.4.1 is published and resolvable on the registry.81Betrifft: nuxt-base-starter — the Wave 2 dependency bump can start.82Nötig: pnpm add @lenne.tech/nuxt-extensions@5.4.1 and continue.83```8485The Wave 2 session may also subscribe with `notify_when_idle` on the Wave 1 session instead of polling. Either way, nobody sits in a `sleep` loop against the registry.8687**3. Send `SOLVED` for anything environmental.** The empirical pitfalls in this skill are almost all machine-wide, not repo-specific: a remote read that silently returned nothing, a registry that has not propagated, a CI runner queue backing up, a toolchain version that broke. The first session to diagnose one has already paid for it, and the other five are walking into the same wall. One `SOLVED` and they do not.8889```90[SOLVED] `git ls-remote` fails on stderr with empty stdout, so tag checks read as "no tags".91Betrifft: every repo in this release round; your tag verification lies the same way.92Nötig: check the exit code, or read over HTTPS — see the push-channel rule.93```9495**4. Report the finish with `LANDED`, so the smoke test starts once.** The validation gate runs after every repo is out. Whoever finishes last starts it; the others say so and stop.9697What does **not** go over messages: which repos exist and in which order they go (this skill says so), and which version a repo is on (the registry and the tags say so). And no session assigns another one a repo. The user decides who takes what, or each session claims a free one and says which.9899## Cross-cutting rules (all repos)100101- **Wire-critical packages move in BOTH framework repos, in one go.** Some102 dependencies are not one repo's business — they are one protocol with two103 ends, and nest-server and nuxt-extensions each own one end. `better-auth` is104 the case that taught us; treat any package both frameworks import the same105 way.106107 Both declare it as a **peer** with a range that is byte-identical in the two108 manifests, and narrow: `>=<lowest known-good> <<next minor>`, never `^`.109 better-auth breaks in MINOR releases, so a caret invites the same split one110 release later. Read the current range out of the manifests rather than from111 here — it moves, and a version written into this skill is a version that goes112 stale:113114 ```bash115 node -e 'for (const p of ["nest-server","nuxt-extensions"]) console.log(p, require(`${process.env.HOME}/code/lenneTech/${p}/package.json`).peerDependencies["better-auth"])'116 ```117118 Bumping it means: raise the range in nest-server AND nuxt-extensions, pin the119 new version in nest-server-starter AND nuxt-base-starter, release all four.120 Never half of it, not even "just to unblock the frontend". Raising only the121 LOWER bound is a bump too — it is what says which patch is known-good, and a122 lower bound that disagrees between the two repos is the same defect as a123 version split, one release earlier.124125 **The floor is not a matter of taste: it must satisfy the SIBLING peers too.**126 A package like `better-auth` ships with companions (`@better-auth/passkey`,127 `@better-auth/core`) that peer-require a version of it in turn. A floor below128 what a companion demands blesses a pairing that cannot actually install —129 `>=1.7.0` allowed better-auth 1.7.0 next to passkey 1.7.1, which passkey130 itself rejects (`^1.7.1`). The range was not wrong in general; it admitted131 exactly one invalid cross-combination, which is precisely the kind that no132 install in either repo would ever hit. Derive the floor from the companion's133 manifest, not from intuition:134135 ```bash136 node -e 'console.log(require("./node_modules/@better-auth/passkey/package.json").peerDependencies)'137 ```138139 nuxt-extensions asserts this mechanically in `test/peer-dependency-ranges.test.ts`140 (floor vs. the companion's requirement, and every range against the devDependency141 actually tested against). Worth copying wherever a peer range has companions.142143 What a split costs, measured: nest-server pinned better-auth 1.6.26 as a hard144 dependency while nuxt-extensions declared a peer, so the app moved to 1.7.1145 and the api could not follow. 1.7 gives `twoFactor.enable` a discriminated146 result carrying `method`, which 1.6.26 never sends — every 2FA activation in147 every fullstack project failed, with a generic client error and nothing148 unusual in the server log. Both repos' `check` was green the whole time:149 each was internally consistent, and only the assembled workspace has both150 halves.151152 Three guards catch it today, and none of them replaces this rule — they catch153 the mistake, the rule prevents it:154155 - **When the manifests are merged** (lt CLI `hoist-workspace-pnpm-config.ts`,156 since 1.45.0). The hoist is last-writer-wins, which is right for root-vs-sub157 and a trap between siblings. Reported: two sub-projects setting one key158 differently in the same run; the incremental case (`add-api` then `add-app`,159 where the earlier run already hoisted and emptied its source); and a repo160 contradicting ITSELF across `package.json#pnpm` and its own161 `pnpm-workspace.yaml`. `allowBuilds` is not merely reported but **merged162 deny-wins** — a warning does not stop an install script that one project163 explicitly refused, and `overrides` stays report-only because there is no164 safe direction for a version.165 - **In the assembled workspace** (`lt-monorepo/scripts/check-workspace-consistency.mjs`,166 since 3.10.0). Fails when api and app resolve a wire-critical package167 differently, when the two frameworks promise different peer ranges, or when a168 member has the package installed but pins it nowhere — `autoInstallPeers`169 defaults to true, so an unpinned peer agrees today and is free to drift on170 the next install.171 - **In behaviour** (`nuxt-base-starter/.github/workflows/test.yml`, job172 `e2e-auth`, green since 2.22.3). Boots MongoDB and nest-server-starter and173 runs the auth suite against a real API. The only layer that sees a broken174 contract rather than a version diff — the other one compares declarations,175 and declarations were green throughout the split.176177 Two things it needs that are easy to miss, both found by its own first runs:178 the template's dependencies must be installed separately (it has its own179 lockfile, and its check chain starts with `cross-env` — one of its own180 devDependencies), and an empty database routes every visitor to `/auth/setup`,181 so the first admin has to be created via `POST /system-setup/init` before the182 suite runs.183184 The smoke test is the end-to-end gate: it builds a real fullstack project, so185 it is the one place a wire split shows up as a failing flow rather than as a186 version diff.187188- **No change → no release.** The ONLY case that skips a release is a repo189 where truly NOTHING changed (working tree clean AND no commits since the190 last released version) — never mint a version that contains no changes at191 all. ANY actual repo change — dependency bumps, code, scripts, lockfile,192 tooling pins — justifies a new version; do not second-guess whether a193 change is "release-worthy". An unchanged repo is reported as194 "already current — no release" and skipped.195 The reference is the PUBLISHED ARTIFACT: for npm packages, commits that196 cannot reach the tarball (outside the package.json `files` set — e.g.197 `.claude/agent-memory/**`, CI config) do not trigger a release of their198 own; verify with `npm pack --dry-run` when unsure. Such commits simply199 ride along with the next real release. For templates the artifact is the200 repo itself, so every commit counts.201202- **Push channel:** ask the script, which decides it functionally:203204 ```bash205 bash "${CLAUDE_PLUGIN_ROOT}/scripts/check-push-channel.sh" <repo-root>206 # -> ssh<TAB>github.com<TAB>authenticated as: Hi kaihaase! …207 # -> https<TAB>github.com<TAB>no SSH authentication (…)208 ```209210 On `https`, push via HTTPS:211 `git -c credential.helper='!gh auth git-credential' push https://github.com/lenneTech/<repo>.git <branch>`.212 `gh release create` is unaffected either way.213214 **Do NOT use `ssh-add -l` for this.** It is the obvious test and it is wrong here — measured215 2026-08-23, where it reported "The agent has no identities" while `ssh -T git@github.com`216 authenticated fine and had done all along. The reason: `~/.ssh/config` routes SSH to the217 1Password agent via218219 ```220 Host *221 IdentityAgent "~/Library/Group Containers/2BUA8C4S2C.com.1password/t/agent.sock"222 ```223224 but **`IdentityAgent` is an `ssh(1)` option and `ssh-add` does not read `~/.ssh/config` at225 all** — it only ever talks to the agent in `$SSH_AUTH_SOCK`, which on macOS points at the226 (empty) launchd agent. So `ssh-add -l` interrogates an agent SSH never uses. On any227 1Password/IdentityAgent setup it is a permanent false negative, and every HTTPS fallback it228 triggered was unnecessary.229230 To inspect the keys SSH really has, aim `ssh-add` at the configured agent instead:231 `SSH_AUTH_SOCK="$HOME/Library/Group Containers/2BUA8C4S2C.com.1password/t/agent.sock" ssh-add -l`.232 But prefer the functional check above — it stays correct regardless of how the agent is wired.233234 **The same broken agent makes READ commands lie, not just pushes.** When the agent235 cannot sign, `git ls-remote` exits 128 and prints its complaint on **stderr** while236 stdout stays **empty**. A pipeline that only reads stdout — `git ls-remote --tags origin237 | wc -l`, or a `grep` for one tag — therefore reports "the remote has no tags" and238 "that tag does not exist", which reads exactly like a missing release rather than a239 failed connection. Measured 2026-08-23: two sessions independently concluded a release240 tag had not been pushed; over HTTPS the remote had all 132 tags including that one, and241 `git fetch --quiet` had been failing silently the whole time, so the remote-tracking242 refs were stale on top of it.243244 So for any remote read: **check the exit code, or use HTTPS from the start.**245246 ```bash247 git -c credential.helper='!gh auth git-credential' ls-remote --tags https://github.com/lenneTech/<repo>.git248 ```249250 Never conclude "not on the remote" from an empty result you did not prove was a251 successful call. `git fetch` is the same trap wearing a quieter coat — with `--quiet`252 it fails without a visible word, and every later `origin/<branch>` comparison silently253 answers from stale local refs.254255 And once the call DOES succeed, do not count its lines. `ls-remote --tags` prints two256 refs per annotated tag — `refs/tags/X` and the peeled `refs/tags/X^{}` — so `wc -l`257 reported 249 where this repo has 132 tags. Compare names, not line counts:258259 ```bash260 … ls-remote --tags <url> | sed 's|.*refs/tags/||; s|\^{}$||' | sort -u261 ```262263 The two failure modes stack: a broken agent turns a real list into "nothing", and a264 line count turns a matching list into a phantom difference. Both end as a confident265 claim about a remote nobody actually read.266- **Dependency maintenance:** per repo via the `/lt-dev:maintenance:maintain`267 command (FULL) — it raises the lenne.tech **frameworks first** (npm + vendor268 core), aligns their pinned ecosystem, and only then hands off to the269 `lt-dev:npm-package-maintainer` agent (skill `maintaining-npm-packages`) for270 the surrounding packages, iterating `check` to green. Framework-first is not271 optional: a CVE inside a framework-pinned dependency cannot be fixed with an272 `override`, only by raising the framework. Maintenance **never commits** (the273 orchestrator commits and releases in a controlled way). For a repo that IS a274 framework (nest-server, nuxt-extensions) the framework phase is a no-op and275 it degrades to plain package maintenance.276- **Never force-push/squash** where the flow does not call for it; the277 nest-server PR is merged explicitly WITHOUT squash (merge commit).278- **Version convention for npm packages:** set the version manually in279 `package.json`, then `pnpm i`/`npm i` (lockfile!), commit message exactly280 `NEW_VERSION: COMMIT_MESSAGE` (e.g. `1.11.0: update deps, fix X`).281- **Commit message:** the CONVENTION is non-negotiable — `NEW_VERSION: MESSAGE`282 for npm packages, conventional-commits for templates (so283 `commit-and-tag-version` derives the bump), and the fixed284 `Updated to nest-server version <X.Y.Z>` for a nest-server-starter version285 bump. `/lt-dev:git:commit-message` is the recommended helper for crafting the286 descriptive part — but it is a helper, not a gate: skip it when the change287 already dictates an obvious, convention-compliant message (a focused one-line288 fix, or the fixed starter message). How you arrive at the wording is free;289 the convention is not.290- **Language:** every published artifact — release notes, commit messages,291 PR bodies, migration guides, descriptions — is written in **English**.292- **Release notes are for CONSUMERS, not for the log.** Audience: developers293 who use the release in their projects. Structure: (1) what is this? (one294 sentence, e.g. "Maintenance release — no API changes"), (2) how do I295 update? (copy-paste command), (3) **do I need to do anything?** (concrete296 checks with before/after — the most important part), (4) optional "Under297 the hood" in 1–2 sentences. NEVER in the notes: raw package version lists,298 test counts / "checks green" status, internal override surgery — that299 belongs in the CHANGELOG / migration guide. Link the migration guide300 instead of duplicating it. NEVER include time estimates ("takes ~5301 minutes") in release texts or migration guides — they are usually wrong;302 describe the effort qualitatively ("no code changes for most projects").303- **Tag convention:** `gh release list` shows the repo's pattern304 (nuxt-extensions/nest-server/cli: bare `X.Y.Z`; the templates tag `vX.Y.Z`305 through their release scripts) — follow the existing pattern.306307## Recipes per repo308309### nuxt-extensions (npm package `@lenne.tech/nuxt-extensions`)310311> **Picking the number: a breaking change is a MINOR here, never a major.**312> Same rule as nest-server below, different anchor: the MAJOR digit tracks the **Nuxt**313> major this module targets — `1.x` is Nuxt 4 — so it moves when, and only when, Nuxt314> moves. Everything of our own ships in a minor, breaking changes included: a removed315> composable, a changed option shape, a **narrowed peer range**.316>317> This half was unwritten until 1.15.0 narrowed `better-auth` from `>=1.0.0` to318> `>=1.7.1 <1.8.0` — an install-breaking change for anyone below the floor, shipped as a319> minor with nothing stating why the digit stayed. Strict semver would call that a major;320> the anchor is what overrides it, and the anchor only holds if it is written down.321>322> The `### Breaking` CHANGELOG heading plus a `migration-guides/` entry carry the warning323> instead. Say up front that the minor contains breaking changes and why the digit stays.3243251. Maintenance (`/lt-dev:maintenance:maintain`) → `pnpm i` → `pnpm run check` green.3262. New version in `package.json`, `pnpm i`.3273. `git add . && git commit -am 'NEW_VERSION: MESSAGE'` → push (main).3284. `gh release create` for NEW_VERSION → publish.yml publishes to npm.329330### nest-server (npm package `@lenne.tech/nest-server`, branch `develop`)331332> **Picking the number: a breaking change is a MINOR here, never a major.**333> The MAJOR digit tracks the NestJS major this package targets — 11.x is NestJS 11 — so it334> moves when, and only when, NestJS moves. Everything of our own ships in a minor, breaking335> changes included: removed APIs, changed signatures, a dependency turned into a required336> peer. Do not "promote" a breaking change to a major because semver would elsewhere; that337> would decouple the digit from NestJS and cost the meaning it carries.338>339> The consumer half of this rule is already in `nest-server-updating` ("Minor = Major, treat340> it as such"). This is the producing half — and the one that is easy to get backwards while341> writing the release, because the change genuinely IS breaking.342>343> The migration guide (step 3) is what carries the weight instead: say up front that the344> minor contains breaking changes and why the digit stays, so nobody reads the version number345> as a promise it does not make.3463471. Work on `develop`. Maintenance (`/lt-dev:maintenance:maintain`) → `pnpm i` → `pnpm run check` green.3482. New version in `package.json`, `pnpm i`.3493. **Migration guide**: create `migration-guides/<old>-to-<new>.md` following350 `TEMPLATE.md` — even for dependency-only releases (short: "no code changes351 required").3524. Commit `NEW_VERSION: MESSAGE` → push develop.3535. PR develop→main: `gh pr create -B main -H develop` → wait for CI354 (`gh pr checks --watch`) → `gh pr merge --merge` (**no squash**).3556. `gh release create` on main for NEW_VERSION → publish.yml → npm.356357> **`publish.yml` runs two jobs in parallel, and only one of them gates the release.**358>359> | Job | Blocks the release? | What it proves |360> |---|---|---|361> | `publish` | yes | the artifact: audit, full suite, TDZ guard, build, consumer gate, then npm |362> | `regression-evidence` | **no** | that the ~57 registered regression tests still observe their defects |363>364> The evidence job cost ~13 of the former ~18 minutes and held every release behind it. What it365> protects is the freshness of the safety net, not the correctness of the artifact: a regression366> test that has gone vacuous does not make the package wrong, it makes a FUTURE regression harder367> to catch. Worth fixing promptly, rarely worth blocking a release on. So it now runs alongside.368>369> **That trade is only honest because a red run cannot be missed**, and it takes all three of these:370>371> 1. the workflow run is marked failed (the publish has already happened by then),372> 2. an issue labelled `regression-evidence` is opened automatically,373> 3. `/lt-dev:publish` reads the last conclusion in its preflight (step 1b) and reports it before374> starting the next release.375>376> Remove any one and this becomes a job nobody reads — which is strictly worse than the old377> blocking version, because it still looks like a safety net. If you ever make another gate378> non-blocking, port all three or leave it blocking.379>380> Carrying a red evidence run into the next release is a legitimate decision; making it silently381> is not. Name the failing mutation, then decide.382383### lt-monorepo (template, not an npm package)3843851. Maintenance (`/lt-dev:maintenance:maintain`) → `pnpm run check` green.3862. `git add . && git commit -am 'MESSAGE'`.3873. `pnpm run release[:minor|:major]` (commit-and-tag-version) →388 `git push --follow-tags origin main` (HTTPS fallback applies — the release389 script does NOT push by itself here).390391### lt CLI (npm package `@lenne.tech/cli`)3923931. Maintenance (`/lt-dev:maintenance:maintain`) → `npm run check` green (note: npm, not pnpm; the394 audit gate aborts on ANY finding — fix via `overrides` + the `//overrides`395 doc object, see cli/CLAUDE.md).396397 **`pnpm run check` here does not just fail, it leaves a mess.** This repo is398 npm-based (`package-lock.json`). pnpm runs its own install first, dies on399 `ERR_PNPM_IGNORED_BUILDS` (`@lenne.tech/npm-package-helper`, `bcrypt`,400 `unrs-resolver`) — and by then has written a `pnpm-lock.yaml` and a stub401 `pnpm-workspace.yaml` that have no business in this repo. Delete both if the402 wrong command ran; the failure is loud, the two files are not.4032. New version in `package.json`, `npm i`.4043. Commit `NEW_VERSION: MESSAGE` → push main → `gh release create` → npm.4054. `npm test` must report 0 skipped (repo policy).406407### nuxt-base-starter (template; consumes nuxt-extensions)4084090. **Wait** until `@lenne.tech/nuxt-extensions@<version>` resolves — version-specific410 endpoint, see the propagation rule above (`npm view` reads a lagging cache).4111. Bump the dependency in `nuxt-base-template/package.json`.4122. Maintenance (`/lt-dev:maintenance:maintain`) → repo root: `pnpm i` + `pnpm run check`; additionally413 `cd nuxt-base-template && pnpm i && pnpm run check`.4143. Optional but recommended before UI-lib bumps: `pnpm run test:e2e` in the415 template (Playwright is NOT part of `check`).4164. `git add .` → commit (message from diff analysis) → version via417 `pnpm exec standard-version --release-as <patch|minor|major>` → then push the418 commit and the tag **in two steps**, NOT via `pnpm run release`:419420 ```bash421 git push origin main422 git push origin refs/tags/vX.Y.Z423 ```424425 Pick the channel with the push-channel rule above — **do not assume HTTPS.**426 `pnpm run release` (root `package.json`) appends `git push --follow-tags origin427 main`, and that combined push was refused at v2.25.0 (2026-09-02).428 **Why it was refused is unmeasured.** This skill used to blame an empty SSH429 agent — but that is the exact false negative `ssh-add -l` produces here, and on430 2026-09-04 the functional check reported `ssh … push normally` with two keys in431 the 1Password agent. A force-push guard on `--follow-tags` is the other432 candidate and is equally unproven: there is no deny rule and no push hook in433 `~/.claude/settings.json`, so it would have to be the built-in harness434 protection. The two-step push worked at v2.25.0 and v2.25.1 — use it, and leave435 the cause open instead of repeating a guess.4365. **Then stop — the GitHub release makes itself.** A workflow reacts to the tag437 push and creates the release. A follow-up `gh release create vX.Y.Z` fails with438 `HTTP 422: Release.tag_name already exists` — within seconds and reliably, so it439 is the workflow having won, not a race and not an error. Verify with440 `gh release view vX.Y.Z` plus both workflows (Release, Tests) green, **never**441 from the exit code of your own `create` call: it reports failure on a release442 that exists, and a session reading that as "the release did not happen" will443 try to fix a release that is already live. Measured 2026-09-04 on v2.25.1.444445### nest-server-starter (template; consumes nest-server)4464470. **Wait** until `@lenne.tech/nest-server@<version>` resolves — version-specific448 endpoint, see the propagation rule above (`npm view` reads a lagging cache).4491. Set `version` AND `@lenne.tech/nest-server` in `package.json` to the new450 nest-server version (starter version == nest-server version, lock-step).451 `spectaql.yml` inherits `version` via the `spectaql:sync` step, so raising452 only the dependency leaves the GraphQL docs advertising the previous453 release — and nothing catches it: `check` passes with the two fields out of454 sync. Verify by hand before committing the bump:455456 ```bash457 node -e "const p=require('./package.json');process.exit(p.version===p.dependencies['@lenne.tech/nest-server']?0:1)" && echo "version matches" || echo "MISMATCH"458 ```459460 **Lock-step is a rule of the starter REPOSITORY, never of projects generated461 from it.** A generated project carries its own version and upgrades the462 framework independently, so there the two fields are expected to differ.463 That is also why this stays a manual check with no guard in464 `scripts/check.mjs`: the check script ships with the template, so a guard465 would travel into every generated project and fail there on a perfectly466 correct state. Decided by Kai 2026-09-04 — do not "fix" the missing guard.4672. `pnpm run update` → apply the relevant migration guides from468 `nest-server/migration-guides/` → `pnpm run check` green. **"Apply the469 migration guide" is NOT only about code changes.** A guide that says "no470 code changes required for most projects" still routinely introduces new471 opt-in configuration (env vars, Docker knobs) that the starter — as the472 REFERENCE project consumers copy — must surface. So for every guide, also473 check its "What's new / config" section against the starter's reference474 config surfaces (`.env.example`, `docker-entrypoint.sh`, `src/config.env.ts`)475 and document any new opt-in knob there (commented-out, default-off), even476 when zero code lines change. A pure lock-step version bump is an incomplete477 downstream update. Applies in publish-directly mode too (this is part of the478 recipe, not the dependency-maintenance step that `--skip-maintenance` skips).4793. Maintenance (`/lt-dev:maintenance:maintain`) → `pnpm run check` again.4804. Commit: on a nest-server version change exactly481 `Updated to nest-server version <X.Y.Z>`, otherwise a normal message →482 push main.483484### Marketplace repos (`claude-code` public, `claude-code-internal` private)485486Not part of the stack waves — they ship Claude Code plugins, not application487code, and nothing consumes them via npm. Both use the same one-step release,488**always through the npm script**, never a hand-made version edit or commit:489490```bash491npm run version:patch "<commit message>" # or version:minor / version:major492```493494`scripts/bump-version.ts` bumps `package.json`, `.claude-plugin/marketplace.json`495and every `plugins/*/plugin.json` to the same version, commits, tags `vX.Y.Z`496and pushes — a complete release, so run it only when everything is final. The497message is mandatory: it becomes the commit body and the tag annotation. Quote498it as one argument; `npm run` forwards it without a `--` separator.499500- **Release gate:** `claude plugin validate plugins/<name>` per changed plugin501 (plus `/lt-dev:plugin:check` when elements were added or restructured). No502 `check` script, no smoke test, no npm propagation wait.503- **Version bumps are mandatory.** Plugins run from the versioned cache504 `~/.claude/plugins/cache/<marketplace>/<plugin>/<version>/`; without a bump the505 same folder is overwritten, which costs rollback and traceability.506- **`bump-version.ts` stages the whole tree (`git add .`), so a peer's uncommitted507 work rides along.** This repo is worked in parallel more than most, because508 stack-wide findings are supposed to land here, so foreign changes in the tree are509 the normal case rather than an edge one. `git:ship` and `dev-submit` gate against510 this; the publish path cannot, because the npm script owns the commit. So the gate511 is manual and belongs before the bump:512513 ```bash514 bash ${CLAUDE_PLUGIN_ROOT}/scripts/change-provenance.sh515 git stash push -m "held out of <version>" -- <foreign paths>516 npm run version:minor "<message>"517 git stash pop518 ```519520 Tell the affected sessions before the stash (`CONFLICT`) and after the pop521 (`READY`) — the window is seconds, but a parallel writer turns it into a conflict.522523 Observed on 2026-09-01 during the 8.9.0 release: two foreign files were in the524 tree, both finished, both describing versions that did not exist — nuxt-extensions525 1.16.0 and nest-server 11.38.0 against npm's 1.15.1 and 11.37.0, plus lt CLI guards526 absent from 1.44.0. Asking their authors (`ORIGIN`) is what surfaced it; the diffs527 alone read as ready to ship.528529- **Check the versions a documentation change references, not just the change.**530 Same release, one line further: the guard list already contained a claim about531 `lt fullstack init` behaviour that shipped in 8.9.0 because only the foreign532 addition had been verified, not the text it was added to. A skill that promises a533 guard nobody can install sends its reader looking for something that is not there.534 `npm view <pkg> version` for packages, `ls-remote --tags` for templates, and535 `git show <tag>:<path>` to prove the tag actually contains what the text claims.536537- **Secrets guard:** `scripts/scan-secrets.sh` runs via pre-commit/pre-push and538 aborts the release on findings — critical for the PUBLIC `claude-code`. Fix539 findings, never bypass with `--no-verify`.540- **Push channel:** `claude-code` → GitHub (SSH-agent check + HTTPS fallback as541 above); `claude-code-internal` → `gitlab.lenne.tech:intern/claude-code-internal`,542 where `gh` does not apply and no GitHub release is created.543- **Consumers:** `lt claude plugins` refreshes the marketplace cache and updates544 every plugin; a Claude Code restart applies it.545546## Single-repo fast path (`/lt-dev:publish`)547548The same recipes serve a second entry point: publish ONE repo's changes549quickly and update only its downstream chain (nest-server →550nest-server-starter; nuxt-extensions → nuxt-base-starter). The target repo551is auto-detected from the current working directory (origin remote matched552against the six stack repos plus the two marketplace repos) or passed553explicitly. Differences to the full554cycle: uncommitted changes in the source repo are the payload (not a555preflight error — but stop on unrelated-looking files); maintenance is an556interactive gate — the command ASKS "publish directly" vs. "maintain first"557(`/lt-dev:maintenance:maintain`) rather than auto-running it; the smoke test is558opt-in instead of mandatory; and the chain ends after the direct consumers.559Everything else — the no-change gate, commit-message convention, release-note560conventions, propagation waits — applies unchanged.561562## Validation: smoke test as release gate563564After wave 2 ALWAYS run `/lt-dev:fullstack:smoke-test` (full run incl.565TurboOps deploy + online checks + residue-free cleanup). Every finding is a566base-repo fix → patch the causing repo → run its recipe again (patch567release) → repeat the smoke-test phase until clean.568569**Important:** the smoke test clones the templates from GitHub (`main`) —570fixes only take effect AFTER commit+push/release of the affected repo, never571from the local working tree.572573## Cleanliness (leave nothing behind)574575- The smoke test cleans up its own systems (TurboOps, GitLab, local); report576 the known policy leftovers (local Mongo DBs behind the confirmation hook,577 server volumes behind the exec blocklist) as manual one-liners — do NOT578 bypass the policies.579- Maintenance runs leave NO branches/stashes: pre-existing stashes stay580 untouched, agents create none, `git stash list` unchanged.581- Never leave a half release: tag without npm publish → check582 `gh run list --workflow publish.yml`, re-run the action instead of583 stacking a new tag.584585## Diagnosing a slow release: separate QUEUE from WORK586587`gh run list` reports a run's duration as `createdAt → updatedAt`, which includes the time the job588spent **waiting for a GitHub-hosted runner**. Comparing releases on that number diagnoses the wrong589thing: a 2026-08-19 nest-server publish looked like a 44-minute outlier against a 17-minute norm and590was in fact **25m queue + 18m work** — identical work to every neighbouring release, nothing in the591repo to fix, and nothing in the repo that could have fixed it.592593Split them before drawing any conclusion:594595```bash596for id in $(gh run list --workflow=publish.yml --limit 10 --json databaseId -q '.[].databaseId'); do597 gh api repos/<owner>/<repo>/actions/runs/$id \598 -q '"\((((.run_started_at|fromdate)-(.created_at|fromdate))/60)|floor)m queue + \((((.updated_at|fromdate)-(.run_started_at|fromdate))/60)|floor)m work \(.display_title[0:34])"'599done600```601602Then attribute the *work* half to a step before optimising anything:603604```bash605gh api repos/<owner>/<repo>/actions/jobs/<jobId> \606 -q '.steps[] | select(.conclusion != null) | "\(((.completed_at|fromdate)-(.started_at|fromdate)))s\t\(.name)"' | sort -rn607```608609## Where a publish's time actually goes (nest-server)610611Measured 2026-08-22, and counter-intuitive enough to be worth writing down:612613| Step | Share of an 18-minute publish |614|---|---|615| Regression evidence (`check:mutations`) | **~13 min** |616| Optimize and check (full suite + build) | ~2.5 min |617| Consumer gate (tarball into the starter) | ~1.5 min |618| **The npm publish itself** | **5 seconds** |619620The 3-minute publishes up to 11.33.1 became 17-minute ones at 11.34.0 — that is when the mutation621check joined the publish path. It is a deliberate cost, not a regression.622623**And the cost is not the tests.** The specs behind all 29 e2e mutations add up to ~40 seconds; the624rest is vitest's cold start paid once per mutation, 49 times. That work is largely single-threaded625I/O and barely scales with cores — the registry measures 744s on a 12-core laptop and 777s on a6264-vCPU CI runner. So **do not reach for a bigger runner first**; it buys almost nothing here.627Parallelism does: `check:mutations --jobs=4` measured 744s → 399s, with all 49 verdicts diffed628against a sequential run to prove the verdicts did not move.629630Whatever you change here, that diff is the acceptance test. A faster gate that reports a different631verdict is not an optimisation — it is a broken safety net that now fails faster.632633## Pitfalls (empirical)634635- **check green ≠ release ready:** nuxt-extensions has its own `release`636 script gates (format/lint/version:check/test:types/test) — verify them637 before tagging.638- **Same-day majors:** pnpm 11's default 24h release-age gate may silently639 write a `minimumReleaseAgeExclude` entry for a fresh third-party major into640 `pnpm-workspace.yaml`. Never commit such an entry into a template — defer641 the update instead (the entry is dead weight once the package ages past the642 gate).643- **Starter lockfiles:** after bumping a dependency in the template ALWAYS644 run `pnpm i` there too (the template has its OWN lockfile next to the repo645 root's).646- **Agent memory:** follow the [`managing-agent-memory`](../managing-agent-memory/SKILL.md)647 skill — it resolves the repo's commit policy (asking at most once, then648 remembering the answer in `.claude/settings.local.json`) and curates the notes649 before they are staged. Never leave them in unstaged limbo.650- **Release scripts that push themselves** (nuxt-base-starter `release`): their651 embedded `git push --follow-tags` was refused at v2.25.0 for a reason nobody has652 measured — run the version tool directly and push commit and tag yourself, in653 two steps, on the channel the push-channel rule picks.654- **Husky/simple-git-hooks** run on every commit (lint) — a red hook is a655 real finding, never bypass with `-n`.656657## Related658659- Command `/lt-dev:maintenance:maintain` — FULL per-repo maintenance660 (frameworks first, then packages) run before each release; never commits.661- Command `/lt-dev:git:commit-message` — recommended helper for crafting a662 convention-following commit message (helper, not a mandatory gate).663- Skill `maintaining-npm-packages` — the 5 maintenance modes (agents use FULL).664- Skill `running-check-script` — iterate `check` until green.665- Command `/lt-dev:fullstack:smoke-test` — the release gate.666- Skill `deploying-to-turboops` — deploy contract + Trap 5 (Turbo-Dev Traefik).667- Skill `coordinating-peer-sessions` — the message protocol behind the parallel668 wave execution above (CLAIM per repo, READY between waves, SOLVED for669 environmental findings).