Command reference
| Command | Does |
|---|---|
lacquer init [--stack S] |
Detect components, write a .lacquer.toml stub (and a docs/brief.md stub). --list-stacks prints 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 |
Record stacks that appeared since init into .lacquer.toml — the fix for audit’s exit 6. |
lacquer sync [--force] [--fix] |
Render core + per-profile content into the project (managed regions + whole-file assets). --fix runs the profiles’ autofixers afterwards. |
lacquer skills |
Install [project].skills entries via the skills CLI. See Third-party skills. |
lacquer plugins |
Install core/bootstrap/plugins.toml (machine-level Claude Code plugins) via claude plugin. See Plugins. |
lacquer doctor [--profile P] |
Prove each check can actually fail; exit 5 if one cannot. --profile limits it to one stack’s checks, for a runner that has only that toolchain. |
lacquer fix |
Run the profiles’ autofixers (formatters, lint --fix) over the project. |
lacquer status |
Show each region’s stamped version vs the lacquer’s latest. |
lacquer audit |
Classify project drift and check the project baseline. Exit 3 if a sync would clobber a local change, 4 on a baseline violation or an expired [project].exclude, dependabot_ignore or [[project.not_run_in_ci]], 6 if a stack on disk is undeclared (usable as a CI gate). It also reports, without ever changing the exit code, any rendered agent, skill or command that Claude Code would skip silently (frontmatter missing or not on line 1, an agent with no name or a name containing :), and runs claude plugin validate --strict over them when the claude CLI is installed (otherwise it says “not checked”). |
lacquer fleet --roster F [--json] |
Audit every project in a roster; exit 4 if any would fail its own audit. --json emits a snapshot. |
lacquer fleet diff A.json B.json |
What changed between two snapshots; exit 4 on a regression. |
lacquer protection [--repo O/N] [--branch B] [--roster F] |
Compare what branch protection requires against what CI can post. GitHub counts a skipped check as satisfying a required one, so a repo passes only if it requires the always-running CI OK aggregate — or some other context posted by a job nothing can skip. Reaches the GitHub API through gh, so it is opt-in and separate from audit. Exit 4 on a finding; exit 7 if a repository could not be checked — never reported as a pass. |
lacquer wait pr <N> [--repo O/N] [--timeout D] [--interval D] [--json] [--inbox F] [--no-inbox] |
Block, in one process, until every check on PR N is terminal, then print each check’s name, conclusion and duration. The sanctioned way to wait for CI. It sleeps between polls, so waiting costs no model tokens: run it in the background and you are woken once, when it returns. Defaults: --timeout 20m, --interval 15s; --repo is inferred by gh from the checkout. Four outcomes, each its own exit code, never conflated — see Waiting for CI. Exits 2, 3 and 4 also put an ACTION in the operator’s inbox. |
lacquer ci-round begin <N> [--reason TEXT] [--review TEXT] [--sha SHA] [--repo O/N] [--inbox F] [--manifest-ref REF] |
Ask for one of the rounds of CI an agent gets on PR N (default 2, [project].ci_round_cap), before the push it covers. The count is recorded on the PR, so a session ending, or a new one picking the PR up, changes nothing. Round 2 must name a failing check or a review request; a third attempt is refused with a report, never a failure. See Capping CI rounds. |
lacquer ci-round reset <N> --reason TEXT |
Explicitly authorize a fresh budget, retaining the ledger audit trail. |
lacquer ci-round status <N> |
Record unknown heads as unrecorded pushes or neutral GitHub updates; show rounds by kind and rounds left on PR N. Exit 10 if exhausted, else 0. |
lacquer console [--roster F] [--inbox F] |
One screen, with no flags or config: the inbox’s open ACTION/UNREAD entries, then every live session on the machine (name, kind, status, project, cwd, age) read from claude agents --json (10 s limit). If claude is missing or fails it prints sessions: unavailable — <reason>, never an empty list; a healthy zero prints sessions: none running. --roster/$LACQUER_ROSTER adds fleet truth and open PRs and maps each session to its project by cwd. The inbox is --inbox, else $LACQUER_INBOX, else $XDG_STATE_HOME/lacquer/inbox.jsonl (~/.local/state/lacquer/inbox.jsonl), created with its directory on the first write; a default file that does not exist yet reads as empty, an explicit one that does not exist is reported unavailable. ci-round raises its exhaustion ACTION in the same file, and so does wait pr (exits 2, 3, 4). With a roster, every run also harvests PR merges: one gh pr list --state merged per roster repo (20 s limit), one UNREAD <owner/repo>#<N> merged: <title> per merge since the repo’s cursor in merge-cursor.json beside the inbox, deduped by PR URL, and one harvest runs at a time (a lock beside the cursor; a second one is skipped with a note), so two consoles add each merge once. A repo’s first look sets its cursor to now and backfills nothing; a gh failure is listed under unavailable, never read as no merges; without a roster it says merges are not being recorded. A background agent going idle is recorded by a Claude Code Stop hook, lacquer console inbox hook stop, that the iOS, web and supabase profiles ship in .claude/settings.json (timeout 10 s; marketing ships no Claude settings; a project with several of these profiles gets one composed file with the hook once, and a conflicting top-level key other than hooks fails the sync naming both profiles): it reads the hook JSON on stdin and, only when $CLAUDE_JOB_DIR is set (a claude --bg session; interactive sessions never write), adds one UNREAD <session name> is idle in <project>: <first line of the last message> with ref session:<id> and the full message (capped at 2 KB) plus the cwd as body. The name comes from claude agents --json (else the short id), the project from the roster (else the cwd’s base name). At most one open entry per session: while it is open a further idle adds nothing, and once resolved the next one adds a new entry. It skips when stop_hook_active is true or there is no last message, always exits 0 (problems are warnings on stderr), and gives up after 5 s. --sessions is optional: it names the dispatch records watch --relaunch, watch --live and kill need (they hold the tmux pane, daemon id and worktree that claude agents does not report), and plain watch without it lists the live sessions. |
lacquer console … dispatch / dispatch-role / watch / kill |
Start, check, relaunch, or stop work on a project or a named role. --mode bg runs claude --bg in a new git worktree and branch under <repo>/.claude/worktrees/, and launches nothing if one cannot be made. --mode tmux starts a detached tmux session in the checkout itself, which it edits directly; attach with tmux attach -t <name>, and a session already running under that name is left alone. --worktree <path> runs the session, in either mode, in an existing worktree instead: for the worktree a PM created and named in an IC’s brief. It must be a registered worktree of the project’s repository (and, for bg, not the checkout itself), or nothing launches; lacquer never creates or removes it, adds .metadata_never_index before launch (ignored through global excludes), and records it like one it made, so a relaunch resumes in it and kill keeps it. --branch <name> (bg only) names the branch of the worktree bg creates, under .claude/worktrees/ with / flattened to -, instead of dispatch/<id>: for a branch a PM chose. It is refused if the branch or directory already exists (pass --worktree for that), and together with --worktree. Every console flag works on either side of the subcommand, with the same meaning (watch --relaunch is --relaunch watch), and among a dispatch task’s words, so a trailing --dry-run is a dry run; a task word that starts with - goes after --, which ends the flags (dispatch <project> -- <task>). An unknown flag, or one the subcommand has no use for (--dry-run with kill), is an error; --roster, --roles, --sessions and --inbox are accepted by every subcommand. Both modes pass --dangerously-skip-permissions with the sandbox off, and neither needs a terminal, so an agent can dispatch. With --sessions, every launch attempt is recorded, a failed one included, and watch reports a failed launch as failed. watch --relaunch puts the relaunched session’s record in place of the dead one (a bg session resumes in its recorded worktree), and stops retrying a record after 3 failed launches in a row, leaving it for you. See lacquer help for the flag combinations each takes. |
lacquer decisions [<owner/name> | --fleet] [--fleet-repo O/N] |
Print the operator’s recorded decisions, oldest first: each comment on the repository’s one open decisions-labelled issue, with its date, the link to the inbox item it came from, the operator’s words exactly as typed, and the basis (the measurement it was decided against) when one was given. With no argument the repository is the current checkout’s origin; --fleet reads the fleet repository (--fleet-repo, else $LACQUER_FLEET_REPO, else fleet_repo in the user config file; no built-in default). Read-only (gh issue list, gh issue view). No decisions recorded prints no decisions recorded for <repo> and exits 0; gh could not answer (or two open decisions issues exist) prints the reason on stderr and exits 1, so the two never look alike; a repository whose only decisions issue is closed says so (reopen it) and exits 1 rather than reading as none recorded. --fleet-repo without --fleet is refused. Control characters in a comment are shown as ^X, not sent to the terminal. Managed CLAUDE.md/AGENTS.md tell agents to run it before briefing or starting work. See Recorded decisions. |
lacquer version |
Print labeled content and build versions, plus the resolved content root path. |
lacquer help (or --help/-h) prints usage, including the full console
flag surface this table abbreviates.
Recorded decisions: lacquer decisions
Section titled “Recorded decisions: lacquer 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 where nobody could grep them. The decisions log keeps them in GitHub, in their own words, next to the work they govern. There is nothing to commit and no rendered file to go stale.
Recording. In the inbox detail popup (lacquer console inbox watch, Enter on
an item), D opens three prompts. Esc at any of them abandons the decision and
posts nothing.
- The words, typed and kept exactly as typed: not trimmed, reflowed or
summarised. (
rreply is unchanged: it goes to the overseer, and is never recorded.) - A basis, optional (Enter skips it): the measurement or reason the decision was made against, and when. Decisions expire into facts; a decision made against “79% of devices are on iOS 26, June 2026” should say so, so a later reader can tell whether the basis still holds.
- A scope, with the target of each shown before anything is posted:
rthis repo,ffleet-wide. This repo is the repository the item’s ref names, when that is an issue or PR in the roster or--extra-repos, else the item’sprojectmapped through the roster; if neither resolves it says why and still offers fleet. Fleet-wide is the fleet repository ($LACQUER_FLEET_REPO, or--fleet-repo; no built-in default), so a decision that spans repositories lives in one place instead of a copy in each project’s.
The decision is one comment on the repository’s one open issue labelled
decisions. First use creates what is missing: the decisions label, then an
issue titled Decisions with a short fixed body saying what it is, then the
comment. Two or more open decisions issues are refused, and named, never
guessed between. Every write, the label and the issue included, goes through the
same gate as a reply’s comment: a repository outside the roster and the extra
repositories is refused, the fleet repository too, and each write happens only on
your keypress. Nothing is sent to the overseer; the popup confirms with the
comment’s URL, and on a failure says what was done and what was not.
The comment is fixed in form, and holds nothing agent-written (no title, no body from the entry):
**Decision** — 2026-09-25T04:10:00Z · from https://github.com/o/r/issues/5
```text<your words, exactly as typed>```
Basis:
```text<the basis, if one was given>```The words are in a code fence, not a quote, because a fence is the one markdown
form that shows a string as written: in a quote #123 writes a cross-reference into
another issue and @name notifies someone. The fence is longer than any run of
backticks in the words, so nothing can close it early.
Reading. lacquer decisions (this repo, from origin),
lacquer decisions <owner/name>, or lacquer decisions --fleet. Agents are told,
in the managed CLAUDE.md/AGENTS.md region, to run the first and the last before
briefing or starting work.
User config file
Section titled “User config file”lacquer decisions --fleet needs the fleet repository, and a background agent
session does not inherit the environment that $LACQUER_FLEET_REPO lives in. So
the repository can also come from a per-user config file, read in this order, the
first one set winning:
--fleet-repo owner/name$LACQUER_FLEET_REPOfleet_repoin$XDG_CONFIG_HOME/lacquer/config.toml, or~/.config/lacquer/config.tomlwhen$XDG_CONFIG_HOMEis unset or not an absolute path
fleet_repo = "owner/name"There is no built-in default (this repository is public), so with none of the
three set the command exits 1 and names all three, with the exact path it looked
at. A missing file is not an error: it falls through to that message. A file
that exists but is not valid TOML, has any key other than fleet_repo, or sets a
value that is not owner/name is an error naming the file, never ignored, so a
typo cannot read as “not configured”. A higher source wins without the file being
read, so a broken file never blocks a command that was told the repository
explicitly.
Seeing a diff or a plan from the inbox
Section titled “Seeing a diff or a plan from the inbox”In the inbox detail popup, v opens what the item’s ref names, read-only and
sanitised (control characters show as ^X), fetched only on that keypress and never
on a refresh:
- A pull request (
owner/repo#n, or a/pull/URL) in the roster or--extra-repos: the changed files and the diff, coloured, withj/kto move a cursor,n/pbetween files. A repository outside the roster is refused beforeghruns, and aghfailure readscouldn't load the diff: <reason>, never an empty diff.ron a+,-or context line is not this line: your words are posted to the PR as one plain comment headed withpath:line(the new-file line, or the old-file line for a removed one, and it says which), through the same gated write as a reply, and typed to the overseer as[inbox <id>] <path:line> <text>. It is not a review thread. The comment and the overseer send are independent, and the result says which happened. - A
file:ref (file:/abs/pathorfile:~/path): the file as the agent will act on it. Only a regular file under~/Developeror~/.claude/plans(compared by directory identity, so case and Unicode spelling do not matter, and a symlink out is refused as its target is); everything else says “outside the plan roots”. Under a root, a dot-directory or dot-file (.ssh,.env,.git) is refused, except.worktreesand.claude/worktrees, where briefs live. At most 1 MB is shown, with a marker when it is longer.
Each diff fetched is also written for the phone mirror to
<inbox dir>/diffs/<owner>_<repo>_<n>_<headsha>.diff: the sanitised diff and nothing
else (no title or body), at most 256 KB, ending in [lacquer: diff truncated at 256 KB]
when cut, one file per PR (the newest head). The format is documented in the
inboxwatch package doc.
Waiting for CI: lacquer wait pr
Section titled “Waiting for CI: lacquer wait pr”Waiting for CI is the most common thing an agent does, and hand-rolled waiters
keep getting it wrong. One reported FINAL and exited 0 while both test jobs
were still running: it branched on (.conclusion // "PENDING"), and jq’s //
substitutes for null, not for the empty string a check in flight reports.
gh pr checks --watch --fail-fast is no substitute: it exits 0 even when checks
fail. lacquer wait pr puts the predicate in one tested place.
lacquer wait pr 425 # blocks; prints every check at the endlacquer wait pr 425 --timeout 45m --json| Exit | Outcome | Meaning |
|---|---|---|
0 |
passed | Every check is terminal and none failed. Skipped checks are named on a skipped: line. |
1 |
failed | At least one check failed (or was cancelled, timed out, needs action, or concluded something unrecognised). Each is named. A failure is decisive: if the ceiling hit with other checks still running it is still 1, and the running ones are listed as abandoned. |
2 |
timed out | --timeout hit while a check was still running and none had failed. The running checks are named. Not a failure and not a pass: the result is unknown. |
3 |
no checks | The PR reports no checks, so nothing tested it. Never a pass. |
4 |
could not wait | gh is missing or kept failing, the PR is closed or merged, or the usage was wrong. The PR’s state is unknown. |
Choices worth knowing:
- Empty is not green. “No check is non-terminal” is trivially true of zero
checks. An empty rollup is re-checked for
--empty-grace(default 30s), because workflows register a moment after a PR opens; if checks appear they are judged normally, and if none do the answer is exit 3. - Skipped exits 0, loudly. A skipped job did not run, and a skipped required
job is how a PR looks green untested, so every skipped check is named in the
output. If every check was skipped it says
PASSED, BUT NOTHING RAN. Read that line before treating exit 0 as “tested”. - Both check shapes are read.
CheckRunentries carrystatusandconclusion; legacy commit statuses (StatusContext) carrystateand nostatus. A pending commit status is still running. - All terminal must hold for two polls. The first reading can predate a slower workflow registering; a check that appears in between is not missed.
- Only the latest run of a check counts. The rollup lists every workflow run on
the commit, so editing a PR while CI runs leaves a
cancelledcheck from the run the concurrency group killed beside the latest run’s real one. For the same workflow and check name across different runs, only the newest run is judged, as on GitHub’s checks tab; the ignored entries are listed (ignored, superseded by a newer run) so nothing is hidden. Two same-named jobs in one run, or entries with no run id, are never collapsed. - A known failure beats a timeout. A failure is a fact and CI cannot become
green from it, whereas a timeout means “not known yet”. If the ceiling hits with
a failure and some checks still running, the exit is
1; the running checks are still listed, marked as abandoned, and their results no longer matter. A caller deciding whether to spend another CI round can key on the exit code alone. - It writes the inbox for you. A wait that ends timed out (2), untested (3) or
unable to run (4) raises an inbox ACTION (
<owner/repo>#<N>: ..., ref the PR’s URL, body the head sha and the reason), so a stuck PR reaches the operator without anyone remembering to say so. Nothing is written for0, nor for1(a failed check: the author is already fixing it, and the operator’s stuck view raises a PR left failing), nor when the PR is merged or closed or the wait was interrupted. The same open entry for the same PR and head commit is never added twice. The file is--inbox, else$LACQUER_INBOX, else the default under$XDG_STATE_HOME, as forconsole;--no-inboxturns the write off. If the write fails it printsWARNING ... NOT writtenon stderr and the exit code is unchanged. - A new head commit mid-wait means new checks. The old commit’s results are
discarded (and the output says
head moved a -> b), and the wait continues on the new commit. The ceiling is not reset:--timeoutbounds the whole wait. - A PR that is closed or merged ends the wait with exit 4; its checks no longer decide anything.
ghfailures are retried, five in a row before the wait gives up with exit 4 and gh’s own message. A failed call is never read as “no checks” or as success, and aghresponse with nostatusCheckRollupis an error, not an empty list.
Manifest shape
Section titled “Manifest shape”A project opts in via .lacquer.toml at its root:
[project]name = "my-app"project_name = "MyApp"scheme = "MyApp"bundle_id = "com.example.myapp"asc_app_id = "0000000000"xcodeproj = "MyApp.xcodeproj"swift_version = "6.0"github_org = "my-org"tools = []exclude = []seed_once = []skills = ["dpearson2699/swift-ios-skills@healthkit"]
[[component]]path = "."profiles = ["ios"]core applies to every project regardless of [[component]] entries. A
component detected as an unshipped stack (e.g. Rust/Go) is recorded with an
empty profile list and a notice — it doesn’t break sync.
seed_once lists files the lacquer ships whose content belongs to the
project, by exact repository-relative path: seed_once = ["ios/Secrets.xcconfig.example"] for a template that names this app’s own keys.
Sync writes such a file when it is absent and never overwrites it afterwards,
even with --force; audit and the lock do not track it, so editing it is not
drift. Use it instead of exclude for these files: an exclusion stops the file
being seeded at all. An entry the lacquer ships nothing at is reported by
lacquer audit as stale. A path both excluded and seed-once is rejected at load.
optional_workflows opts into a workflow the lacquer ships but does not install
by default, named without its .yml:
optional_workflows = ["some-workflow"]The profiles currently ship none, so this installs nothing today and a name
with no file behind it is an error rather than a silent no-op. The mechanism is
kept because the reason it exists has not gone away: a workflow needing
credentials nobody has doesn’t fail loudly, it fails daily and quietly.
testflight-feedback was the case that produced it — it wanted
APP_STORE_CONNECT_FEEDBACK_ISSUER_ID and two siblings, no project in this fleet
had them, and it was red on every scheduled run in every repo that received it.
A scheduled job nobody can satisfy is worse than a missing feature: it trains
people to ignore red. It has since been removed outright rather than left
default-off, because in three years nothing opted in.
A name with no matching file is an error, not a silent no-op.
Retired projects
Section titled “Retired projects”retired marks a project that is no longer worth investing in but is not being
deleted:
[project]retired = { since = "2026-08-18", reason = "not a viable app" }Retired means stop the spend, stay consistent. sync keeps shipping
everything that holds the repo to the fleet’s shape — PR-triggered CI, lint and
format configs, CLAUDE.md / AGENTS.md, .gitignore, .gitattributes, hooks,
skills — so the project still audits clean and can be picked back up. It stops
shipping everything that costs money or attention on a schedule:
| Dropped | Kept |
|---|---|
Any workflow whose on: block has a schedule: trigger (ios-cleanup-ci.yml, supabase-health.yml, and ios-testflight-feedback.yml where opted in) |
*-ci.yml, web-dependency-review.yml, web-env-validation.yml, ios-release.yml |
.github/dependabot.yml |
every non-workflow asset |
“Is scheduled” is read from each workflow’s content, not from a list of
filenames — a filename list silently misses the next scheduled workflow someone
adds. A workflow declaring workflow_dispatch: beside schedule: is still
dropped: the dispatch entry is a convenience, the cron is what runs unattended.
Both fields are required and a malformed entry fails the load. retired = true
records that someone retired the project and not why, and six months later why is
the only thing anyone wants to know. Unlike [baseline.relax] and the dated form
of exclude, retirement takes no until — it is not debt with a term, and an
expiry would either be rubber-stamped forever or quietly turn a dead project’s
cron jobs back on.
lacquer status and lacquer audit both lead with the retirement and its date,
and audit still exits 0: the dropped assets stop being managed units, so they
read as neither drift nor missing. Nothing is deleted — files already in the repo
stay until someone removes them by hand.
skills entries are "<owner>/<repo>@<skill-name>" strings, installed by
lacquer skills — see Third-party
skills.
Multiple products from one repo
Section titled “Multiple products from one repo”A repo that ships more than one App Store app — a paid app and a free or lite
sibling built from the same source — declares each as a [[product]]:
[[product]]name = "MyApp"scheme = "MyApp"bundle_id = "com.example.myapp"asc_app_id = "0000000000"tag_prefix = "myapp"
[[product]]name = "MyApp Lite"scheme = "MyAppLite"bundle_id = "com.example.myapp.lite"asc_app_id = "1111111111"tag_prefix = "myapplite"Declaring none is the normal case: [project] is then treated as the single
product, and every tag releases it.
tag_prefix is required once a project declares more than one product — a blank
prefix means “every tag releases this”, which is right with one product and
incoherent with two. It also derives the release workflow’s push-tag filter:
a repo whose products are prefixed mike-v and mikefree-v triggers on those
patterns, not on v*. A project declaring no products keeps the historical
v*.
tag_prefix decides which product a tag releases. myapp-v2.1.0 releases
MyApp and leaves the Lite app alone. One tag must release exactly one
product. An App Store version train closes permanently once its version
reaches READY_FOR_SALE, so a tag that fanned out to both apps would push the
already-shipped one at a closed train and fail with error 90186 — every time,
for the life of that version.
A tag matching no product’s prefix fails the release rather than guessing;
guessing signs a product with another app’s credentials. A product with a blank
tag_prefix matches any tag, which is what makes the single-product case work
unchanged.
Per-product CI targets
Section titled “Per-product CI targets”The iOS CI workflow builds and tests every declared product — one
Build (Release) leg and one Test leg each, with fail-fast: false so one
product failing does not cancel the other’s run. Four optional fields describe
the test leg:
[[product]]name = "MyApp Lite"scheme = "MyAppLite"bundle_id = "com.example.myapp.lite"asc_app_id = "1111111111"tag_prefix = "myapplite"test_target = "MyAppLiteTests" # defaults to "<scheme>Tests"ui_test_target = "" # blank = no UI tests for this variantextra_test_targets = ["CoreKitTests"] # local package suites to run as wellapp_target = "MyApp.app" # coverage target; defaults to "<scheme>.app"Target defaults use name only when scheme is empty. Explicit
test_target and app_target values override these defaults.
extra_test_targets adds -only-testing: selectors for suites the app’s own
bundle does not contain — typically a local Swift package’s test target, which
is otherwise run by nothing while xcodebuild still exits 0. Declaring any turns
on a CI step that reads the result bundle back and fails the job for a selector
that matched no tests; blank and repeated entries are rejected at load, because
both render a selector that runs nothing. The pre-commit Swift Tests hook runs
the same extras.
audit checks each selector against the targets that exist. A native test
target in project.pbxproj counts, and so does a .testTarget in the
Package.swift of a local package the project references
(XCLocalSwiftPackageReference, resolved from the .xcodeproj’s directory). A
selector found in neither place is reported as naming a target that does not
exist. If a referenced package can’t be read, the selector is reported as
could not check, not as missing, and the report gives the reason. That happens
when the manifest is absent or declares a test target whose name is computed
rather than written as a string.
Package suites are part of the “no selector covers it” report too, with one
difference: a package suite can also be run by swift test in its package, so
it is reported only if no workflow a pull request starts runs it either. The
audit recognises swift test in the package (by --package-path, a step’s or a
job’s working-directory, or a cd), an xcodebuild test or flowdeck test
that selects the suite or runs a scheme testing it (including the scheme Xcode
generates for a package), and the same commands inside a script in the
repository that the step runs. It does not recognise swift build --build-tests, which compiles the suite and runs none of it. A suite run some
other way can be declared in [[project.covered_elsewhere]], and a suite
deliberately run in no CI job in [[project.not_run_in_ci]], both below. If the
package can’t be read, or a workflow that might run the suite can’t be (a
${{ matrix }} directory, a scheme that isn’t committed), the suite is
reported as could not check rather than as running nowhere.
app_target is declared, not derived: a scheme and the product it builds
genuinely differ in real projects, and a wrong target selects no coverage row at
all — which reports 0.0% rather than failing.
Each leg’s CI simulator and test-results artifact are scoped by a slug derived from the product name, so two legs on one runner cannot delete each other’s simulator or collide on an artifact name. Two products whose names reduce to the same slug are rejected at load.
A project declaring no products renders the CI workflow byte-for-byte as it did before products existed — the matrix machinery expands to nothing.
A target the managed workflow cannot run
Section titled “A target the managed workflow cannot run”audit reports every test target in the Xcode project that no selector names.
Some targets are unreachable from the iOS test leg by construction — a watchOS
bundle is a testable of a different scheme, and the workflow carries one
platform=iOS Simulator destination — so the only way to run them today is a
project-owned workflow. Declare that, rather than leaving the audit wrong about
it:
[[project.covered_elsewhere]]target = "AlphaAppWatchApp Watch AppTests"workflow = ".github/workflows/watch-ci.yml"job = "watch-tests"reason = "watchOS bundle run by watch-ci.yml job watch-tests on a watch simulator"job is required, and reason must name both the workflow file and the job. A
declaration whose workflow file does not exist fails audit (exit 4).
The declaration is verified on every audit, never taken on trust: the workflow
must exist, must not be one the lacquer writes, must have the named job, must
name the target outside a comment, must contain a test invocation, and must be
triggered by a code change.
Anything short of that and the target is reported again with the failed check
printed beside it. There is no until — the declaration expires by ceasing to
verify, not on a date, and a declaration naming a target the project no longer
has is reported as stale.
watchOS test bundles
Section titled “watchOS test bundles”A watchOS unit-test bundle can be run by the managed workflow:
[project.watch_tests] (or [product.watch_tests]) renders a Watch Tests job
on a watch simulator. audit finds watch bundles that nothing runs, reading the
tracked project.pbxproj or, when none is tracked, the XcodeGen project.yml
beside the .xcodeproj, and prints the exact table to add, with the scheme taken
from the scheme that tests the bundle. This is the one row of the uncovered
report that blocks: it warns, printing the date, until the grace period ends,
then audit exits 4 until the bundle is declared, verified as
covered_elsewhere, or recorded in not_run_in_ci. A watch app with no test
bundle at all gets a notice and never blocks. A project with no watch app gets
nothing.
A suite deliberately not run in CI
Section titled “A suite deliberately not run in CI”Some suites are run on purpose somewhere CI can’t reach. bravoapp’s
BravoAppCoreTests needs on-device models and is written to fail, not skip,
without them, so CI builds it and never runs it. The audit is right that nothing
in CI runs it, and none of its suggested fixes applies. Say so, with a reason and
a date:
[[project.not_run_in_ci]]target = "BravoAppCoreTests"reason = "needs on-device models; built in CI, run on device before release"until = "2026-12-31"All three fields are required. until is YYYY-MM-DD and covers the whole of
that day. It works for native targets and local-package suites alike, and it
needs [project].xcodeproj, because that is where the audit reads test targets
from. A target can’t be declared in both this and covered_elsewhere, since only
one of them can be true.
While the declaration is in term, the suite leaves the “no selector covers” list and is printed on a line of its own, so it stays visible:
deliberately not run in CI: BravoAppCoreTests — needs on-device models; built in CI, run on device before release (until 2026-12-31)Past until, it expires. The suite goes back in the report, the expiry is
named, and audit exits 4, the same as an expired dependabot_ignore. This is
the divergence from covered_elsewhere, which has no date because the project
holds no remedy for it. Here the project does hold the remedies: make the suite
runnable in CI, delete it, or review the reason and set a new date.
A declaration is reported as stale when it no longer describes a gap: the
target doesn’t exist, or something now runs it (a selector, a verified
covered_elsewhere, or a workflow the audit sees running the suite). Stale
declarations are reported but don’t gate. Remove them.
Release-time secrets
Section titled “Release-time secrets”A project that needs real values at release — monetization SDK keys, ad unit
IDs, an analytics key, a crash-reporting DSN — maps each xcconfig key to the
GitHub secret holding it. A single-app project (no [[product]] block) declares
them under [project], where they fold into the product the manifest
synthesises — the same fallback scheme, bundle_id, asc_app_id and
extra_test_targets have:
[project]name = "MyApp"scheme = "MyApp"bundle_id = "com.example.myapp"asc_app_id = "1111111110"secrets = { REVENUECAT_API_KEY = "REVENUECAT_API_KEY", SENTRY_DSN = "SENTRY_DSN" }secret_formats = { REVENUECAT_API_KEY = "appl_*", SENTRY_DSN = "https://*@*/*" }A project with several products declares them per product, since each app’s
keys are its own. Setting secrets, secrets_file or secret_formats under
[project] alongside any [[product]] block is rejected rather than merged,
because which product they belong to would have to be guessed:
[[product]]name = "MyApp Lite"scheme = "MyAppLite"bundle_id = "com.example.myapp.lite"asc_app_id = "1111111111"tag_prefix = "myapplite"secrets_file = "Config/Monetization.xcconfig" # optional, defaults to Secrets.xcconfigsecrets = { REVENUECAT_API_KEY = "LITE_REVENUECAT_KEY", ADMOB_APP_ID = "LITE_ADMOB_APP_ID" }secret_formats = { REVENUECAT_API_KEY = "appl_*", ADMOB_APP_ID = "ca-app-pub-*~*" }secret_formats optionally constrains a value’s shape, as a shell glob
checked at release time. Non-empty is not the same as correct: the two ways
these keys actually go wrong — pasting another app’s key, or leaving Google’s
public test AdMob ID in place — both produce a perfectly non-empty value that
builds, signs, uploads and passes review, then serves the wrong ads to real
users. A mismatch fails the release without echoing the value. A pattern may
use letters, digits and _ ~ . : / * ? @ -; anything else (quotes, |, (,
&, spaces) is rejected at load, because the pattern is used unquoted in a
shell case. The release’s writer re-checks the same set (it once
omitted @, so a https://*@*/* pattern loaded and then failed every release).
secrets_file and secrets_example are relative to the component root. A
value that repeats the component’s own path (ios/App/Secrets.xcconfig under an
ios/ component, which names ios/ios/App/…) is rejected at load with the
corrected value, when the tree shows it: the folder exists from the repository
root and not from the component root.
secrets_example names the committed template the release seeds the file from;
it defaults to <secrets_file>.example. With keys declared and no template
found, the release fails and names where it looked: a keys-only file leaves
every other key undefined and gives the placeholder check nothing to compare
against. A declared secrets_example is the only template consulted, and the
watch-test job seeds the same file from it.
The manifest holds the secret’s name; the value stays in GitHub. lacquer
rejects a value that looks like a real credential, because this file is
committed.
The release writes those values into the xcconfig on that product’s matrix leg
only, and fails if any of them is unset or empty. It does not fall back to
the placeholder seeding ci.yml does: CI seeds placeholders because tests must
run without production keys, whereas a release doing the same would sign and
ship an IPA wired to appl_xxxxxxxx, with nothing looking wrong until the
revenue didn’t arrive. A product that declares no secrets renders no step, which
is the normal case.
A workflow_dispatch run picks its product from a dropdown, defaulting to
all. Scoping matters there too: a dispatch of all after one app has shipped
a version hits the same closed train. Naming an unknown product fails rather
than falling back to everything.
Capping CI rounds: lacquer ci-round
Section titled “Capping CI rounds: lacquer ci-round”An agent gets at most two rounds of CI on a pull request. If checks still fail after the second push it stops and hands the PR to a human instead of pushing a third time. Re-running CI is the most expensive reflex an agent has (minutes of a shared Mac, plus a whole agent context to read the result), and a third attempt is almost never a fix: it is a guess. Forcing the stop turns “push again and see” into “say what you do not understand”. Until this command it was persona prose, which depends on every agent reading and honouring it; now the tool enforces it.
lacquer ci-round begin 431 # round 1: after the PR exists, before the pushlacquer wait pr 431 # exit 1: lint failedlacquer ci-round begin 431 --reason "lint failed: unused import in wait.go, removed it" # round 2lacquer ci-round begin 431 ... # exit 10: EXHAUSTED. Do not pushbegin records the commit you are about to push (--sha, default git rev-parse HEAD), so run it before git push and push exactly that commit. It is
idempotent for a commit it already granted.
For a review-requested correction, use
lacquer ci-round begin 431 --review "PM requested correcting the misleading comment"
instead of --reason. This works on green CI and consumes a round under the same
cap. The ledger records kind review; status shows, for example,
2/2 used (1 failure, 1 review). There is no comment-only exemption.
| Exit | Meaning |
|---|---|
0 |
Granted, or already recorded: push this commit. |
10 |
Exhausted. A report, not a failure: the PR is not failed or closed and nothing is pushed. It comments on the PR with what is still failing, raises an inbox ACTION in the default inbox (or --inbox / $LACQUER_INBOX); if that cannot be written it prints the exact console inbox add command to run, and prints what was spent. Do not push; say what you do not understand. |
11 |
Round 2 or later needs --reason naming at least one check the previous round reported failing (at a word boundary, in a reason of four or more words). The refusal lists the names it will accept. Reset also requires a non-empty reason. No new push is granted; an observed unrecorded head still counts. |
12 |
Nothing to spend a round on. Either the previous round reported no failure (its checks are still running, lacquer wait pr exit 2; it has none, exit 3; or they passed), without a review request, or the commit you named is already the PR’s head without a grant. No new push is granted; an observed unrecorded head still spends a round. |
13 |
The tool could not check: gh failing (lacquer wait pr exit 4), the PR closed, an unreadable ledger or manifest, bad usage. It cannot say a round is allowed, so do not push. |
The codes start at 10 so none is read as lacquer wait pr’s 0-4. The mapping to
that command is the point: only a wait that exits 1 leaves something concrete
to fix for a failure-driven round. Review requests use --review instead;
exits 2, 3 and 4 do not themselves justify a failure-driven round.
Choices worth knowing:
- The count lives on the PR. Each round, update, reset and stop is one comment opening
with an HTML-comment marker holding one JSON object (round number, head SHA, UTC
time, the failing checks addressed, the reason), above prose saying the same
thing, so the stop is visible without opening CI and parseable. Append-only,
not one comment edited in place: a lost update would silently refund a round,
whereas two racing appends are ordered by GitHub and the later one is told it
lost. Only comments from an
OWNER,MEMBERorCOLLABORATORcount, so a stranger on a public repo cannot forge a stop or a reset. A ledger comment the tool cannot read is exit 13, never skipped. - An unrecorded push spends a round. Agents and humans share an account, so
the tool cannot infer authorship. Both
beginandstatusappend anunrecordedentry for a newly observed unknown head, once per SHA, except for the GitHub update-branch case below. Local merge commits pushed withoutbeginare charged too. The initialbegincan register the head that opened the PR as round 1. Only observed heads can be accounted for; this does not inspect every intervening push or install a pre-push hook. - GitHub update-branch merges are neutral. For an unknown head, the commit API
must report committer email
noreply@github.com, exactly two parents, and the previous known head as one parent. The tool records kindupdate, marks the SHA known, and neither spends a round nor resets the budget. Later observations do not charge it. Any other unknown head is stillunrecorded; if the commit API fails, no push is granted (exit 13). - Only explicit reset refills the budget. A human authorizes
lacquer ci-round reset <N> --reason "<why>". This writes aresetentry; subsequent rounds count fresh, while earlier entries remain on the PR. - Failure-driven round 2 must address round 1. The cap is on guessing, not on rounds. A reason that is empty, too short, or names no failing check is refused. It is a forcing function for saying which failure you understand, not proof that you fixed it.
- Check state is
lacquer wait pr’s, not re-derived: a cancelled job from a superseded run does not read as a failure on a green PR. - The cap is read from
--manifest-ref(defaultorigin/main), not the working tree. A branch that could edit its own.lacquer.tomlcould raise its own cap. A ref with no manifest means the default.
[project].ci_round_cap
Section titled “[project].ci_round_cap”[project]ci_round_cap = 2 # optional; 1-10, default 2Below 1 would refuse the push that opens the PR, and a large cap is no cap, so both are rejected at load rather than read as “use the default”.