Skip to content
patrickserranoPublic

Repository files navigation

lacquer

A Go CLI plus a set of profile templates that standardize how Claude Code works across every project in ~/Developer. The lacquer renders shared content — CLAUDE.md rules, skills, commands, CI workflows, git hooks, tool configs, the credential rules in .gitignore — into each project and tracks how far each project has drifted, so a lesson pinned once propagates everywhere instead of being copy-pasted and left to rot.

A project opts in per component (a subdirectory) via a .lacquer.toml manifest. Each component declares one or more profiles; core applies to every project regardless.

Commands

Command Does
lacquer init [--stack S] Detect components, write a .lacquer.toml stub (and a docs/brief.md stub). --stack also declares the components the project doesn't have yet.
lacquer init --list-stacks Print the archetypes --stack accepts.
lacquer onboard --org O [--no-repo] init, then create a private GitHub repo under O when the repo has no origin.
lacquer adopt Re-detect, and record any stack that appeared since init into .lacquer.toml. Only ever adds.
lacquer sync [--force] [--fix] Render core + per-profile content into the project (managed regions + whole-file assets); --fix then runs the autofixers.
lacquer fix Run each profile's autofixers (formatter, lint --fix) over the project source.
lacquer settings [--project P] [--target T] [--configuration C] [--json] [SETTING...] Inspect static build settings with provenance; --xcode queries Xcode, --xcode --compare compares both.
lacquer ratchet [--write] Measure project ceilings; --write enrolls or tightens the committed baseline. --coverage-report runs the iOS coverage gate. --enroll KEY --to N --reason TEXT enrolls a coverage key a product-count change made CI measure.
lacquer doctor Prove each check can fail: feed known-bad input and assert it's rejected (exit 5 if one can't).
lacquer skills Install [project].skills entries via the skills CLI.
lacquer plugins Install core/bootstrap/plugins.toml (machine-level Claude Code plugins) via claude plugin.
lacquer status Show each region's stamped version vs the lacquer's latest.
lacquer audit Classify project drift; exit 3 if a sync would clobber a local change, 4 on a baseline violation, 6 on an undeclared stack (usable as a CI gate). With --ci (what the shipped CI runs, at the lock's own version), exit 8 when a file matches the lock's hash but not that version's render: the lock was written by a different build than it names. With --ci --ci-event pull_request, exit 9 when the PR's lock names an older lacquer than its base's and records no reason. The PR-time floor holds only when the PR's lock names v1.79.0 or later, because CI runs the binary the PR head's lock names; a downgrade to an older release re-renders the workflow without the floor and is caught after the merge by the gate-off harvest.
lacquer wait pr <N> Block until every check on a PR is terminal (no tokens while it sleeps). Exit 0 none failed, 1 a check failed, 2 timed out, 3 no checks — an empty check list is never a pass. Exits 2, 3 and 4 also raise an inbox ACTION (--no-inbox to skip). The sanctioned way to wait for CI.
lacquer version Print labeled content and build versions, plus the resolved content root path.

lacquer --help prints usage.

Inspecting build settings

lacquer settings --project App.xcodeproj --target App --configuration Release
lacquer settings --project App.xcodeproj --json MARKETING_VERSION
lacquer settings --project App.xcodeproj --target App --configuration Release --xcode --scheme App --compare

Static output is labelled static resolution by lacquer: not Xcode's evaluation; defaults and conditionals are not applied. It uses the baseline reader's layers: project xcconfig, project pbxproj, target xcconfig, target pbxproj. Each setting shows its winning layer/file, UNSET when no declaration exists, or UNKNOWN with a reason when the reader cannot determine it. UNSET does not imply Xcode has no default. An unreadable project or unmatched selection exits non-zero.

The default keys are SWIFT_VERSION, SWIFT_TREAT_WARNINGS_AS_ERRORS, and SWIFT_STRICT_CONCURRENCY; positional keys replace that list. With no project, exactly one .xcodeproj must exist in the current directory. Omitted target or configuration selects all target configurations. Put flags before setting names.

--xcode requires an explicit --scheme S (missing it is a usage error). It calls xcodebuild -showBuildSettings -json -project P -scheme S -configuration C for each selected pair and filters the JSON by target; it does not build or infer a target-to-scheme mapping. If the scheme does not build a selected target, the command fails and lists the targets it does build. Each invocation supplies a temporary -derivedDataPath, -disableAutomaticPackageResolution, and -skipPackageUpdates, then removes the temporary directory. Failures report stderr and exit non-zero without a static fallback. Xcode output identifies its evaluator, but does not expose a winning source file. --compare requires --xcode and marks MATCH, DIFF, or UNKNOWN comparison; differences are informational, not an exit-code gate. --json retains the label and explicit set/unset/unknown states.

Console

lacquer console needs no flags and no files. It prints the inbox's open ACTION/UNREAD entries and every live Claude Code session on the machine, read from claude agents --json. If that cannot be read it says sessions: unavailable — <reason>; an empty list is only ever printed when claude answered with one. The inbox defaults to $XDG_STATE_HOME/lacquer/inbox.jsonl (~/.local/state/lacquer/inbox.jsonl), created on first write; --inbox and $LACQUER_INBOX override it. Add --roster (or $LACQUER_ROSTER) for fleet truth, open PRs and the project each session belongs to. --sessions is only for dispatch records, which watch --relaunch and kill need.

A background agent that goes idle writes itself into the inbox: the iOS, web and supabase profiles ship a Claude Code Stop hook (lacquer console inbox hook stop) that adds one UNREAD, <session> is idle in <project>: <first line of its last message>, ref session:<id>, only when $CLAUDE_JOB_DIR is set (a claude --bg session), and at most one open entry per session. It always exits 0; problems go to stderr. The iOS profile's .claude/settings.json carries the editor guards as well; web's and supabase's carry only this hook, and marketing ships none. A project with more than one of these profiles gets one file, composed by lacquer sync: each hook event's matcher groups are unioned by matcher, identical hook entries appear once (so the Stop hook fires once, not once per profile), and if two profiles set the same top-level key other than hooks to different values the sync fails naming both profiles and the key.

Recorded decisions

Decisions were lost when they were paraphrased on the way into a brief, and the operator's answers lived in one file on one machine. lacquer decisions reads the alternative: the operator's words, verbatim, in GitHub next to the work they govern. The log is issue-backed. Nothing is committed and there is no rendered file to go stale.

In the inbox detail popup, D records a decision: the operator's words, kept exactly as typed (never trimmed, reflowed or summarised), an optional basis (the measurement it was decided against, because decisions expire into facts), then r for this repo or f for fleet-wide, with the target of each shown before anything is posted. Esc at any prompt posts nothing. An ordinary r reply stays a reply: it goes to the overseer and is never recorded, and a decision goes to GitHub only.

A decision is one comment on a repository's one open issue labelled decisions. This repo means the repository the item's ref names, else its project mapped through the roster. Fleet-wide means the fleet repository (--fleet-repo, $LACQUER_FLEET_REPO, or fleet_repo in ~/.config/lacquer/config.toml; no built-in default), so a decision that spans repositories lives in one issue and not in a copy per project. The first decision in a repository creates the decisions label and an issue titled Decisions; two open decisions issues are refused by name. Every write goes through the same gate as a reply's comment, so a repository outside the roster and $LACQUER_EXTRA_REPOS is refused, the fleet repository included, and each write happens only on the operator's keypress. The comment holds nothing an agent wrote:

**Decision** — 2026-09-25T04:10:00Z · from https://github.com/o/r/issues/5

```text
<the operator's words, exactly as typed>
```

Basis:

```text
<the basis, if one was given>
```

The words sit in a code fence rather than a quote, because a fence is the one form that shows a string as written: in a quote #123 writes a cross-reference into another issue and @name notifies someone.

lacquer decisions                 # this checkout's repository, from origin
lacquer decisions owner/name
lacquer decisions --fleet         # the fleet repository

It prints them oldest first and only reads. None recorded prints no decisions recorded for <repo> and exits 0; a gh that could not answer, or two open decisions issues, exits 1, so empty and broken never look alike. The managed CLAUDE.md/AGENTS.md region tells agents to run the first and the last before briefing or starting work.

Dispatch model and effort

console dispatch starts ICs on sonnet by default. Set top-level ic_model = "opus" and/or ic_effort = "low" in the fleet roster to change fleet-wide defaults. console dispatch-role instead uses model/effort in its [[role]] entry; absent settings inherit Claude's defaults.

Both commands accept --model M and --effort E, overriding each configured value independently in bg and tmux modes. For example:

lacquer console --roster fleet.toml --sessions sessions.jsonl --mode bg dispatch app "implement the unit" --model opus --effort low

With --sessions, JSONL records keep the requested model and effort, including failed launches. The dashboard and console watch show those settings, and watchdog relaunches retain explicit recorded values. Empty values (including old records) display inherited/unknown; they do not imply Sonnet. These are requested settings, not transcript verification of the service's actual model. Verify that separately from the session's own transcript when auditing model usage. Dry runs print the selected flags and write no session record.

Dispatch worktree preparation

Before an agent starts in a new, assigned, or resumed worktree, console writes .metadata_never_index at its root to exclude build churn from Spotlight. The marker is ignored through the user's global core.excludesFile, or Git's default $XDG_CONFIG_HOME/git/ignore (~/.config/git/ignore without XDG). Console preserves existing entries and appends the marker rule once; it never adds it to project .gitignore files. An unwritable global file or a repository ignore override that exposes the marker prevents launch with an error. Dry runs write neither markers nor exclusions. Existing worktree branches and source files are preserved. Console does not remove old worktrees.

LACQUER_ROOT

Every command that reads shipped content resolves LACQUER_ROOT (default .) and prints its absolute root path, tag or branch, and commit to stderr. The root must be a clean Git checkout, detached at a tag matching VERSION. Branches, dirty trees (including untracked files), missing Git, unknown commits, and failed Git inspection are refused. Verification is local; it does not fetch or claim that a pinned release is the newest release.

LACQUER_ROOT=~/.local/share/lacquer/content lacquer status

For deliberate development against a working checkout, explicitly opt in:

LACQUER_ALLOW_UNVERIFIED_ROOT=1 LACQUER_ROOT="$PWD" go run ./cmd/lacquer status

Every invocation that uses this override warns that its output is UNREVIEWED. The separate LACQUER_ALLOW_STALE_BINARY override still governs sync when the binary and content versions differ. Neither override proves a release pin.

status labels version-marker drift stamp-behind and checks whether managed content still matches; audit classifies content drift. A release affecting only another profile can leave a project's stamps behind while its content matches.

If the root has no VERSION file or profiles/ directory, commands fail with an actionable message before trying to read content.

Profiles that ship

  • core — universal rules/skills/commands applied to every project.
  • ios — Swift/Xcode: SwiftLint/SwiftFormat, CI, TestFlight, Skills; git hooks via pre-commit.
  • web — TypeScript + Biome + Vitest; CI + git hooks via lefthook; Claude Stop hook (.claude/settings.json).
  • supabase — Deno Edge Functions + Postgres/RLS; CI + git hooks via lefthook; Claude Stop hook (.claude/settings.json).
  • marketing — no CI, no hooks, skills only: ~50 marketing/growth skills (ads, SEO, copywriting, funnels, lifecycle, pricing, planning). Never auto-detected — there is no marketing "stack" on disk to find, so add it to a component's profiles deliberately when marketing work is in scope.

A component detected as an unshipped stack (e.g. Rust/Go, or a bare SwiftPM package) is recorded in the manifest with an empty profile list and a notice — it doesn't break sync, and audit keeps reporting it so the gap stays visible.

Declaring the stack before the code exists

Detection can only see what is already on disk, which makes it useless at the one moment the stack is actually being decided — while the idea is still a brief and a PCD. So name the stack there, as an archetype, and hand it to init:

lacquer init --list-stacks
lacquer init --stack ios-supabase

--stack declares the components a project of that kind has, including the ones that do not exist yet, so both halves are gated from the first commit. Detected components always win where the two disagree — the archetype only fills gaps. See archetypes/.

Projects that grow a stack after onboarding are the other half of the problem. sync and audit now re-run detection every time, and:

  • a stack the lacquer ships a profile for blocks — run lacquer adopt to record it, or add the path to [project].exclude to keep it unmanaged;
  • a stack no profile covers is reported on every run and gates nothing — that gap is the lacquer's, not the project's.

Detection used to run exactly once, at init, and never again. One repo bootstrapped as TypeScript-only during a spike, grew a Swift package the next day, and a year later still declared profiles = ["web"]: no hooks, no CI, 191 tests run by nothing at any gate. Another declared its iOS app but not the Supabase backend or the admin web app sitting beside it. Neither ever produced an error, because nothing ever asked.

Updating a project

lacquer audit    # see what drifted; exit 3 means sync would overwrite a local edit
lacquer sync     # apply; refuses to clobber a locally-modified managed unit
lacquer sync --force   # adopt the lacquer version over a local change

fleet uses the same blocking policy as audit, including still-present orphans, and names those orphans in its report. The uncalled-script report recognizes scripts named in rendered CLAUDE.md instructions as agent entry points (including their script helpers). Removing that documentation exposes an otherwise uncalled script again; other Markdown files do not grant exemptions. This checks wiring or documented use, not proof that a script executed.

Sync writes a .lacquer.lock baseline so audit can tell "the project edited this" from "the lacquer moved on". With an existing lock, sync also refuses untracked-conflict units: lacquer now ships a path (or managed region) where the project already has differing content, but no lock entry records ownership. Review it, then use --force to take lacquer's content or exclude/disown the unit. First sync, with no lock at all, still adopts existing content and prints which units it replaced. Identical content is accepted without a clobber warning; uncommitted asset changes remain protected even with --force.

Ratcheting measured improvements

After syncing a project, run lacquer ratchet --write and commit .lacquer.ratchet.toml to enroll it. The separate project-owned TOML file keeps metric decisions out of .lacquer.lock, which sync regenerates as content hashes:

[ratchet]
claude_md_project_lines = 120
unjustified_suppressions = 7

lacquer ratchet measures without writing. audit and fleet compare enrolled projects against their ceilings. A regression prints both numbers and gates with exit 4 (audit's destructive-drift exit 3 still takes precedence). An improvement prints ratchet: METRIC improved A → B. sync and ratchet --write save lower values automatically; neither raises an existing ceiling. Sync completes its normal rendering before measuring, and returns 4 if a metric still regressed. Without a baseline, audit reports how to enroll; it does not invent a ceiling.

To accept a specific increase, run:

lacquer ratchet --loosen unjustified_suppressions --reason "Legacy migration requires this suppression"

This records the current value and a per-metric string in [reasons]. Commit that diff for review. A historical reason never exempts later increases. Malformed baselines, unknown metrics, and empty loosening reasons are errors.

Metric definitions:

  • claude_md_project_lines: physical lines outside Lacquer managed-region markers in current root and profiled-component CLAUDE.md files, counting each destination once. Managed bodies and marker lines are excluded; blank lines outside regions count. AGENTS.md mirrors are not counted. Malformed region boundaries fail measurement. A Lacquer content increase cannot consume the project prose budget. This measures files on disk, not future rendering.
  • unjustified_suppressions: directive comments in tracked Swift, JavaScript, TypeScript, Vue, Svelte, CSS, and JSONC source. Counts swiftlint:disable, biome-ignore, and eslint-disable variants without a trailing justification. Biome uses :, ESLint uses --, and SwiftLint accepts //, dash, or colon separators. Empty punctuation is not a reason. Enable/end directives and quoted examples are excluded. Git symlink entries and submodules are not source blobs; tracked regular targets are counted once. Untracked files are excluded.

iOS coverage: a ratchet measured in CI

ios_uncovered_lines is the app target's executable minus covered lines, read from xcrun xccov view --report --json. It is an external metric: the Test job's Check Coverage step measures it, and audit, sync and plain ratchet never do. They print ratchet: ios_uncovered_lines is measured in CI (not checked here) for an enrolled project, and leave the recorded value alone. A project with several [[product]]s records one ios_uncovered_lines_<slug> per product.

Enroll from a report (a local test run's xcresult, or CI's coverage-report.json):

xcrun xccov view --report --json TestResults.xcresult > coverage-report.json
lacquer ratchet --write --coverage-report coverage-report.json --product -   # or the product slug

Until a project enrolls, the step prints ::warning::coverage gate not enrolled: … and passes. Once enrolled, with recorded value B and slack S = max(20, 0.5 % of the app target's executable lines):

Measured M Verdict
M > B + S fails, printing lacquer ratchet --loosen ios_uncovered_lines --to M --reason "…"
within B ± S passes and prints the band
M < B − S on a pull request fails, printing lacquer ratchet --accept ios_uncovered_lines=M: run it and commit
M < B − S on push warns with the same command

--accept only lowers the value; --loosen … --to N only raises it, needs a reason, and records it in [reasons]. Deleting tested code moves both counts equally, so it does not move the metric.

A pull request cannot loosen or delete its own ratchet

On a pull request the step runs the gate with --compare-base, which also reads .lacquer.ratchet.toml from the PR's base: the first parent of the merge commit the run checked out, which is the base commit that merge was built on (not the event's base.sha, which can move on afterwards). In CI's depth-1 checkout that commit is fetched from origin, alone and depth 1, with a two-minute limit. Every key the base records is compared, coverage keys for every product and the local metrics too:

Head, against the base Verdict
file deleted fails, naming every key
a local metric removed, or a coverage key removed while its product is still in .lacquer.toml fails, naming the key
a coverage key removed together with its [[product]], with a new reason in [reasons] passes, reported as a loosening to removed
a key raised (or a coverage key removed with its product) with no reason, or the base's unchanged reason fails, quoting a reused reason and printing the --loosen command
a key raised with a new reason passes, reported as a loosening (below)
a key lowered, or unchanged passes silently
no file at the base, or no coverage key in it (first enrollment) passes
the key this leg measures is not in the head's file, while the base gates coverage fails, printing lacquer ratchet --enroll <key> --to <this run's value> --reason "…"
a coverage key the base lacks, enrolled while the base gates coverage, with a new reason passes, reported as an enrollment (below)
the same, with no reason or the base's reason fails, printing the --enroll command

Changing the product count (#578). One product measures the lone ios_uncovered_lines (--product -); several measure one ios_uncovered_lines_<slug> per leg. So a PR that adds or drops a [[product]] measures a key its base never recorded. That used to read as "not enrolled", warn, and pass, leaving coverage un-gated. Now the leg fails and prints the value to enroll at: run lacquer ratchet --enroll <key> --to N --reason "<why the product count changed>" with that value, and commit. --enroll only adds a key that is not enrolled yet, needs a reason, and records it in [reasons]. The enrollment is reported, and the console harvest raises it as an unread inbox item. The lone key counts as live only while the project renders a single product, so after 1→2 the stale lone key can be removed with a new reason, like any key whose product is gone; while one product is rendered it cannot. A surviving product's slug key after 2→1 stays live, as before.

Anything that stops the compare (the checkout is not a merge commit, the base cannot be fetched or read, the base's file does not parse) fails the gate and says which: a base that could not be read is never treated as "not enrolled". On push, workflow_dispatch and schedule there is no base, and the gate behaves exactly as above. The rule applies to the step from the lacquer version a project's .lacquer.lock names, so a project gets it when it syncs.

A loosening writes loosened <key> <a>→<b>: <reason> to the job summary and leaves one machine-readable line, as a ::notice annotation on the check run:

lacquer-ratchet-loosened/v1 {"key":"ios_uncovered_lines","from":4312,"to":4400,"reason":"…"}
lacquer-ratchet-loosened/v1 {"key":"ios_uncovered_lines_widget","from":120,"removed":true,"reason":"…"}

That is the whole format: the prefix, one space, one JSON object on one line; removed replaces to for a coverage key removed with its product. An enrollment writes enrolled <key> at <n>: <reason> and its own line, lacquer-ratchet-enrolled/v1 {"key":"…","to":<n>,"reason":"…"}, as a ::notice titled lacquer ratchet enrolled. "New reason" means nonempty and different from the base's reason for that key. The raw log line carries it in workflow-command encoding (% as %25); the annotation's message is the text above. A change of shape will be /v2. The console's merged-PR harvest reads these annotations from the head commit of each merged PR that changed .lacquer.ratchet.toml and adds one unread inbox item per loosened key, so a loosening reaches the inbox and not only a reviewer reading the diff. A failed look is retried on the next harvest; a PR whose runs or annotations run past three pages of 100, or whose line is damaged, gets one unread "couldn't read ratchet loosenings … check by hand" item instead, so it cannot hold the harvest back. That item lists the valid lines it read before it stopped. Enrollments are harvested the same way, one unread item per key.

A merged PR that turned a gate off

Every gate a PR runs is advisory against that PR: on pull_request the workflow and the lacquer binary both come from its head, so it can edit its own ci.yml or move its lock to a release that predates a check. The console's merged-PR harvest is the layer it cannot reach. It runs the fleet's own lacquer after the merge, compares the merge commit with its first parent, and adds ONE unread inbox item for the merge, listing every finding with the PR's reason or no reason recorded, when the PR:

  • moved .lacquer.lock to an older lacquer, or removed it (an upgrade, the same version and a first sync are silent);
  • added or extended an exemption in .lacquer.toml: an exclude (including a wider path), a [baseline.relax] key, a dependabot_ignore (including new version ranges), a not_run_in_ci or covered_elsewhere target, a retired_secrets name, retired, a seed_once path or a biome_ignores pattern. "Extended" means a later until, or a dated entry made undated; removing or shortening one is silent. Entries are compared as whole entries, so a later-dated duplicate is caught wherever it sits;
  • edited or deleted a managed file outside a sync. A sync rewrites the managed files and the lock together, so after one every managed file hashes to what the merge's lock records. A file (or managed region) the base's lock lists that the PR changed is reported as edited when the merge's lock still lists it and it no longer hashes to that value. It is reported as deleted when it is gone and either the lock is unchanged or neither the lacquer version nor the manifest changed: a sync at the same version from the same manifest writes the same files. A file gone from a PR that moved the version or changed the manifest is not reported: the lock lists what a sync wrote to disk, and such a sync legitimately stops committing files (a skill directory the new .gitignore region ignores). Deleting an orphan the audit asked you to remove is reported: telling an orphan apart needs the locked release's render, which the harvest lacks. An edit that only moves uses: versions of the same action says so with the bumps, and is not reported at all when Dependabot opened the PR (the author comes from the merge list the harvest already reads, at no extra call); any other line changed in that file reports as usual.

Nothing is blocked. A legitimate downgrade, such as reverting a bad release, is surfaced with its reason, and that is its whole path. Record the reason in the lock itself, after syncing with the older release; the next sync drops it:

"downgrade": {"from": "1.78.1", "reason": "1.78.1 breaks the release job, #123"}

from is the version being left: a reason only counts for that downgrade. It lives in the lock, not the manifest, because the release being downgraded to may not know a new manifest key and would reject it.

A merge whose file list leaves the lock, the manifest and every managed path alone, and renames or deletes nothing, costs no extra call once the harvest has learned the managed paths of its base branch (it keeps them beside its cursor, per branch, from the last lock it read, and reads every merge into a branch until then). A rename always counts, because gh lists it by its new path only. Any other merge costs one commit read, two recursive tree reads, and one blob read per blob it needs: the lock at each side, the manifest at each side when it changed (or the merge's alone when a finding wants an exclusion's reason), and each managed file the PR changed (at both sides when edited, for the action-bump check). A hand edit of one workflow in a project with a manifest is seven calls.

A failed read is retried on the next harvest. A file that does not parse, or a merge commit gh does not name or GitHub does not have, gets one unread "couldn't check … for gates turned off …; check by hand" item beside whatever could be checked, and the harvest moves on. A tree too large for GitHub to list whole gets one such item for the repository, not one per merge.

Known limits. A rebase merge is compared as its last commit against that commit's parent, so a gate turned off by an earlier commit of the same PR is missed. Removing a [[component]] or a profile turns that stack's gates off with no finding: the lock and the files change exactly as a sync of the smaller manifest would change them. A hand edit whose lock hash was refreshed by hand too reads as a sync here; audit --ci fails it in CI (exit 8).

audit --ci --ci-event pull_request, which the shipped CI runs, adds a floor under this: it exits 9 on a downgrade with no recorded reason, and prints the reason when there is one. It only holds between two releases that both carry it, which is why the harvest, not the floor, is the gate. The PR-time floor holds only when the PR's lock names v1.79.0 or later, because CI runs the binary the PR head's lock names; a downgrade to an older release re-renders the workflow without the floor and is caught after the merge by the gate-off harvest.

An enrolled project's app target must also reach coverage_floor (80 %) from profiles/ios/baseline.toml. A project below it adds a dated relaxation:

[baseline.relax]
coverage = { until = "2027-01-31", reason = "enrolled at 31%; tracking issue in the project" }

A relaxed floor warns; an expired relaxation fails, like every other one. The gate also fails, before comparing anything, on a report that is not JSON, one without the app target, or one with zero executable lines. lacquer fleet shows each iOS project's enrollment.

Lacquer owns a separate CI ceiling in internal/shipped/claude_ratchet_test.go: the existing rootapp, multistack, duoapp, and spmpackage fixtures are rendered and their managed lines (including markers) must stay at or below pinned constants. Lower those constants in the same PR whenever the managed text shrinks, including work on #453.

Measurement errors fail closed. Stage source deletions before tightening; a missing tracked file is an error, not an improvement. Doctor exercises both metrics through the real audit path in a scratch project: establish a baseline, improve, sync, pass a clean audit, then prove each regression is rejected.

Regeneration drift and dead relaxations

audit and fleet report build-setting names present in a tracked .xcodeproj but absent after xcodegen generate, and the reverse, by target/configuration and project/configuration. A sibling project.yml opts the project into this comparison. It compares the current checkout, including local edits, in a scratch copy; it does not replace the original project. Value-only changes and effective xcconfig values are outside this presence check (the baseline checker still resolves its own settings).

XcodeGen and macOS plutil are required. Missing tools, unreadable input, failed generation, and unsupported inputs such as symlinks or generation hooks report NOT CHECKED; they never stand in for a clean comparison. Findings are report-only. The iOS Mac lint job runs lacquer audit --xcodegen-only before doctor, using the same pinned release, so CI can report this even though the Linux drift job lacks XcodeGen.

A [baseline.relax] entry whose baseline passes in every checked component is reported as a dead relaxation to remove, including strict concurrency implied by Swift 6. Like stale exclusions, this notice does not change the exit code. An unknown baseline or a key enforced only by CI (documentation, pgtap) is reported as relaxation NOT CHECKED, never assumed live or dead.

Simulator runtime: one pin, and a dated override

The iOS Test job and the rendered watch-test job test on one simulator runtime pin, config.DefaultRuntimePin (27.0 today). Both jobs render from it, so they cannot drift apart, and a pin bump moves both.

A project whose test host cannot run on the pin yet declares the runtime to test on and a date, rather than excluding ios-ci.yml (which freezes the file out of every later profile change):

[baseline.relax]
simulator_runtime = { major = "26", minor = "2", until = "2026-12-31", reason = "test host crash-loops on 27.0; tracking issue in the project" }

major and minor are digits only (minor defaults to 0); until and reason are required, as for every relaxation. Through until, the Test job and the watch job create their simulators on that runtime and print a warning saying so. If the runner does not have that runtime installed, the job fails and names it, because falling back to the newest runtime would test on the OS the override exists to avoid. After until, the override stops applying (both jobs return to the pin), the Lint job's relaxation step fails the run, and lacquer audit reports the entry EXPIRED and exits 4. An override that names the pin itself is reported as a dead relaxation. A project that declares no override renders a byte-identical ios-ci.yml.

Proving the checks work

Every serious defect found onboarding this fleet was the same shape: a check that ran, reported success, and verified nothing.

  • The editor hook called swiftlint lint --path FILE. --path had been removed from SwiftLint, so it errored on every write — and 2>/dev/null || true ate the message. It linted nothing for months and looked healthy doing it.
  • The DocC gate passed DOCC_FLAGS=--warnings-as-errors. That is a real build setting name and it is silently ignored; a deliberately broken symbol link still exited 0.
  • The drift job ran go build ./path from a non-module directory. It failed in every repo, on every PR, for a reason unrelated to drift.

None was caught by running the check. CI already tells you whether a check passes; it cannot tell you whether it could fail.

lacquer doctor

writes a known-bad fixture, runs the check against it, and asserts the check rejects it. A probe that passes on broken input is reported as broken, with the reason it exists. Exit 5 — distinct from audit's 3 (drift) and 4 (baseline), so a caller can tell "a check is broken" from "the project is wrong".

proving each check can fail:
  ok    SwiftLint rejects a warning-severity violation under --strict
  ok    SwiftLint is invoked in a form it still accepts
  ok    SwiftFormat --lint rejects unformatted code
  ok    missing_docs rejects an undocumented declaration
  ok    the formatter and the linter agree on member order

5/5 checks proved they can fail.

Probes live in profiles/<p>/doctor.toml, and fixtures are written to a scratch directory outside the project — a deliberately malformed file must never be committable. A missing tool is a finding, not a skip: a check whose binary is absent is not running. (The opposite of lacquer fix, where a missing tool skips — an unfixed file is still caught by CI, an unverified one is not.)

Adopting on an existing codebase

lacquer sync writes the configs; it does not touch your source. On a mature app that means the newly-synced .swiftlint.yml finds everything at once — 509 violations in one app here, 290 in another — and roughly two thirds of that is mechanical: member ordering, import sorting, trailing closures.

--fix pays that down before you ever look at it:

lacquer sync --fix     # sync, then run the profiles' autofixers
lacquer fix            # just the autofixers, any time

Measured on one app: 290 violations → 93, with the whole type_contents_order category (117) going to 1, because the synced .swiftformat enables organizeDeclarations in type mode with a --type-order mirroring .swiftlint.yml. What's left is judgement work — singletons, closure length, layering — which is the right thing to be left with.

--fix is opt-in on purpose. Plain sync only ever writes lacquer-managed files, and that contract is what makes lacquer audit able to say "you changed this, the lacquer didn't". --fix deliberately breaks it by rewriting project source, so it has to be asked for rather than discovered in a diff.

A fixer whose tool isn't installed is reported and skipped, never fatal — the opposite of a check, which must block when it can't run. An unfixed file is still caught by CI; a missing Homebrew formula shouldn't block adoption.

Third-party skills

lacquer sync distributes this repo's own skills (core/skills/, profiles/*/skills/) — that's a solved problem, versioned and drift-audited. Third-party skills are a different concern: one global install shared across every project, kept up to date by vercel-labs/skills, a real package manager for agent skills — not something lacquer reimplements. The packages this fleet actually pulls in (with source links, and which is suggested from which Swift import) are cataloged in Skills reference — currently dpearson2699/swift-ios-skills (Apple framework references) and HunterHillegas/mac-assed-mac-app-skill (AppKit/macOS conventions).

[project].skills in .lacquer.toml declares which packages this project needs, mixing lacquer's own skills and third-party ones uniformly:

skills = [
  "patrickserrano/lacquer@security-review",
  "dpearson2699/swift-ios-skills@healthkit",
  "dpearson2699/swift-ios-skills@storekit",
]

lacquer init seeds this list automatically by scanning the project's actual Swift imports (see internal/skillsuggest) — review and trim before running lacquer skills, which installs exactly what's declared, project-scoped, via npx skills add <source> -s <name> -p -y. Before invoking the installer, lacquer refuses entries whose destination contains tracked files (including clean files and local deletions), preserving project-owned skills while continuing with other entries. It also flags any installed skill no longer declared in the manifest (informational — nothing is auto-removed).

sync reads skills-lock.json offline and only reminds you about declared skills missing from that record. An unreadable or malformed lock file produces a warning.

This is deliberately a separate command from sync: sync stays fully offline and deterministic (its whole test suite depends on that), while skills is the one command that reaches the network.

Plugins (machine-level bootstrap)

Claude Code plugins install once at user scope and are shared across every project on a machine — a different shape of problem than [project].skills, which is per-project. core/bootstrap/plugins.toml lists the marketplaces and six plugins this fleet relies on — superpowers, codex (adversarial review via a real Codex subprocess), context7, figma, security-guidance, and telemetrydeck-analytics — cataloged with source links in Plugins catalog. lacquer plugins applies the manifest via claude plugin marketplace add / claude plugin install, both confirmed idempotent (an already-configured marketplace or already-installed plugin is a clean no-op). Only plugins actually enabled on the reference machine are listed — one installed-but-disabled there is a deliberate choice, not silently re-enabled on a fresh machine.

lacquer plugins

This is how a fresh machine picks up the same plugin set an existing one already has, closing the same "bootstrap a machine with none of this preconfigured" gap that [project].skills closes for per-project skills.

Installing

go install github.com/patrickserrano/lacquer/cmd/lacquer@latest

Or build from a checkout:

go build ./cmd/lacquer

Tagged releases (with prebuilt darwin/amd64 and darwin/arm64 binaries and changelogs) are published automatically on GitHub Releases whenever VERSION changes on main.

Versioning

VERSION is semver and machine-assigned — never edit it in a PR. CI rejects that, because two open PRs both bumping to the same number merge cleanly (both sides make the identical change) and produce two different contents sharing one version. Hand-picking also produced a permanent gap: v0.69.0 does not exist, because two bumps were missed and had to be corrected at once.

Every merge with a material change releases. version.yml derives the next version from conventional commits with svu (pinned), writes VERSION, pushes, and dispatches the release. release.yml tags v<VERSION> verbatim, so the file and the tag cannot drift. There is no path filter — VERSION versions the whole thing, CLI binary as much as synced content — and no loop, because a push made with GITHUB_TOKEN does not trigger workflows.

Commit Bump
feat: minor
fix: patch
feat!: / BREAKING CHANGE: major
docs:, chore:, ci:, refactor:, style:, test: no release

A README or docs-site edit ships nothing to a project, so it mints no version.

But prose under core/ or profiles/ is material — projects consume it, so it must release, and docs: would leave lacquer status telling them they're current while the content moved. Commit those as feat:/fix: even though they read like docs. CI enforces this rather than leaving it to discipline: a PR touching core/** or profiles/** with a non-releasing type is rejected.

Because the repo squash-merges, the PR title becomes the commit subject on main — so it is the PR title that CI validates, and the version is derived from. A non-conventional title would otherwise compute no bump and silently release nothing.

The PR body is load-bearing for the same reason, with a sharper edge: svu decides "breaking" by substring-searching the commit body, not by parsing a conventional footer — and that is the only check that reads the body at all (feat:/fix: are matched against the subject alone). So quoting the marker anywhere, even inside a code span or a table cell, declares a breaking change. #89 proposed 1.0.0 off a 0.72.0 base because its description reproduced the table above. CI now rejects a body carrying that marker unless the title also declares it with !.

The project sits in 0.x until a breaking change lands. feat!: / BREAKING CHANGE: is the standardized, explicit way to declare one, so it graduates the major — 0.x → 1.0.0, and 1.x → 2.0.0 after that. If you need to set a version by hand for any other reason, push it to main directly; the workflow leaves a hand-set value alone.

The version is stamped into each managed region's marker so lacquer status can report stamped-vs-latest. A project last synced before semver carries the old integer form (v70); that reads as 0.70.0 and is re-stamped on its next sync.

Codex instructions and guards

Projects enabling codex or antigravity receive dedicated AGENTS.md regions from core/AGENTS.core.md and profiles/*/AGENTS.*.md; these retain the safety rules and concrete checks without copying tool-specific procedures. Tests cap rendered core plus profile instructions at 10,000 bytes. Project-owned prose survives updates; existing locks migrate through the same region keys. CLAUDE.md sources and rendering are unchanged.

Codex projects also receive .codex/hooks.json, a Python guard and an activation README. Project and hook trust review is required; synchronization alone does not enable enforcement. See Codex instructions for coverage, limitations and regression commands.

Managed regions

A region is a block the lacquer owns inside a file the project also owns. Everything outside the markers is left untouched, which is what lets a project keep its own content in the same file.

File Marker key Comment form
CLAUDE.md, AGENTS.md (root) core <!-- ... -->
<component>/CLAUDE.md, <component>/AGENTS.md the profile name <!-- ... -->
.gitignore (root) gitignore # ...
.gitattributes (root) gitattributes # ...

The .gitignore region carries the ignore rules that must not be a per-project decision: App Store Connect keys (*.p8), signing material, Secrets.xcconfig, .env and friends — with the committed templates (Secrets.xcconfig.example, .env.example, .env.schema) re-included. It also names the third-party skill trees installed from [project].skills, one by one, so the skills the lacquer syncs into the same directories stay tracked and auditable. skills-lock.json is ignored as local installation state.

Everything else in a project's .gitignore — DerivedData/, build outputs, per-project junk — stays project-owned and survives every sync.

The .gitattributes region marks the agent-skill directories (.claude/skills, .codex/skills, .agents/skills, whichever [project].tools enables) as linguist-vendored. The lacquer ships ~207KB of Python skill tooling into each of them, so a Swift repo taking the ios profile picks up half a megabyte of code it did not write — and GitHub counted every byte of it toward the language bar. One fleet repo read as more Python than Swift. linguist-vendored suppresses the stats and collapses those paths in a diff without untracking them, so lacquer audit can still see drift in a synced skill.

This too is a region rather than a file, and for a sharper reason than .gitignore: three fleet repos already keep real content in .gitattributes — Git LFS filters, line-ending normalization, their own linguist overrides. Shipping a whole file would have broken LFS.

Shared lint configuration

Sync manages a runner-label region in .github/actionlint.yaml, using the same custom-label list tested against the rendered workflows. Existing labels, paths, config-variables, and comments survive. First adoption may normalize existing YAML formatting to create the block sequence; subsequent syncs replace only the marked region. audit, status, and the lock track that region.

Web projects can keep generated files out of Biome without excluding the shared biome.json. Declare extra negated files.includes patterns in .lacquer.toml:

[web]
biome_ignores = ["!**/payload-types.ts"]

Patterns apply relative to each web component's biome.json. With no entries, the rendered config is byte-identical to the default. The file remains wholly managed, so future shared lint improvements still arrive. Doctor checks the synced runner labels and declared ignores, with negative controls that reject missing values; these configuration checks need no external linter binary.

Project fragments

Three managed configs also take a project-owned fragment from .lacquer.toml, so a project that needs one more rule, or its own docs entry points, keeps the shared file instead of excluding it:

# Custom rules added to every iOS component's .swiftlint.yml.
[ios.swiftlint_custom_rules.sentry_import_confined]
name = "import Sentry confined"
regex = '^\s*import\s+Sentry\s*$'
message = "Report through ErrorReporter"
severity = "error"
excluded = ['.*Core/Reporting/ErrorReporter\.swift$']

# Per-path Biome overrides appended to biome.json's overrides, in Biome's shape.
[[web.biome_overrides]]
includes = ["core/**"]
[web.biome_overrides.linter.rules.style.noRestrictedImports]
level = "error"
options = { patterns = [{ group = ["node:*"], message = "core runs on Workers" }] }

# typedoc.json's entryPoints (default ["src/index.ts"]).
[web]
typedoc_entry_points = ["core/src/index.ts", "api/src/index.ts"]

Listing files means a new exported file is undocumented until someone adds it to the list, and the docs gate cannot fail for it. To cover new files by default, name directories and let TypeDoc walk them:

[web]
typedoc_entry_points = ["src/lib", "src/components"]
typedoc_entry_point_strategy = "expand"

A glob ("src/**/*.ts") also covers new files, with or without the strategy. Whenever a strategy or a glob is declared, the rendered typedoc.json also carries exclude: ["**/*.test.ts", "**/*.test.tsx", "**/*.spec.ts", "**/*.spec.tsx", "**/__tests__/**"], so test fixtures and helpers are not read as public surface. Without either key the file renders exactly as shipped, with no exclude. "expand" is the only strategy accepted: TypeDoc's "packages" mode does not apply the root's validation to each package, so an undocumented export passes it silently. A plain directory without the strategy is refused, because TypeDoc's default reads it as the directory's index file and fails when there is none.

A fragment may only add enforcement. A SwiftLint custom rule may not reuse the id of a rule the profile names or a doctor probe asserts, because a custom rule with a profile custom rule's id replaces it. A Biome override may set only includes and linter.rules, at level "error", with options only on the noRestricted* rules, and never on a rule the profile's biome.json sets or a doctor probe asserts. Entry points must stay inside the component. sync and audit refuse anything else, naming the key, so a pull request that tries it fails its drift check. A project that declares none of these keys renders all three files byte-identical to the shared default.

The fragments live in the manifest rather than in project-owned child configs on purpose. SwiftLint's child_config would accept disabled_rules, only_rules, excluded and any rule's own settings, each of which silently loosens the profile, and an edit to such a file would not wake the drift audit that every shipped CI runs only when a lacquer-managed path changes. .lacquer.toml always wakes it.

Doctor runs one check per fragment kind. Each plants a fragment that would switch a profile rule off against the synced file and fails if the guard accepts it, then confirms every declared fragment reached that file. The SwiftLint and Biome probes run against the rendered config, fragments included, so each still has to reject its own planted violation. TypeDoc's probes pass their own --entryPoints, so no manifest value changes what they test; one of them pins --entryPointStrategy expand over a directory and must report an undocumented export in a file nobody listed.

The web CI's Lint · Types · Test · Build job runs the pre-push docs hook's TypeDoc on the component's real code, in its Docs (TypeDoc) step, so an undocumented export no longer merges green when the hook did not run. It skips only under an active [baseline.relax] documentation, exactly as the hook does.

Docs

The opt-in rule eval suite compares Claude's behavior with and without rendered core plus each shipped profile’s CLAUDE.md instructions and six skill routes. Its offline regression tests run with go test ./...; paid model runs require an explicit local invocation.

docs/plans/ holds the design and build plans. The design doc (docs/plans/2026-06-15-lacquer-design.md) carries an "Implementation status" note distinguishing what's built from what's still aspirational.

About

Built by Patrick Serrano, an iOS engineer building apps under a small independent studio. lacquer is the internal tooling that keeps engineering practice consistent across the whole fleet.