cockpit
AGENTS.md0 starsChanged 41 days agoMITView on GitHub
This is a public repository. Never include any of these in commits, PRs, code comments, or documentation: In commit messages and PR descriptions, say what changed and why, not which ticket tracks it. Reference public GitHub issues only. If the context needs an internal ticket, summarize the requirement. Before you commit, scan for what gitleaks cannot catch: your team's ticket prefixes, and @firstname references that are not GitHub handles. Use a dedicated git worktree for every code change. Never commit…
- Commits and pushes
Where files are headingNew AGENTS.md files have more rules about keeping secrets out of the code, and fewer about code style.
Of the AGENTS.md files we read, the 31,439 first seen in the last six months, against the older ones: the kind of rule that gained most share, and the kind that lost most. About files like this in general, not this one, and an estimate, since a kind is a pattern in the words.
In this repository4 other files it gives its agents.
- CLAUDE.mdCLAUDE.md
- cockpit-devSkill
- coverage-auditSkill
- spec-auditSkill
The file
Show its rules and text
We split this AGENTS.md into the rules it gives an agent
28 rules. About 0 are common, 5 are distinctive, and 23 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈5
Distinctive · estimate
This author's own choices. Worth a look.
23
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- could be automatic: a setting could enforce it
- Distinctive: line 15
Use a dedicated git worktree for every code change.
- Very specific: line 17
Before any Edit or Write, run git branch --show-current and git worktree list.
- Very specific: line 34
When you change match_worktrees, _spawn_missing_workspaces, nudge_if_idle, _track_dev_done, _maybe_autoclose, the cache.py cell writers, …
- Very specific: line 70
Use loop.add_signal_handler.
- Very specific: line 82
Every cell naming something on the web is an OSC 8 hyperlink (worktree_table.py::_cell_links / _apply_links).
- Very specific: line 101
Do not: add a row key that pipes into cmux diff.
- Very specific: line 106
Keep mute and snooze as separate fields, never one tri-state, or the CLI's mute would self-clear.
- Distinctive: line 120
Do not: add a manual nudge key with a canned message.
- Very specific: line 172
Keep it in read_text, the one seam every flat-cell renderer shares.
- Very specific: line 178
Use the worktree, not repo+branch.
- Very specific: line 210
Put the emoji in the value, not set-status --icon.
- Very specific: line 275
Read read_workspace_groups, never list_workspace_groups, which flattens a failed read to [] (see Shared rules).
- Very specific: line 278
Keep the record in pill_state, never on disk.
- Distinctive: line 313
Use age, not a lock or spawn-side registration.
- Very specific: line 376
Default to the PR diff. With no PR, fall back to a local diff and name the substitution.
- Very specific: line 377
Use bare gh pr diff, so no daemon is needed.
- Very specific: line 378
Read no cockpit config. Resolve with git.worktree_root.
- Very specific: line 389
Never: call cmux("close-workspace", …) raw.
- Distinctive: line 405
Never: read it back: no cell, pill, decision or TUI row.
- Very specific: line 439
Every entry names a tier cockpit has.
- Distinctive: line 519
Never cache a failed identity fetch.
- Very specific: line 531
Do not: add a retry or back-off here.
- Very specific: line 571
Ask through the TicketProvider and never branch on a provider name.
- Very specific: line 579
Check per repo, not per credential, so a repo whose tickets block overrides the org's is reported.
- Very specific: line 599
Only APPROVED, CHANGES_REQUESTED and DISMISSED are verdicts, per reviewer, latest wins.
- Very specific: line 608
Update with the updatePullRequestBranch mutation.
- Very specific: line 683
Never: remove skip-github-release, skip-labeling or the chore(main): release guard from release-please.yml.
- Very specific: line 684
Never: remove create-branch: false from release.yml.
≈ “Common” is an estimate: at least 1 in 5 of the 40,246 AGENTS.md files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
Privacy & Internal References
This is a public repository. Never include any of these in commits, PRs, code comments, or documentation:
- Internal ticket IDs (Linear
ENG-123, JiraPROJ-456) - Internal GitHub PR/issue URLs from private repos
- Real names of teammates (use roles: "the reviewer", "the on-call engineer")
- Internal Slack channels, wiki URLs, tool links, hostnames, service names, infra identifiers
- Customer names or company-specific identifiers
In commit messages and PR descriptions, say what changed and why, not which ticket tracks it. Reference public GitHub issues only. If the context needs an internal ticket, summarize the requirement. Before you commit, scan for what gitleaks cannot catch: your team's ticket prefixes, and @firstname references that are not GitHub handles.
Worktree discipline
Use a dedicated git worktree for every code change. Never commit directly to main/master in the primary checkout. Never edit in place on a feature branch without a sibling worktree. cockpit derives per-branch state from git worktree list, so an unisolated branch is misattributed or dropped.
Before any Edit or Write, run git branch --show-current and git worktree list. If HEAD is main/master, or the tree is the primary checkout (first entry), spawn a worktree with cockpit new first. If HEAD is already a non-main branch in a sibling worktree, proceed. Do not spawn another.
Architecture notes
Each ### is one invariant: the rule and its enforcing file::symbol. Obey the Never / Do not lines. docs/state-machine.md holds four Mermaid diagrams, the control-flow half of this file. Keep both in sync.
Shared rules
- Match a workspace to a repo by cwd against that repo's own
worktrees(). Never use a path-prefix test, because a worktree usually lives in a sibling directory. - An extracted helper takes its input and does not fetch it. A helper that fetches its own input silently un-mocks every caller's test stubs.
Nonemeans "couldn't ask".[]means "answered with nothing". Never collapse them, or a network blip reads as an empty result.- Fold and snooze membership comes from the render's own record. Never re-derive it, because it partitions per chain, not per row.
- Updates are brew's job. Do not add a version check, a
ukey, a re-exec, orcockpit update. - A new
~/.claudeinstall target needs an inverse inteardown_claude_integration, orcockpit teardownleaves it behind.
Keep docs/state-machine.md in sync — a stale diagram is worse than none
When you change match_worktrees, _spawn_missing_workspaces, nudge_if_idle, _track_dev_done, _maybe_autoclose, the cache.py cell writers, tick cadence, or the spawn/teardown/nudge/devdone/color rules, update the matching diagram in the same PR.
Docs have four altitudes — put a fact at exactly one of them
FEATURES.md (user), README.md (visitor), docs/config.md (operator), and AGENTS.md + docs/state-machine.md + specs/ (you: rulebook, control flow, behavior ledger). A change to user-visible behaviour updates FEATURES.md in the same PR. Nothing fails when it is skipped.
- Don't restate across altitudes. Duplicated prose drifts silently.
FEATURE_GUIDE_URLpoints atmain. The version bump lands beforetag.ymlpushes the tag, so a pinned URL 404s during the release-PR window. Do not pin it to a tag.- A user-facing doc names capabilities, never
file::symbol, so it survives renames.
Inventory is derived every cycle, never stored
Each cycle re-reads git worktree list and cmux's workspace list. Only PR payloads are cached (~/.config/cockpit/cache/<repo>__pr-<N>.json), because each is a network round-trip. Never add a stored identity file.
Packaged as the cockpit console script — invoke by subcommand, never by file path
- Dispatch:
cli.pyrouteswatch / setup / teardown / statusline / starship / idle-pill / new / close / diff / nudge / statusand--version. Add entry points ascockpit <sub>incli.py, not as file-path invocations. A new subcommand's argparse parser goes incli.py::parsers, which thedoc-cli-driftpre-commit hook reads to reject docs naming a flag that no longer exists. teardown(config.teardown_claude_integration) inverts the~/.claudewrites ofsetup. Run it beforebrew uninstall.- Distribution: a brew formula whose single source of truth is the tap repo
khivi/homebrew-cockpit. Do not vendor it here. There is no plugin, marketplace, or self-update path. - Version: static in
pyproject.toml, read viaimportlib.metadata.preflight._warn_cockpit_not_on_pathonly warns. - Claude footprint:
cockpit setupidempotently writes the statusLine command, idle-pill hooks, command templates under~/.claude/commands/, and cmux'ssidebar.showPullRequests(see theprpill section). Each has an inverse inteardown_claude_integration. _COCKPIT_HOOKSis exactly six hooks:StopandStopFailure(a turn ended by an API error) →idle-pill stop,PostToolUseandPostToolUseFailure→idle-pill resume(clearsblocked=only),UserPromptSubmit→idle-pill promptandPermissionRequest→idle-pill blocked. Only those have a reader; do not add one without a reader. The drop pass ininstall_claude_hookssweeps every event in the file, since a retired hook sits under an event the template no longer names. An event left with no groups is deleted._COCKPIT_HOOK_CMD_REstill matchesstatuslineto clean older installs.{python}pin: starship configs use{python} -m cockpit.cli <sub>, pinned at setup toconfig.stable_python, which maps a brewCellar/<formula>/<version>interpreter to its unversionedopt/<formula>symlink sobrew upgradecannot dangle it. Never run setup from inside a worktree venv, because it bakes in an ephemeral.venv/bin/pythonthat dies on cleanup.- Pin self-heals:
cockpit watchre-pins at startup viaconfig.repin_interpreter_if_stale, rewriting only the interpreter prefix. - idle-pill hook:
cockpit/hooks/cmux-idle-pill.shships in the wheel and isbash-exec'd, so it needs no exec bit.
Slash commands are user commands, not a plugin. cockpit/claude_commands/*.md wrap cockpit new/close/broadcast/nudge/diff $ARGUMENTS. A template documents the CLI and never reimplements it. parse_args errors on a bare --context, because an unexpanded flag means the substitution failed. Do not teach the CLI to synthesize its own context. Templates install as flat, hyphenated files (cockpit-new.md → /cockpit-new), since colon-namespacing is plugin-only. hatchling ships only VCS-tracked files, so git add a new template.
Every gesture is a command; cockpit ships no agent skill. The daemon delivers review notes by sending the literal DIFF_COMMENTS_NUDGE string, which a typed command serves. Do not add a skill for a gesture the daemon already sends a command for.
cockpit watch is a Textual TUI, and the TUI is the daemon (cockpit/tui/)
- No headless mode. A non-TTY
watchexits 2. The app owns the pidfile. daemon._reclaim(viadaemon.reassert_pidfile) must re-create the state directory. Do not useensure_state_dirs(), which also seeds aconfig.json.- Slow and fast ticks run in
@work(thread=True)workers, serialized by_tick_lockacquired inside the worker. - Never let the slow tick's
on_repo_donehook write a cell. Only the daemon writes. - Use
loop.add_signal_handler. Never usesignal.signal, which raises off the main thread. - Tick prints go through one
_QueueWriter. Never use per-tickredirect_stdout.
Table
- The table is read-only (
worktree_table.py), keyed by worktree path, grouped under header rows (HEADER_KEY_PREFIX). - The status glyph sits in a fixed-width slot (
_STATUS_SLOT), because 🔇/🔔 differ in ink width. Do not drop that padding. - An ellipsized cell must keep its full text on its tooltip (
row_tooltips). pr-nudgegives 🔔 and isPR.nudge_issue, so bell and nudge cannot disagree. Mute (🔇) wins.pr-snoozedgives fold membership and no glyph.
Links
Every cell naming something on the web is an OSC 8 hyperlink (worktree_table.py::_cell_links / _apply_links). CI links to <pr-url>/checks.
- The ticket URL must be cached. The daemon writes it every cycle into the
ticketblock'surl(cycle._stamp_ticket_urls), because three providers expose it only in the PR body's footer. Do not resolve a missing link inworktree_table.py. - The stamp always writes, including
None, and runs on carried blocks, because the ticket id decides carry-versus-rebuild. treads the cached string first (app._open_ticket_url), so key and click agree.- Never link a blank cell.
- No underline and no click handler. A
DataTablehandler would collide with row select or Focus.pandtstay the keyboard route. - The tooltip names the destination.
test_links_survive_all_the_way_into_terminal_outputpins that Textual still emits the escape.
Row actions
- Except
n,f,mandz, row keys never touch cmux or the cache. - Footer help is gated by
ACTION_REQUIRESfed bycurrent_capabilities(). Actions stay bound and self-guard. fensures a workspace (spawn if missing), then focuses.use_worktree: falserepos host several sessions at one cwd, sofresolves by repo name (_workspace_ref_by_name), then cwd, then spawn.crunsprobe_blockers, enqueues aTeardownRequest, slow-kicks. The commit guard splits by ownership (worktree_state_blockers). Own branch:git.count_unlanded(patch-id againstorigin/<default>, reachable from no remote ref exceptorigin/<branch>). Coworker's branch:git.commits_only_local. Do not collapse the two. Do not re-baselinecount_unlandedonorigin/<branch>.con a primary checkout:teardownalways skipsgit worktree remove. On the default branch it closes the workspace only and the guard relaxes. On a feature branch it deletes the branch and the guard does not relax.Coverrides the soft open-PR block but still refusesworktree_state_blockers. Closing never runs teardown inline. With no daemon the marker stays queued.mandzrepaint on the keypress (app._repaint_prefcallscache.restamp_pref), the one row-action cache write. It writes cells and snapshot, becauserepublish_pr_caches_from_diskreverts cells alone. Do not extend it to a cell the daemon derives.nlaunchescockpit new <source>detached viapython -m cockpit.cli new. Do not runspawn.pyby path.- Do not add a row key that pipes into
cmux diff.
z snooze
zwritesNudgePref.snoozedplus three wake snapshots from the cached payload, resolved via_cache_repo_name, because the confignamematches no file.- Keep mute and snooze as separate fields, never one tri-state, or the CLI's mute would self-clear.
zclears any mute it lands on. cycle.py::_resolve_prefswakes a snooze on review activity (wake_on, fromtotal_from_othersnotunaddressed), new work (nudge_issuediffers fromwake_nudgeand is non-empty), or a push to a PR I review (head_oiddiffers fromwake_head,not PR.mineonly). An empty snapshot wakes nothing.- Do not fold
head_oidintowake_signature, which my own PRs share. Do not give the snooze a time-baseduntil. - A stacked chain snoozes and wakes as one unit.
- The pressed row's direction applies to every member (
app._apply_snooze). - Membership comes from
worktree_table.chain_paths, read on the main thread inaction_snooze_row(render's own record, see Shared rules). - The wake half is
cycle.py::_wake_chains. It fires on a wake, never on the chain existing.cockpit nudge snooze <N>stays per-PR. - Do not give the snoozed row a glyph to fix a half-snoozed chain.
- The pressed row's direction applies to every member (
- A bare
cockpit nudge snoozewarns (nudge_cli._warn_split_chain) and the write stays per-PR. The tip comes fromstacks.py::chain_tip. Do not re-derive a chain innudge_cli.py. The warning fails silent on an absent cache. - Both CLI commands write unconditionally, because
restamp_prefpluskick_runningconverges a diverged pref and surface. Re-snoozing re-arms the snapshots from the cached payload, except with no payload: blankingwake_onto"0|"would wake it next tick.
a ask and A ask-snoozed
ais the one manual send.AskScreenroutes throughcmux.nudge_if_idlewith nopref_key, so it overrides mute and snooze but honours every idle guard. It writes no cell or pill.- Do not add a manual nudge key with a canned message.
- The modal is an
Input, never aTextArea, or a multi-line box submits several truncated prompts. acarries the typed line and nothing else. Do not re-attach diff comments.- On a repo header
aaddresses the whole repo. The fan-out matches by cwd (see Shared rules) and excludes the daemon's own. - A partial send keeps the draft and the refs that missed, and the retry targets only those. A refusal keeps the text and names its cause from
skips. A merely busy session queues the line (see the queued-ask rule). - The async state hint (
rest_skip_reason) never blocks the submit.nudge_if_idleis the sole authority. Aisa's fan-out aimed at one snoozed fold. Membership comes fromsnoozed_paths(see Shared rules). Do not pass apref_key: snoozing silences the automatic nudge, never a typed line.
Kicks
- Row-action kicks are repo-scoped (
_kick_slow(<row's repo>)) exceptz.s, SIGUSR1, the interval and startup stay full-cycle. zkicks full-cycle, becausecycle_allbuildsReviewFoldsonly whenonly_repo is None. Do not buildfoldsunderonly_repo: a bucket with no ref from the scoped repo is dissolved. Do not move the pass to the fast tick, which would read absent payloads as "no reviews left".
Global keys, palette and header
- Global keys:
q,s(inGLOBAL_ORDER, notROW_ACTIONS) and the repo-scopedh. Do not giveaction_show_outputa key back. Do not list sync inCOMMANDSas well as binding it. Updates are brew's job (see Shared rules). - "Feature guide" opens
open_url(FEATURE_GUIDE_URL). Never point it at a local path, since the wheel may not ship the file. "What's new" isaction_open_release_notestoRELEASE_NOTES_URL. Do not pin either URL to a tag. app._announce_upgradecomparesversion.upgraded_version()against cockpit's own last run, with no network. Do not widen it into a version check. A first-ever run and an unresolvablerunning_version()stamp nothing.ConfigCommandsimplementsdiscoveras well assearch, because the palette is empty untildiscover()fills it. Do not add an entry without adiscoverhit.COMMANDSorder is the menu: in-app overlays, then$EDITOR, then the browser pair. Do not append to the end.#header-reponames the cursor row's repo, fed byapp._refresh_footer_caps. The colour comes from a_repo_colormap filled byupdate_inventory. Never callload_config()there. Do not add a Repo column or useDataTable.fixed_rows.#header-repoowns the one1frslot, so segments right of it stay right-anchored.test_the_countdowns_do_not_move_when_the_cursor_changes_repopins it. Do not add a second growing segment.#header-brandshows the running version, dim and linked.brand_texttakes the URL as an argument, set fromRELEASE_NOTES_URLonto theversion_urlreactive. Do not import the URL into the widget. Do not move the version into the menu.- The trailing
≡ Menu(#header-menu) is the palette's one visible entry point and is unconditional, becausectrl+pcannot come fromBINDINGS. Do not move it intoFooterBar. Do not print the key beside it. Overridelink-colorandlink-stylein CSS, notcolor. Use a single-cell glyph, because☰measures two cells but draws one. - The countdowns use a glyph in the bar and a word in the tooltip (
SLOW_GLYPH/FAST_GLYPH). Do not put a glyph in the bar that the tooltip does not spell out. - Footer keys explain themselves on hover through
TOOLTIPS. Do not rebuild the footer as a widget per key.
h parks a repo
h is the one repo-scoped key and the only user state that stops the daemon polling.
- One key, three meanings by cursor row: expand or collapse the hidden section, un-park a revealed repo, park the cursor row's repo. Do not add a second key for reveal.
on_clickon the hidden row must resolve the row fromevent.style.meta, never the cursor, because Textual runs it before the cursor moves.- Key by resolved path in
$COCKPIT_HOME/hidden-repos.json(lib/hidden.py), never the configname. It fails open. cycle_allfilters a parked repo out, except whenonly_repois set.- Parking closes the repo's cmux workspaces (
_park_workspaces), workspace-only, matched by cwd (see Shared rules). It spares the daemon's own and any notworkspace_is_idle. - Un-parking closes and respawns nothing, and parking never costs a fetch.
- A spawn into a parked repo un-parks it in
spawn.py::_unhide_spawn_target, one gate for every mode, after the spawn, keyed offmain_worktree_path(wt). Do not move it into a per-mode branch. Do not key it on the worktree path. n's repo picker keeps parked repos, sunk and dimmed._spawn_newun-parks before launching, redundant with the gate so the row repaints at once. Do not filter parked repos out. Do not delete either half as duplication.- Parked repos collapse into one trailing session-only row (
HIDDEN_ROW_KEY).hshows in the footer only on a repo-reading row (HEADER_CAP). The binding stays live everywhere.
Only the daemon writes the cache; renderers read
lib/starship.py field printers are strictly read-only.
- Slow tick (300s),
cycle.py::cycle_all: full reconcile (gh fetch, base-distance, per-PR JSON, PR flat cells, git-state cells, pills). - Fast tick (30s),
cockpit.py::_fast_tick: pidfile re-assert, then a network-free republish of git-state, cost,session-waitand PR cells, plus name and colour reconcile, trailing-fold restore and theidle=pill re-assert. The last three write into live cmux and aredry-gated. It ends with the diff-comment hand-over and the seed-queue drain, eachdry-gated throughnudge_if_idle's owndry=.
A new cell needs a writer in cache.py and a call site in a tick. Never let a renderer read source state directly.
Cell text is externally authored (PR titles, ticket ids, footer URLs). cache.py::strip_control neutralizes it inside read_text, because Rich's Text strips BEL but not ESC, so a title could inject its own OSC 8 link.
- Keep it in
read_text, the one seam every flat-cell renderer shares. Do not re-implement it per renderer or move it to the writers, which would leave older cells raw. - Strip whitespace first, then replace control characters one-for-one, never drop them.
_ellipsizeand_STATUS_SLOTcount codepoints. - Payload-derived text never passes a flat cell and needs its own call:
_ticket_ids, the ticket half of_cell_links, and anchor text indiff_comments.py.
Key every flat cell by worktree path (cache.py::cwd_cache) or session id. Never key by branch: same-named worktrees in different repos would merge.
- Use the worktree, not repo+branch. Only the path is held by all three renderers (TUI, starship,
restamp_pref). - The path travels in the PR payload (
write_pr_cache'scwd), becauserepublish_pr_caches_from_diskhas only the JSON. Dedup per worktree. - A PR with no local worktree writes no cells, but its JSON snapshot is still written.
find_pr_payload_for_cwdprefers the snapshot stamped with this worktree, and falls back to a branch match only among unstamped payloads. A snapshot stamped with another worktree is that worktree's. Callers pass the worktree root, never the shell cwd.- A stamp is live only while the worktree holds the snapshot's branch. A checkout away leaves it behind with nothing to rewrite it, so both readers match the branch too, and the fast tick hands
republish_pr_caches_from_diskthe branch each listed worktree holds. Never trust acwdstamp alone.
cmux events is a doorbell — it wakes a tick, it is never state
lib/events.py::watch_workspace_events treats an event as a trigger only. It kicks the fast tick, which re-derives everything. Gate on has_capability("events.v1") plus is_cmux(). Do not feed an event into a decision, a cell, or the slow tick.
- Subscribe to
workspace.createdandworkspace.closedonly.sidebar.metadata.*andworkspace.renamedmake the daemon ring its own doorbell. - Debounce in the app. An event during a fast tick sets
_events_pending, and_run_fast'sfinallykicks once more. - The cursor file is cmux's resume bookmark. Do not grow it into inventory or route it through
cache.py. on_unmountkillpgs the child, because killing the leader leaves a grandchild holding the stdout pipe. Give up after_MAX_FAST_EXITSquick exits.
The one payload read is the sidebar X. _closed_workspace passes workspace_id and cwd to an optional on_closed callback. Nothing caches the payload.
- A raising
on_closedhandler is logged, not fatal. - Filter cockpit's own closes, or parking a repo tears down every worktree in it.
_note_self_closerecords the UUID before the close and lives incmux_close_workspace_best_effort. Do not re-implement it per call site. Key by UUID, not cwd. - Route the X to the refusing gate. Do not map it onto
C. Refusals toast loudly.quietsuppresses only the missing-worktree toast.
sidebar_color — cosmetic, cmux-only, per-repo
Applied slow-tick via _apply_repo_colors and fast-tick via _tint_repo_workspaces, deduped in pill_state under color:<ref>. Preflight validates it (_validate_sidebar_colors, sys.exit(2)). Valid set: colors.CMUX_COLOR_ANSI.
The pr pill replaces cmux's native sidebar PR row — which cockpit cannot set, and must not trust
cmux resolves a branch to a PR by name alone, so it shows the first of several PRs and a draft as open. cockpit setup turns that row off (sidebar.showPullRequests: false). Cockpit renders its own from ctx.prs.
- The
prpill is the one pill that names the PR.draft,stateand the fourci_*kinds are still emitted, but their_CMUX_RENDERERSentries areNone. Do not re-enable any without turning theprpill off. - CI rides the pill as a trailing glyph, and a non-passing build takes the colour. Keep
"ci"inACTIONABLE_KEYSalthough nothing writes it: it sweeps stale pills. PR_KEYis in_PR_PILL_CLEAR_KEYSbut notACTIONABLE_KEYS, soclear_pr_pillsclears whatapply_pillswrote.- Put the emoji in the value, not
set-status --icon. The renderer contract is a(key, value, color)3-tuple. - Emit it for every state including OPEN, so no pill means no tracked PR. Do not spawn pills for untracked workspaces.
lib/cmux_config.pymakes cockpit's one write into another tool's config. It edits~/.config/cmux/cmux.jsonas text, because the file is JSONC and ajsonround-trip strips its comments, and nocmux config setkey covers this setting. A trailingMARKERcomment owns the line, soteardownundoes only its own edit with no stored state.- A file that already reads
falseis the user's. It gets no marker, so teardown never turns their choice back on. - Skip any layout the line edit cannot prove by re-parsing, and print a by-hand instruction. Never guess.
- The write is setup-only and gated on
is_cmux(). Do not move it towatchstartup, which would override a user who turned the row back on.
A cmux card shows three rows before "Show more" and no setting changes that. Cockpit drops wip while rebase or merge is in flight, because it restates that pill. Do not fix a buried pill by reordering KIND_ORDER.
orgs is a load-time defaults layer — nothing below load_config knows orgs exist
config.py::apply_org_defaults merges an org block into each member inside load_config(), so the reader chain gains an org rung with no call-site changes. Do not add an org-aware reader, an org_* field, or a repo_org(...) helper.
- One level deep, repo wins. A scalar the repo sets beats the org's, and a block unions per field. Do not make it recursive or a whole-block override. Rebuild each block into a fresh dict, never alias it.
- Do not persist it or add a writer that serializes
load_config()'s dict. - Validate effective values:
_validate_orgsfirst (an undefined org hard-fails), then the merge. - Orgs affect TUI ordering only. No org header row, no cross-repo workspace-group, no park-the-whole-org key.
Stacked PRs: one cmux sidebar group + one indented TUI row — derived from PR.base, never stored
lib/stacks.py::find_stacks derives chains from PR.base, matching on PR.branch. Do not shell out to gh stack view or persist a stack. A fork yields one chain, because a workspace is in one group.
cycle.py::_reconcile_sidebar_groups renders each chain of two or more PRs with local workspaces as a collapsible group (square.stack). The header is <tip> (N), the tip and not the root, with the repo's sidebar_tag. Members follow, tip first. It is cmux-only, best-effort and cosmetic: it never spawns, closes, nudges or writes a cell.
- Reconcile against cmux's live
workspace-group list, not apill_statemirror. Match by member ref, never name. Touch only groups overlapping this repo's refs. - The header is cmux's spawned anchor, never a member. Spawn it with
--cwd $HOME, outside every registered repo, or_reap_workspace_orphansreaps it. Close it on dissolve, becauseungroupleaves it as a loose row. Also sweep groups whereicon == square.stackandmembers <= {anchor}. - The anchor must own a live shell (
_durable_anchor), becausecreatespawns it with no command and it dies silently.create_workspace_groupswaps in an anchor spawned withANCHOR_KEEPALIVE_COMMAND, then closes the husk throughcmux_close_workspace_best_effort. Never use a raw close, which routes into teardown as a user X. Do not revert to the anchorcreatereturns. - Minimum fold size is one, so
create_workspace_grouprefuses only an empty ref list. Callers set their own floor. - A stack whose tip is snoozed joins the
snoozedfold whole and stays out ofdesired, because groups do not nest. Key on the tip. Gate the divert onfolds is not None, or a repo-scoped kick strands members. Do not use a position-based answer.
The TUI shows the stack as indentation from the flat cell pr-base. stacks.py::stack_order returns (index, depth) and _stack_rows sorts each chain under its tip. Nesting is one level. Do not restore the per-level cascade. Key by index, not branch, so a base cycle falls back to flat rows.
Reviews and snoozed PRs sink to the bottom — TUI row bands + two trailing cmux folds
Both surfaces answer "is this my turn?" from flat cells, never a stored marker: row order in the table, two collapsed groups at the bottom of the sidebar (per org).
The snoozed band collapses behind a per-repo ▸ N snoozed row, so a snoozed row has no glyph.
zopens it, as one key with three meanings likeh. Do not hang this offhor a new key. Enter and a single click toggle it too.- The fold row carries
SNOOZED_CAP, notHEADER_CAP, which would hidez._skipsuppresses every row key exceptSNOOZED_ROW_ACTIONS. Both drop theirACTION_REQUIRESentry viareq = None, not an early return, which would skipBACKEND_ACTIONS. - The cursor-skip loop must stop on it. It keys off its own sentinel, not
HEADER_KEY_PREFIX. - A snooze moves the cursor onto the fold that swallowed the row. Ask the table, since a snooze below the tip folds nothing.
_split_snoozedpartitions by chain, sozwrites the whole chain's prefs. Membership is the render's own record (see Shared rules).- A snoozed row keeps the 🔔 suppression:
_status_glyphreturns blanks, butpr-nudgeis never blanked. Do not drop the suppression with the glyph. 🔇 wins over snoozed.
The TUI renders three bands per repo (_row_band): 0 my queue, 1 a coworker's PR I review, 2 snoozed. Snooze outranks review. The sort is stable. A chain bands by its tip. Mute is not a band, since 🔇 means "stop nudging me about a PR I'm working on".
Coworker reviews fold into one trailing <org> reviews (N) group, the one fold that spans repos. not PR.mine workspaces get the eyeglasses icon and are re-parked at the bottom every cycle (--to-index 9999, since workspace-group list reports no index). The key is the repo's org, else its name. A bucket with a sidebar_tag is named by the glyph (_fold_tag).
- It has its own pass, because one repo cannot see sibling reviews.
_reconcile_sidebar_groupsonly collects into aReviewFoldsaccumulator, and_reconcile_review_groupsdrains it once after every repo. - Ownership splits by icon. The per-repo pass filters review-iconed groups out.
- Stacks win overlap. A lone review folds too. The drop-departed-members loop guards on
folds.owned, not the bucket, because a hand-added workspace is the user's. Do not persist a "this is a review" marker. - An incomplete cycle suspends the dissolve (
ReviewFolds.partial), the one irreversible step, because a repo that never reported leaves its bucket absent, which looks like "no reviews". Re-park, rename and re-member still run. Do not key this on the bucket being empty instead of the cycle being complete.
Snoozed PRs fold into trailing <tag> <repo> snoozed (N) groups below reviews, one per repo, not per org, because an org-wide pile hid which repo a snooze belonged to. They use the same accumulator and pass walking _TRAILING_FOLDS, with their own ReviewFolds.snoozed_tags. Membership is NudgePref.snoozed, mine included.
- Precedence on overlap: stacks, then snoozed, then reviews.
- Order comes from pass order, not a rank field. Do not reorder that tuple expecting names to sort it.
- Match the two families separately, or one claims the other's fold. The per-repo pass filters both icons out of
all_groups, or it dissolves a snooze-iconed group every cycle.
Both piles are born collapsed, at create time only, since cmux creates groups expanded. Only _reconcile_review_groups passes collapsed=True. A stack stays open. Do not re-assert collapse per cycle, which would slam shut a fold the user opened, and do not read is_collapsed back.
cycle.restore_trailing_folds rebuilds a fold lost mid-interval on the fast tick. It replays the slow pass's (name, refs) record from pill_state and is not a second authority.
- It can only create: no dissolve, rename, member change or re-park. That is what permits 30s.
- Read
read_workspace_groups, neverlist_workspace_groups, which flattens a failed read to[](see Shared rules). Do not re-merge them. - A live group of the same icon sharing any member means the fold exists. Match by overlap, not name.
- Filter refs against live workspaces. If all are gone, skip and leave the record.
- Keep the record in
pill_state, never on disk. With no records, make no cmux call. - Retiring a record rides
folds.partial.
Whatever destroys the folds is still unidentified. Do not treat it as solved.
Workspace names track repo + branch (wt.workspace_name), re-asserted on both ticks
wt.label (git.py::branch_label) derives from the branch, not the dir basename, and is never "". The cmux name is label under an optional <tag> prefix. Every path that spawns, renames or matches by name uses workspace_name. rename_workspace_if_needed re-asserts idempotently. Naming is cosmetic and never a send. To relabel, rename the branch. Any main-branch worktree (wt.is_primary or wt.branch in MAIN_BRANCHES) keeps its custom name. The branch half matters in a bare repo, where no sibling is is_primary.
sidebar_tag is opt-in per repo and applied by one function, git.py::tag_workspace_name.
- Expand
{repo}once at load time (config.py::expand_sidebar_tags), right after the org merge, so an org can declare a tag and all fiverepo_entry.get("sidebar_tag", "")readers see the result. Do not teachtag_workspace_namethe token. The value is the repo'sname, else path basename, as inbroadcast._repo_label. Never persist it. - Apply the tag in both naming halves, or a fresh workspace is renamed a tick later:
Worktree.sidebar_tagfor the daemon,spawn.py'sws_nameforcockpit new. Do not add a third naming site. spawn.pyresolves the tag after every routing hop, since routing rewritesargs.repo. With no determined repo (--cwdalone), apply no tag. Do not fall back todiscover_repo().- Pass the tag only at call sites that read
workspace_name: the fast tick's rename pass,cycle_all, the orphan reap'swt_by_name, and the TUI's_resolve_worktree. - The primary checkout takes no tag. A tag would double its repo name and break
_workspace_ref_by_name. - A stack header takes the tag through
cycle.py::_stack_group_nameandtag_workspace_name. A trailing fold spans repos, socycle.py::_fold_tagreads the bucket's own declaration (theorgsblock, or the repo entry for an org-less pile) and substitutes it for the bucket name. It reaches the cross-repo pass inReviewFolds.tags. Do not feed a member's merged tag into a fold header:{repo}is already one repo there. In a fold{repo}expands to nothing, and empty falls back to the bucket name. Keep the state word, or the two folds and a stack header look alike. - A tag ending in a non-alphanumeric takes a space, not
SIDEBAR_TAG_SEP, because after a glyph the·renders as a stray dot. Test the last character, not for an emoji.
The daemon creates worktrees in the background — never blocking the tick on git
cycle.py::_spawn_missing_workspaces shells out via module dispatch in a detached Popen(start_new_session=True):
- My PR with no worktree runs
cockpit new --pr <n> --repo <name>. Always on. review_prs(per-repo, default false) spawns every coworker open PR without a worktree. Dependabot PRs are excluded unless"dependabot": true. External (non-collaborator) PRs are excluded unless"review_external": true, because a fork PR's body and diff are untrusted and a Bash-capable agent on them risks prompt injection. The gates are independent.
The seeded first turn is the configurable skills.review, run dry: report findings, ask before posting. skills.plan and skills.actions are sibling seams, each followed by the shared plan_tail.txt gate.
All four skills fields default to unset and fall back to prose cockpit ships, because a command default (even built-in /review) lives outside the wheel and can contradict review.txt's dry-run rule. The fallback is review_prose.txt, rendered into review.txt's {lead} slot by prompts.py::review_lead, the one resolver for spawn._review_prompt and build_pr_prompt's coworker branch. Do not re-add a default naming any command. Do not copy the dry-run tail into review_prose.txt.
_bg_spawn_pr guards in-flight launches in pill_state and logs to $COCKPIT_HOME/spawn.log.
_too_young_to_adopt refuses a worktree younger than _SPAWN_ADOPT_GRACE_SECONDS (120s), because cockpit new creates worktree and workspace in two steps and a poll between them spawns a second Claude.
- Guard both attach paths. The
--prform races too. - Use age, not a lock or spawn-side registration.
- Fail open: an unstattable path reads as old. Do not invert that. Not configurable.
A coworker's PR is review-mode, never author-mode. PR.mine defaults to true. nudge_issue requires mine, which also quiets the 🔔, while pills, cells and the Issue column still show the issue. build_pr_prompt branches on mine, so a coworker's PR gets review.txt, not pr_authority.txt's force-push grant. Do not add a nudge or authority grant keyed on the worktree's existence alone.
use_worktree: false repos opt out of all of the above. It defaults to true. _spawn_missing_workspaces early-returns for them. The row shows only while a workspace is open on it, and n starts one. Read it as not repo.get("use_worktree", True): a bare .get() treats an unconfigured repo as opted out. Do not let these repos reach any auto-spawn.
The live PR list carries at most one PR per head branch
Cockpit joins PR to worktree, workspace, row and cache by head branch. With two PRs on one head, {pr.branch: pr} is last-wins while match_worktrees emits both, so a workspace is spawned and _dedupe_workspaces closes it every cycle. gh.list_relevant_prs collapses through gh._one_pr_per_branch, and cache.prune_superseded_pr_caches is the disk half. Do not harden one reader instead. Fix it at the producer.
The per-branch alias returns the newest PR in any state, so gh._pr_rank prefers OPEN before updated_at and number, matching cache._pr_payload_rank. Do not rank by number alone.
Trunk-headed PRs get a synthesized branch — main/master heads never become the worktree branch
gh.py::pr_worktree_branch(number, head, base) is the single normalizer: a head in MAIN_BRANCHES becomes pr-<N>-<base-slug>, else the head verbatim. Apply it at the four join points, which must agree: _pr_from_node, resolve_pr_branch (fetches refs/pull/N/head, never touching local main), list_open_pr_heads, and _relevant_pr_query/_collect_nodes (which rejoin by embedded number). branch_label strips the token. Do not thread headRefName straight into a worktree branch.
Slack thread source — codename branch, MCP-delegated fetch, no claude mcp list probe
A Slack permalink classifies as slack mode, user-initiated only. Spawn synthesizes a codename branch seeded on the thread's identity (channel id + message ts), not the URL, so re-spawns are idempotent. _slack_prompt delegates the read to the in-session MCP. Never add a claude mcp list pre-flight gate: it reads managed connectors as absent while they handshake and would silently disable the feature. The prompt's retry-then-STOP logic handles an absent connector, in prompts/linear.txt as in prompts/jira.txt. Do not reintroduce a gating pre-flight for any provider. The one sanctioned reader is lib/mcp.py, which decides nothing.
Prompt prose lives in packaged cockpit/prompts/*.txt, not Python string lists
Prompts render via templates.render(name, **slots). Templates hold static prose and {slots} only. The Python builder owns control flow, picks the template and builds conditional blocks as slots. A missing slot raises KeyError. Do not re-inline prose into Python lists or add conditionals to a .txt. hatchling ships only VCS-tracked files, so git add new templates.
plan_tail.txt and the plan_only.txt fallback tell the session to write plan.md in the worktree root. The gate's no-code rule must carve that file out, or the two lines contradict. Do not teach it to commit the file. Spawned repos lack our .gitignore, so spawn.py::resolve_worktree writes a root-anchored plan.md line into the repo's local info/exclude (git.py::exclude_plan_file). That keeps the file out of git add -A and out of the close guard's dirty count, and git worktree remove deletes it. Do not exempt it inside count_dirty instead, and do not write it into a repo's .gitignore. Do not promote it to a cache cell, config field, or anything the daemon reads, because a session might not have written it.
The one cache exception: session-scoped cells (written outside the daemon)
lib.claude.stash_from_stdin writes context-<sid>, rate-limit-5h-<sid>, model-<sid>, permission-mode-<sid>, transcript-path-<sid> and cost-<sid> from the statusLine stdin. The daemon never reads them, except cost-<sid> to derive wt-cost. Do not extend this to a new cell. The daemon must never write one.
wt-cost — session cost folded onto a worktree, the daemon's bridge across the two keyings
The $ column totals what every session at a worktree has spent. Sessions are keyed by id and rows by path, so cache.py::write_worktree_cost_cache joins them on the fast tick into wt-cost-<cwd>. Renderers read only that cell.
- Join on Claude Code's project-directory slug, forwards only. It is lossy, so apply
_claude_project_slugonly from path to directory. Do not recover a path from a slug. - Name the stem
wt-cost, notcost, orcost_reporting_available's glob latches on cockpit's own value. - Gate the column on data, never on plan. Do not add a config field or detect the plan: the blob carries no tier, and some plans report
0. - Blank is not zero. The cell is
""for a costless worktree, since absent means "never reported" as often as free.
Nudge idle-gate: trust the idle= pill, NOT cmux's native Needs input
nudge_if_idle (lib/cmux.py) must tell "parked at prompt (safe to send)" from "awaiting a y/n permission (unsafe)". Native claude_code= has Running, Idle and the ambiguous Needs input, which fires for both.
- Block on native
Running, then on theblocked=pill. ThePermissionRequesthook sets it before the dialog shows, andStop,StopFailure,UserPromptSubmitand the next finished tool call clear it. Do not source it from the Notification hook's permission_prompt matcher, which fires about six seconds late. Send only if theidle=pill is present or native isIdle. Re-assert a dropped pill under nativeIdle, with a verify+retry loop. Never simplify the gate to trustNeeds input. reassert_idle_pillsalso runs on the fast tick, because native state vanishes on a cmux restart while the pill persists. It only writes pills, isdry-gated, and trustsRunning/Needs inputno more than the gate. Do not widen it into a second at-rest authority.- A ref with no native state is covered by
_screen_signals_idle, from two sources. Turn running? Ask the transcript (lib/transcript.py::turn_in_flight, only a definiteFalsepasses, union over every session at the cwd, cwd threaded in by the caller). Do not use the screen for that:-- INSERT --tracks composer focus, not turn state. Choice pending? Ask the screen: a marker (Enter to select,Esc to cancel,to navigate) refuses,-- INSERT --must be present, and the prompt line must be empty. It feeds only the self-heal, is cmux-only, and fails closed. Do not let the transcript reach the send gate: a running turn and a pending permission both look like one unansweredtool_use. - The hook's liveness guard compares against
cmux workspace list --json, which carries UUIDs.cmux list-workspacesprints only refs and names, so matching against it exits 0 for every session. Do not stub that listing with aworkspace:Nid. _idle_skip_reason(status_lines)is the one verdict, in the ordernudge_if_idleapplies.rest_skip_reason(ref)wraps it for display callers. Do not re-derive it at a call site.cmux.one_linecollapses every message to one line insidenudge_if_idle, before thedryprint, becausecmux sendturns newlines into Enter and submits a truncated prompt. Do not re-implement it per call site. This is why theamodal is anInput, never aTextArea.cmux.deliver_followupdelivers a spawn's seeded body by keystroke and confirms it on screen before Enter, becauseclaude_code=registers before Claude Code takes input. Retry until_screen_showsfinds the body's leading characters. A body that never appears is reported, never submitted: giving up presses no Enter. Verification fails open when the screen is unreadable or the needle was already present._screen_showsreturnsNone, notFalse, when it cannot see._FOLLOWUP_SEND_ATTEMPTSis 2, because a re-send whose predecessor landed stacks a second copy. Do not raise it for a slow spawn.- The echo covers only the first
_FOLLOWUP_ECHO_PREFIX_CHARS, so_warn_if_body_garbledre-checks the submitted body viatranscript.submitted_body. It warns and does not_queue_retry, because a garbled body still reached the session. No matching record is never corruption. Do not widen the needle to the whole body. What drops bytes is still unidentified, so this makes the loss visible and does not prevent it. - With the
/cockpit-seedtemplate installed,deliver_followupwrites the body under$COCKPIT_RUNTIME_DIR/seed-bodies/(lib/seed_bodies.py) and types only/cockpit-seed <id>. Do not add a second token path at a call site. An absent template (a stat of the file) or unwritable store falls back to typing the body. A read never deletes.writeprunes onRETAIN_SECONDS, pastseed_queue.STALE_SECONDS. Validate the id before it names a file. The template carries no lead line.
cockpit diff is the ONLY diff entry point — a CLI, because the daemon cannot be one
cmux diff defaults to the caller's own workspace and surface, which a daemon cannot supply, so the entry point is the cockpit/diff.py CLI. Do not add a TUI row key that pipes into cmux diff.
cmux.py::render_diffhas one caller. It names neither--workspacenor--surfaceand passes the environment untouched. Do not add aworkspace=/keep_surface=pair.- It takes exactly one of
patch=(stdin, the only way to show a PR) orsource=(cmux'sunstaged/staged/branch/last-turn). Do not reimplement a git source, add an in-overlay renderer or add adeltadependency. - Pass
--layout unified,--cwdand the subprocesscwd."diff"is in_CMUX_ONLY_VERBS. Comments stay local to cmux. _move_diff_to_caller_panere-homes the viewer as a tab in the caller's pane. It resolves the caller, never a target, with onetreecall. Every failure is silent and degrades to the split.close_diff_viewersrecognises a viewer by thecmux-diff-viewer://scheme, never by title, and closes by UUID, only on--ack.- Default to the PR diff. With no PR, fall back to a local diff and name the substitution. On a
MAIN_BRANCHESbranch use--unstaged, not--branch, which againstorigin/HEADshows only unpushed commits. Key it on the branch, not a dirty tree. - Use bare
gh pr diff, so no daemon is needed. The cache supplies only the title's PR number (find_pr_payload_for_cwd). A source flag reaches no network. - Read no cockpit config. Resolve with
git.worktree_root. Do not route throughclose.py::_resolve_target. --commentsprintslib/diff_comments.py's pending notes and marks nothing.--ackcallsmark_deliveredand closes the tab. Do not merge them: acking on print loses a note when a turn dies. Offer both the worktree andmain_worktree_pathas roots. Nothing pending closes nothing.- It writes nothing durable.
Two destructive primitives, and only ONE of them removes a worktree
cmux.py::cmux_close_workspace_best_effort(ref)closes a session and touches nothing on disk. Direct callers: dedup, parking (h), the anchor husk swap, dissolving a trailing fold.teardown.py::teardown(TeardownRequest)closes the workspace, removes the worktree, deletes the branch and drops the PR cache. It owns the self-close ledger.TeardownRequest.worktree_pathis the whole difference. Autoclose andc/C/cockpit closepass it._reap_workspace_orphanspassesNoneand gets a workspace-only close plus a delete of a<login>/branch ref. One path removes a worktree, guarded once: a dirty tree or unlanded commits refuse bothcandC.- A new destructive trigger builds a
TeardownRequest. It does not open a third path. - Autoclose skips a muted PR, mine or a coworker's, reading
ctx.prefs. Do not extend the hold to snooze: a merge ends the turn a snooze waits on. - Never call
cmux("close-workspace", …)raw. Theworkspace.closedevent looks like the user's sidebar X and routes into teardown. - The stale-branch-ref reaper is the one destructive action outside both. Do not copy it as a precedent.
The daemon makes exactly THREE automatic sends, and only one of them is a new message
The set is closed: the PR nudge (cycle.py, slow tick, PR.nudge_issue, silenced by m/z via pref_key), the diff-comment hand-over, and the seed retry (cockpit.py::_drain_seed_queue). Everything else is typed by the user (a, A, cockpit broadcast) and passes no pref_key. A fourth must derive from an actionable defect the session can fix. Judge it by whether it originates with cockpit or finishes something the user started. The queued ask below is a retry of a typed line, not a fourth send.
- Queued ask (
lib/ask_queue.py,cockpit.py::_drain_ask_queue): whena/Ameets a session refused only as busy (cmux.rest_pending, neverparked), queue the line and re-offer it throughnudge_if_idleon the fast tick. Use its own directory, not the seed queue's, since both key on ref. Drain after the seed drain. The marker pins the cwd at enqueue and is dropped if the ref moved. A queued ref leaves the retry set (_ask_misses). The toast keeps the gate's reason.STALE_SECONDSis 600 and is correctness. A failed enqueue keeps the draft. - Diff-comment hand-over (
cockpit.py::_nudge_diff_comments): sendsDIFF_COMMENTS_NUDGEthroughnudge_if_idleto the session in a worktree with pending notes. Do not give it a second send path or generalise it into a fan-out. Pass nopref_key, since a note the user wrote is not noise. Dedup on the comment-id set, not the worktree. Record only on an accepted send. Run afterreassert_idle_pills. Gatedrythrough the gate's owndry=. - Seed retry (
lib/seed_queue.py): one durable JSON marker per workspace under$COCKPIT_RUNTIME_DIR/seed-requests/. Queue only where the body did not reach the composer, never on a failed Enter, which would type a second copy.cmux.py::_queue_retryis the one writer and always returns False. Key by ref, so a respawn supersedes.STALE_SECONDSis 300 and is correctness. Do not lengthen it. Drop a marker whose ref is not live. Retire only on an accepted send. Isolateseed_queue.STATE_DIRintests/conftest.py::_isolate_runtime_dir, since it bindsCOCKPIT_RUNTIME_DIRat import. - The 📝 column is a cue, never an input. Nothing reads the cell back. The same holds for ✋ (
session-wait, the fast tick's copy of theblocked=value).
actions.jsonl — an append-only log nothing reads back
lib/action_log.py::record appends one JSON line to $COCKPIT_RUNTIME_DIR/actions.jsonl for "why didn't it nudge me" and "who closed this". It is a log like spawn.log, not cache.
- Never read it back: no cell, pill, decision or TUI row. It is not inventory.
- Runtime dir, not
$COCKPIT_HOME: arefis machine-local and the home may be synced. - Three choke points call it:
cmux.nudge_if_idle(sendorskipwith the gate's reason, actor fromtag),teardown.teardownand the spawn path (cycle._bg_spawn_pr,spawn.py). Do not add a call site per caller. - Dry runs log nothing, because nothing happened.
- A
skiplogs only when its reason changes for that(actor, ref)(_last_skip), because the fast-tick drains re-offer a busy session every 30s. Do not drop the dedup: unchanged refusals rotate real sends out. - It never raises (
OSErroris swallowed), oneO_APPENDwrite per line, one.1rotation pastMAX_BYTES. sessionis filled only when the caller already holds it. Do not look it up in the logger.
cockpit broadcast reuses the nudge gate — no second send path, no cache cell
cockpit/broadcast.py is a one-shot gesture: no cell, pill or pill_state, and skipped refs are printed, never queued. Do not give it its own send path, idle check or cache cell. Extend nudge_if_idle.
--repoand--worktreefilter the one loop. Never make either a second scope.--repomatches workspace cwds against the repo's ownworktrees()via_repo_paths(cwd match, see Shared rules). Name the repo by_repo_label, casefolded. Do not accept the path basename, which is.barefor every bare clone. An unknown name exits 2 and lists the repos.- The unscoped path reads no config, because broadcast reaches unmanaged workspaces.
--worktreeis an exact cwd match, not a prefix and not a single ref, since ause_worktree: falserepo hosts several sessions at one cwd. A non-directory exits 2. It reads no config and excludes--repo.
Nudge prefs are keyed per repo — a PR number alone is not an identity
NudgePref persists one JSON file per PR at $COCKPIT_HOME/cache/nudges/<repo>__<number>.json, keyed by the git nwo name, as the PR cache files are. Every entry point threads it, including _resolve_row_pref (via _cache_repo_name, not the config name) and cockpit nudge's _resolve_pr, which exits 2 rather than fall back to a bare number.
- Keyed by number alone, two repos' PR #10 share a file: a mute silences both, and each cycle wakes the other's snooze. Do not add a call site that invents a key without a repo. Do not default
repo_nameto"". load_preffalls back to a legacy<number>.jsonand never unlinks it, since several repos may read it.- A worktree with no PR gets no pref and no nudge.
_refresh_orphanapplies pills only. Do not answer "an abandoned worktree should nag" with a send. Use a derived cell.
Backend capability gate — probed once at startup, warns and degrades, never dies
lib/capabilities.py checks the backend is new enough on two axes: REQUIRED_VERBS (each mapped to the tier it disables) and REQUIRED_CAPABILITIES. Verbs are parsed from cmux --help. A parse miss warns.
- A missing
capabilitiesverb is the too-old signal. Report it as its own warning, not as "no capabilities". An empty verb set warns about nothing. - Warn, never die. Do not promote any of these to
sys.exit(2). - Cache in
capabilities.probe, never inresolve_tool, so tests can vary PATH and config. - Probe in the daemon only, since
cockpit setupmay install the backend. Skip when the backend is not cmux. has_capability(id)gates features built on the baseline. Pair it withis_cmux().- Every entry names a tier cockpit has. Do not add one for a feature that does not exist.
workspace.groups.v1is required becauseworkspace-groupis absent fromcmux --help'sCommands:list. Do not addworkspace-grouptoREQUIRED_VERBS. Do not narrow its capability. Do not write verb counts into prose. The map isdocs/cmux-surface-audit.md, and its live half istests/e2e/test_cmux_surface.py.
$COCKPIT_HOME may be inside a file-sync folder — write pid-scoped, warn on conflicts
config.py::_atomic_write_textputsos.getpid()in its temp name. A fixed<name>.tmplets the losing process's content land under the winner's name. Do not go back to a fixed suffix. Do not re-inline the write at aconfig.pycall site. A sibling module owning its own state dir may repeat the pattern, asseed_queue.enqueuedoes. The guard is the literalos.replace.preflight._warn_sync_conflictsonly surfaces a conflicted copy, because the process cannot resolve it. Match onlyconflicted copyand.sync-conflict-. Other spellings look like ordinary filenames, and a false alarm trains the user to ignore a real warning.
Machine-local runtime state lives in $COCKPIT_RUNTIME_DIR, never $COCKPIT_HOME
config.COCKPIT_RUNTIME_DIR owns PID_FILE, daemon_signal.STATE_DIR and seed_queue.STATE_DIR. It does not follow COCKPIT_HOME. These files key on local cmux refs. Under a synced home, machines would fight over one pidfile, drain each other's close queue and type each other's prompts.
- Not
$TMPDIR: the pidfile is an IPC rendezvous, andTMPDIRresolves per launch context. - Not a hostname stamp:
gethostname()drifts on macOS. - Name the legacy files, never read or delete them. Reading honours a close from an unknown machine, and another machine may still use them.
- Tests set the env var, not only module attributes, because fixtures reload
lib.config. Fixtures isolating onlyCOCKPIT_HOMEdo not cover the runtime dir. - The two
COCKPIT_HOME-inspectingpreflightwarnings need a hermetic home in tests.
Config surface has three faces — keep them in sync
cockpit/lib/config.py is the authoritative reader. It has two mirrors: cockpit/config.example.json (documentation only, never installed) and docs/config.md. When you change a config field, update all three in the same PR. tests/test_config_faces.py fails on a key config.py reads, or a CONFIG_FIELDS entry, with no docs/config.md row. Prose and the example's coverage stay unchecked. Provider ticket fields also flow through the provider's CONFIG_FIELDS.
tickets config — the one provider selector
tickets is an object: {provider, close_on_merge, dev_done, merge_done} plus per-provider extras. A bare string is shorthand for {provider: …}. Fields and defaults live in docs/config.md.
- One field per concept across providers. Do not re-split
dev_done/merge_done/token_envper provider.projectandboardstay distinct. Trello keepskey_envandtoken_env. GitHub rejectsmerge_done. - One name per field. Do not re-add an alias arg or a "guess the provider from a sibling field" fallback.
- Resolve per field: repo block, global block, default. Provider selection uses
_tickets_block, where the repo's whole block wins. tickets::provider_for(cfg, repo_entry)returns theTicketProvider, the single source of truth fordev_done_value,parse_footers,fetch_states,fetch_titlesandnarrow_repos. Do not addprovider == "github" ? …ternaries. Add aTicketProviderfield.spawn.pypicks the prompt from a(mode, provider)table (_TICKET_PROMPTS). Add a provider by adding its pair. It readsrepo_tickets, never the globaltickets(). It runs after every routing hop, or it resolves the cwd's repo, not the target's. The ticket-key routing gate is per-repo for the same reason. With no matching pair, it falls to_plan_only_prompt(..., source=…). Do not drop thesourceslot.- Credentials are env-only. Config holds the variable name, never the value. Warnings, logs and errors name the variable, never the value.
cockpit config ticketsexits 2 for a cwd outside every configured repo.start_labelis the one spawn-time tracker write (GitHub, opt-in, best-effort).linear_team_keysis provider-neutral, since Jira declareskeystoo. Pair it with the resolved provider.- To add a setting, put it in the provider's
CONFIG_FIELDSand add a reader inconfig.py. Do not use a hardcoded preflight list or a top-level flat key. Do not flatten the per-provider schemas into one dict, since names repeat across providers.
Ticket→repo routing has two stages: a free match, then a paid tiebreak. find_repos_by_ticket_key over tickets.keys is offline. TicketProvider.narrow_repos (on tickets.project, one fetch) runs only when the free match returned more than one. It never narrows to zero. It groups candidates by resolved credential, because a team key is workspace-scoped and another workspace answers about a different issue with the same identifier. Do not collapse this to one key read off candidates[0].
spawn.py must not branch on a provider name. _route_by_ticket is the shared tail.
- Linear: free-match
keys, tiebreak onproject. - Jira: free-match the same
keysfield and reader. It stays_no_narrow. Do not give it aprojectfield or duplicatefind_repos_by_ticket_key. - The shape gate stays
LINEAR_RE_CI. Widening it reclassifies branches likefeature2-1as tickets. - Trello: no free match. The
tickets.boardopt-in is the discriminator (a list is allowed). With none declared, the spawn makes zero network calls. - GitHub needs neither stage.
Trello alone has a third stage, tickets.label (tickets::_narrow_by_label), for two repos on one board. Do not route on the card's list, because the list is state. A label is orthogonal to it.
- It runs only on a board tie where some repo claims a label.
- A repo with no label is the board's default and takes every unclaimed card. A claimed label wins over the default.
- It never narrows to zero. An inconclusive fetch or an unmatched card leaves the board match, and the caller refuses.
fetch_card_labelsreturnsNonefor "couldn't ask" and[]for "no labels". Do not collapse them, or an API blip hands the card to the default repo.- It is its own
GET. Do not add it tofetch_card_board, which must cost one fetch. - Routing only. It never reaches the inbox scope, where
boardstays the only scope.
A ticket URL is a first-class source for every provider. Linear and Jira URLs extract the identifier and take the same mode as the bare id. Do not pass them verbatim the way slack/trello mode does, or git worktree add -b <URL> fails.
Per-org credentials come from the env-name indirection, a _tickets_field call, so an org block covers its members.
- Identity caches key on the resolved secret, not the variable name (
_secret_fingerprint), or org B reads org A's viewer id. - Spawned sessions get no ticket credentials.
_bg_spawn_prpasses the env minusconfig.credential_env_names(cfg), because areview_prssession runs over an untrusted diff. Strip by resolved name, never a prefix guess, and touch nothing else (PATH,COCKPIT_HOME,CMUX_*pass through). - The first-turn prompt states the repo's tracker identity (
spawn.py::_tickets_block), since a session cannot derive it. It names no credential variable. - An unset credential warns at startup for every provider through
TicketProvider.credential_envs. The provider names its own variables. Do not add a provider-name ternary. Resolution is the repo's. Keep it in step withcredential_env_names, which a test pins.
devdone= pill — the ticket provider is the one auxiliary (read-only) state source
The pill needs repo_tickets(...) != "none" and a PR-body delivery footer. Use footers only (provider.parse_footers), never a branch slug or bare mention. The {provider, tickets, fetched_at} block is cached in the PR JSON under ticket.
_prefetch_linear_blocks decides refetch or carry-forward per PR (footer-id change or TTL). It resolves the union of due ids across all a repo's PRs through provider.fetch_states. Do not fall back to a per-PR fetch fan-out. It runs before the write loop. A per-source failure isolates to its own ids. title never feeds a decision. _track_dev_done raises the pill only when every delivered ticket equals provider.dev_done_value(...), casefolded. The pill never sends.
done-on-merge — the daemon's only sanctioned tracker writes, dispatched per provider
Opt-in via tickets.close_on_merge. _transition_merged_tickets dispatches on provider.name, fired on _is_post_merge_stale independent of teardown. Each writer is guarded by a per-run marker in pill_state, viewer-gated, idempotent and logged.
- Linear:
issueUpdatetomerge_done. Skip unless assigned to the API-keyviewer, or if already at target or canceled (both arecompleted, so name equality decides). Cache the viewer id and team state maps. Fetch the viewer lazily. - GitHub:
gh issue closeon each delivered issue still open and assigned to the auth login. - Jira: REST transitions API, since Jira moves by transition.
- Trello: move the card to the list named
merge_done(no default). Skip unless I'm a member.
Never cache a failed identity fetch. A failed write clears the marker to retry. Any future daemon tracker write must be opt-in, viewer-gated, idempotent and logged.
Failed background spawns — the other surface NOT derived from git worktree list
A PR whose daemon-launched cockpit new --pr child failed has no worktree, hence no row. The child records why; the daemon publishes it; the TUI shows it.
- The child writes its own verdict.
spawn.py::mainwraps_runfor--pronly. A non-zero exit or an unexpected exception writes$COCKPIT_RUNTIME_DIR/spawn-results/<repo>__<N>.json(lib/spawn_results.py) with a consecutivefailurescount. A success deletes it. Do not write one for another source mode. - Key by the repo's nwo plus the PR number, as
NudgePrefdoes. The confignameis only the fallback whenghcannot say, so the daemon matches either. Do not key by number alone. - Machine-local, in the runtime dir and never
$COCKPIT_HOME, for theseed_queuereason.tests/conftest.py::_isolate_runtime_dirredirectsspawn_results.STATE_DIR. - Only the daemon writes the cache and retires markers.
orchestrators/spawn_failures.pyis shaped liketicket_inbox: the per-repo passadd()s,publish()drains once, and only whenonly_repo is None. The title comes fromctx.prs. Do not add a network call. - Retire a marker when its PR is no longer open or its branch has a worktree. A repo that did not report keeps its markers.
iter_pendingreturnsNonefor "couldn't list" and[]for none, andNonewrites nothing.SpawnFailures.partialsuppresses the write, asTicketInbox.partialdoes. SpawnFailuresScreenand the#header-failuressegment read the payload only. The segment is auto-width and absent at zero, so do not give it a1fror place it right of#header-status. The count is fed fromapp._render_table, never from_refresh_footer_caps, which runs on every arrow key. Text passesstrip_control.- Do not add a retry or back-off here. That is
_bg_spawn_pr's in-flight guard.
The brief (b) — a diff baseline nothing else reads
cockpit/lib/brief.py keeps brief.json under $COCKPIT_RUNTIME_DIR. cycle_all calls brief.record on a full, non-dry cycle. On the first record of a new day, the previous last becomes baseline. BriefScreen diffs baseline against the live PR payloads.
- Never let a decision, cell, pill or send read brief.json. It is a diff baseline only.
- Never re-baseline on
b. The baseline moves only on a day change, so the brief stays the same all day. - Do not record under
only_repo. A scoped cycle is a keypress, not a tick. - Do not auto-open it or add a send. It is pull-only.
- Isolate
brief.STATE_PATHintests/conftest.py::_isolate_runtime_dir, since it binds at import.
The ticket inbox — one of two surfaces NOT derived from git worktree list
T opens TicketsScreen: tickets assigned to me, in an active state, with no worktree. cycle.py::_collect_ticket_inbox collects per repo, orchestrators/ticket_inbox.py::publish drains once, and cockpit/tui/widgets/tickets_screen.py renders.
- Shape it like
ReviewFolds. The per-repo pass only records and the cross-repo pass fetches. Build it only whenonly_repo is None. - The fetch groups by resolved credential and the payload keys by org, because asking the wrong workspace answers about a different issue with the same identifier. Group on
(provider, credential, bucket). Do not collapse the two axes. The group key is the env-var NAME (TicketProvider.credential_envs), never the secret. fetch_my_openreturnsNonefor "couldn't ask" and[]for "answered with nothing" (see Shared rules). The distinction reachesTicketInbox.partial. An incomplete cycle suspends every bucket and a failed group suspends the buckets it feeds. A suspended bucket keeps its old payload. Do not re-key this on the bucket being empty.- The collector never branches on a provider name.
inbox_scopesis aTicketProviderfield. - An empty scope means "ask about everything" except for Trello, where it means "ask about nothing" (
tickets._trello_my_open), because a Trello account spans every board its owner joined.tickets.boardis required here and takes a list (config.py::trello_boards). Undeclared returns[], not None. - The inbox is a payload, never a flat cell (
cache.py::write_ticket_inbox), because an unstarted ticket has no worktree path or session id. It has no TTL. - Stamp
in_flighton both ticks and always write it, includingFalse(the_stamp_ticket_urlsrule).publishstamps on the slow tick andcache.py::stamp_inbox_in_flighton the fast tick, both throughticket_inbox.py::active_ids, which takes its inputs and fetches nothing (see Shared rules). ticket_inbox.py::_drop_donedrops tickets whosestatematchesTicketProvider.done_values, casefolded. Those are the user's owndev_doneandmerge_done, so it takes no config field of its own. A bucket it empties is still written, because[]is an answer.tickets.inbox_states, when set, IS the whole filter and skips_drop_done. Half-replacing would re-hide a state the user listed.config.ticket_inbox_statesresolves per repo and a fetch group takes the union. Linear filters server-side and case-exact. Do not add client-side casefolding for Linear. Jira and Trello filter client-side, casefolded. GitHub ignores it, andpreflight._validate_inbox_stateswarns.trello.py::_board_and_list_namesresolves names, because/members/me/cardsreturns ids and ignoresboard=true/list=true. A failed names call returns None, not partial cards. Archived boards drop their cards. An unknown board id is not archived. A Trello row shows#<idShort>ashandle, andidstays the key for every join, dedup andin_flightmatch.- The screen reads payloads only: no fetch, no git, no
load_configper keypress, no cell written. Pass tracker text throughstrip_control(cache.py), because it bypassesread_text. - The app computes routing candidates (
app._ticket_routes) and passes them asroutes. Do not derive them in the screen, becausefind_repos_by_ticket_keyreadsload_config()per call. Do not callnarrow_reposfor a marker, because it is the paid stage. - Give the columns explicit widths. Do not go back to
add_columns. An auto column resizes on idle while the cell render cache ignores the width, so rows cache at a stale width. Ellipsize each cell to the column cap plus 1, since_ellipsizeleaves a string one over the limit alone. - Each org is a fold and
enteron a header toggles it through_rebuild(). Every org starts folded except a lone one. Folds are session-only. tickets_screen.py::ticket_sourcereturns the ticket's URL, falling back to its id, andapp._start_ticketshells out tocockpit new. Pass the URL, becauseowner/repo#Ndoes not classify.enterpostsTicketsScreen.Startand the overlay stays up. Onlyescapedismisses. Do not give the screen a dismiss value. It is aModalScreen[None]pushed with no callback.- A started row shows
STARTED_STATEand a secondenteron it is a no-op, becausecockpit newruns detached and a double-tap cuts a-2path. Key_startedby spawn source, not row key. The mark is optimistic, so every declining path (the--drygate, an unroutable ticket, a cancelled picker) releases it throughapp._release_ticket→TicketsScreen.release. Do not make the mark a payload field. _start_ticketpasses--repoexplicitly (_with_repo, shell-quoted) and refuses when routing cannot resolve one, because its cwd is the daemon's own. Route throughspawn.route_ticket_repos, which sharesticket_repo_candidateswithcockpit new, from a@work(thread=True)worker. A URL with its own nwo is still checked against the config. Do not re-add a per-provider waiver. Do not default to the cursor row's repo._route_ticketbranches on the surviving set fromroute_ticket_repos. One name spawns, none refuses through_refuse_ticket, several ask through_pick_ticket_repo→RepoPickScreen. Never re-derive the set. Treat a raised route as no candidates. The picker pre-selects nothing, because a seededSelectpostsChangedon mount. Compare againstSelect.NULL. Do not useSelect.BLANK, which is a plainFalsein Textual 8.x.- The Ticket column shows
?(ambiguous) or!(nothing claims it) from stage one only, so opening the modal reaches no network. A ticket stage one cannot answer for is absent fromroutes, not[]. The marker takes its width from the handle's budget (_MARK_SLOT) and must never widen a column. Do not add a Repo column. - The inbox is passive: no daemon-derived cell, no send, no config field.
Tis not--drygated._start_ticketis.
c in the inbox diagnoses one bucket — lib/ticket_check.py, the only ticket surface that may ask a tracker a question the daemon never asks.
- Ask through the
TicketProviderand never branch on a provider name.whoamiproves the credential works.verify_scopesreports which declaredtickets.keys/tickets.boardthe tracker knows. verify_scopesasks about the declared scopes, never for a listing, because a paginated listing can push a scope onto page two and report it missing.- A failed connection suppresses the scope verdict.
whoamiruns first andverify_scopesonly on a pass. Noneand[]stay distinct into the report.unknown_scopes is Nonerenders as "not checked", never "all fine".- Report a credential by env var NAME, as
bool(os.environ.get(name)). A value never enters the report. lib/mcp.pyprobes the declared MCP server throughclaude mcp list. It is cockpit's one probe, allowed because it gates nothing. It returns None for "couldn't read it", never{}._mcp_verdictreports "not listed" as a probable miss, never a verdict. Run it in the repo's cwd, dedupe per cwd across a bucket, and skip it for a repo declaring no server. Do not let it reach a tick, a cell, or any gate.- The check is read-only and not
--drygated. It writes no cell, pill,pill_stateor tracker state. check_buckethas oneon_repohook, read byCheckProgressScreen, for progress and cancel. Push the overlay on the UI thread before the worker starts. Cancel by raising out of the hook. Do not useworker.cancel(), which stops only a queued worker. A cancelled run pushes no report.- Check per repo, not per credential, so a repo whose
ticketsblock overrides the org's is reported.con an empty inbox checks every bucket.
gh api ignores the cwd — the host is stated per call, never process-wide
gh pr view and gh repo view infer the host from the cwd's remote. gh api and --repo do not: they answer on the default host with HTTP 200 and an empty result, so a tenant renders zero rows and list_relevant_prs reports success.
git.py::origin_host reads the host from the remote with no network. gh.py::gh_env states it as a per-child GH_HOST. Never set GH_HOST on the daemon process, since it would redirect every github.com repo. Never thread cwd= into an api call.
""means "state no host".origin_hostreturns""for github.com and an unparsable remote,gh_env("")returns None, and None is byte-identical to the host-unaware call. A wrong host is worse than the default._graphql'shostis a required parameter, so each new call site must choose. The two internal phase helpers default it.- The login is per host (
gh_self_user(host), cached;self_user_for_hostis the tolerant wrapper).PR.minegates the nudge, the review-vs-author prompt and the force-push grant, so a github.com login on a tenant repo makes every PR a coworker's._prepare_cycleresolves the host beforeself_user. The reaper resolves it per owning repo (_my_prefix). Both fall back to the process-wide login, which acts less. github_issues.pyderives the host at_gh_json, its one I/O seam. Do not thread it through the fetchers orTicketProvidersignatures.preflight._warn_unauthenticated_hostsis the only detector, since the failure looks like success. It usesgh auth token --hostname, never reads the token, and only warns.- There is no
hostconfig field. Add one only for an origin the remote can't name (aninsteadOfrewrite, a mirror on a third host). Known gap:fetch_run_info's-R <nwo>is host-blind.
A null reviewDecision is not "no approval" — gh.py::_review_decision falls back to the reviews
GitHub returns reviewDecision: null on an approved PR when a ruleset declares the required reviewers (the same GraphQL blind spot as dismissesStaleReviews). Defaulting null to REVIEW_REQUIRED hides the approved pill that decide_pills keys on it.
- A reported decision always wins. The fallback runs only on null.
- Only APPROVED, CHANGES_REQUESTED and DISMISSED are verdicts, per reviewer, latest wins. COMMENTED and PENDING keep the prior verdict. CHANGES_REQUESTED beats APPROVED across reviewers.
- Exclude the PR author's reviews and every Bot review, matching
_unaddressed. - Derive it at the one construction site, so
primary_issue, theapprovedpill,update_branch_skip_reasonandwake_signatureagree. Do not re-derive approval at a renderer or gate.
update_stale_branches — the daemon updates a PR head server-side, never by rebasing the worktree
Opt-in, slow-tick _update_stale_branches.
- Trigger on
mergeStateStatus == "BEHIND". Never use the localbehind_of_basecount orbase-distance, which fire on every repo without the protection rule. - Update with the
updatePullRequestBranchmutation. Do not replace it with a local rebase and force-push: a conflicted rebase strands arebase-mergestate that wedges teardown. expected_head_oidis mandatory. It is the compare-and-swap, like--force-with-lease. Do not drop it.- Scope it to approved or snoozed PRs, the quiescent states. Do not widen it to every stale PR.
- Under stale-review dismissal, a new commit discards the approval.
update_branch_skip_reasontherefore refusesAPPROVED and <dismisses>. Do not remove this gate. - The dismissal verdict has two sources.
dismissesStaleReviewscovers classic branch protection only, and a ruleset-only repo reportsbranchProtectionRule: null.gh.branch_dismisses_stale_reviewsreads the merged effective rules, and the two are ORed. Do not gate on the GraphQL field alone. - The ruleset lookup fails closed, the one place cockpit does.
Nonemeans "dismisses", because a wrong answer silently discards an approval. Only the approved half pays for it. Do not invert it for symmetry. - Key the marker by head oid. A failed mutation pops it. A skip keeps it.
- REBASE rewrites the head, so
git.resync_to_originreconciles the worktree. Its guard is also a compare-and-swap: refuse unless the tree is clean and HEAD equals the pre-update sha. Do not weakenexpected_headto a dirty-check, which would reset away unpushed commits.
Dev setup and common commands
# One-time after cloning — wires pre-commit hooks for commit + push stages:
./setup.sh
# Run THIS worktree's build against a throwaway sandbox (never `uv run cockpit watch`,
# which shares state with the installed daemon — see below):
./dev.sh
# Run the test suite serially — right for a single test or a small selection:
pytest tests/test_spawn.py::test_linear_key_routes_to_matching_repo_without_repo_flag
# Run the whole suite with -n auto (near-linear parallel speedup).
# `addopts` omits it because worker boot is pure tax on the single-test line above:
pytest -n auto
# Coverage report — `--cov` is not in `addopts` for the same reason.
# `coverage.yml` runs it nightly against a floor:
pytest -n auto --cov --cov-report=term-missing
# Type-check:
mypy cockpit/
# Lint + format via the pinned pre-commit hook, scoped to your files:
pre-commit run ruff ruff-format --files <changed paths>
# Audit the workflows for security issues after touching .github/:
pre-commit run zizmor --all-filesNever lint or format with uvx ruff or a global ruff. uvx pulls the latest ruff, whose rules drift from the pinned version and rewrite files you never touched. The pinned hook is what CI enforces.
./dev.sh — five isolation axes, and none of them is optional
uv run cockpit watch from a worktree is not a dev run: it shares all state with the installed daemon. dev.sh seeds a .cockpit-dev/ sandbox. Each axis blocks a different path to real damage:
COCKPIT_HOME→config.jsonand the PR cache.COCKPIT_RUNTIME_DIR→cockpit.pidandclose-requests/. It does not followCOCKPIT_HOME. If shared, everycockpit closein every worktree routes into the dev build and runs the realteardown.TMPDIR→ the flat cells live undertempfile.gettempdir(), notCOCKPIT_HOME. Isolating only the home leaves the fast tick repainting the user's live footer.tool: none→ every cmux write becomes a no-op through the existing gates. Use that validated config value, not a dev-only code branch.--dry→tool: nonedoes not cover_maybe_autoclose, which removes worktrees and runsgit branch -Dthrough git.--dryalso gates tracker writes. It covers the reconcile cycle (ctx.dry), the fast tick (state["dry"]), and the TUI row keys that reach outside (n/f/h/a, via_blocked_by_dry). It does not gatec/C(they only enqueue) orm/z(they touch onlyCOCKPIT_HOME). Do not narrow the gate back to the cycle.
dev.sh forces --dry onto every watch invocation. Do not re-hardcode dry=False in cockpit.py, and do not add a second dev-only suppression path. --dry also suppresses cache writes, so snapshot mode copies the real PR JSONs in.
dev.sh refuses cockpit setup (exit 2), because setup bakes a .venv/bin/python that dies on cleanup. It also refuses new and close, because tool: none and --dry do not gate git and its config points at the real repos. Do not add a subcommand that mutates through git without a refusal beside it.
.claude/skills/ is repo-local dev tooling — never an install target
A skill here loads only for people working in this repo. The wheel ships packages = ["cockpit"] and cockpit setup installs from cockpit/claude_commands/, so nothing here is installed and nothing owes a teardown inverse. Do not confuse it with cockpit's ~/.claude footprint.
- A skill wraps a command instead of reimplementing it, like
cockpit-devover./dev.sh. - The
descriptionloads into every session. Keep it under 250 bytes and put the procedure in the body. allowed-toolsis required, with least privilege.- A skill fits only a judgment nothing else can enforce. A
PreToolUsehook cannot block an edit toCOVERAGE_FLOORin.github/workflows/coverage.yml, socoverage-auditcovers it. Do not put a rule here that a hook, a test, or this file would hold better.
.github/workflows/tag.yml's checkout must keep its credentials
tag.yml is exempt from zizmor's artipacked rule. Its checkout credentials authenticate the git push origin "v$v" that fires release.yml and publish.yml. Adding persist-credentials: false breaks every release silently, and zizmor accepts the change. Do not "fix" it.
Release versioning
Release mechanics live in docs/releasing.md. Read it before touching any release workflow or release-please-config.json.
- Never hand-edit
CHANGELOG.md. release-please owns it. - Never remove
skip-github-release,skip-labelingor thechore(main): releaseguard fromrelease-please.yml. Each removal breaks releases silently. - Never remove
create-branch: falsefromrelease.yml. The tap'smainis protected, so the formula update would never land. - Never edit
publish.ymlto fix a PyPIinvalid-publisherfailure. The fix is on the PyPI side.
Commit / PR-title convention
We squash-merge, so the PR title becomes the commit subject on main. Use Conventional Commits: feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert. pr-title.yml enforces it as the required lint-pr-title check. Local WIP messages are unconstrained.
Branch protection plus a repository ruleset guard main, so a plain gh pr merge fails even when green. The ruleset requires approving and code-owner review. A solo author cannot approve, so merge with gh pr merge <N> --squash --admin. Check the ruleset with gh api repos/khivi/cockpit/rulesets.
Test layout
New modules get their own test_<name>.py. Do not append tests for a new source file to an unrelated module. Shell hooks under cockpit/hooks/ live as tests/test_<hook>.py with no Python source mirror.
The suite cannot reach the live machine — tests/conftest.py, and it is not per-test
The backend has no path to redirect: cmux create and cmux send act on the live sidebar. _no_live_backend + _isolate_cockpit_home block it, and tests/test_suite_isolation.py pins the property.
- An extracted helper takes its input; it does not fetch it (see Shared rules). Patching a name in one module never rebinds another module's copy, so a helper that fetches its own input goes live under tests.
refs_at,skip_summaryandgit.repo_worktree_pathsare pure for this reason. Do not make one callworkspace_cwds()orworktrees(). - Three layers block the backend:
subprocess.Popen,shutil.whichfor cmux/limux, andconfig._atomic_write_text(raises inside the real$COCKPIT_HOME). - The guard fails loud, never inert.
cmux(..., check=False)returns""on a missing binary, which hides the leak. - The guard keys on where the executable resolves, not its name. A test may build its own fake
cmuxundertmp_path(astests/lib/test_events.pydoes). @pytest.mark.real_backendis the only opt-out, used by thetests/e2e/files. Append it (pytestmark = [pytestmark, ...]), because a secondpytestmark =replaces the first.
Test style by layer
- Leaf modules (
cockpit/lib/*wrappinggit,gh,cmux,subprocess.run) test against the real tool ontmp_path. Stubbing the command tests the stub. - Orchestrators mock collaborator calls to assert ordering and gating. CLI entry-points mock at the orchestrator boundary.
- TUI (
tests/tui/*) drive the app headlessly viaApp.run_test()/Pilot with the ticks andload_configinjected. - End-to-end (
tests/e2e/*) run real binaries. Reserve them for cross-layer behaviour. - A gate that stands in for a third party's readiness must verify the outcome, not trust the proxy.
cmuxtests use stubs, and a stub only re-asserts the belief it was written from. Do not add a call site that infers a third party's readiness from a signal it emits about itself and then acts irreversibly. Confirm the effect, and report when it cannot be confirmed. tests/e2e/test_followup_delivery.pymutates live state, so it needsCOCKPIT_E2E_LIVE_DELIVERY=1on top ofreal_backendand cannot ridepytest -n auto. Do not promote it to a regression test for the readiness race, and do not read a green run as proof the race is handled.- Repo-wide invariant tests (
tests/e2e/test_cmux_surface.py,tests/test_comment_references.py) assert facts about the tree. The latter checks that everybacktickedsymbol and path resolves, in code comments and in this file. Leave a retired name unbackticked. Do not park a stale reference in their allowlists.
Invariant coverage — specs/ is the ledger, and every bullet must be claimed
specs/*.md is the human-owned behavior spec: - [<id>~<rev>] <what the system does>, one bullet per invariant. This file holds the rule and its Never; the bullet holds the behavior. A test claims a bullet with @pytest.mark.covers("<id>~<rev>"). rg 'covers\(' tests/ maps test to id.
tests/test_invariant_coverage.py fails on a marker naming an unknown id and on an unwaived bullet no test claims. Editing the spec is how you demand a test. Bullets flow spec to test, never the other way.
- Bump
~<rev>only when a bullet's claim changes. That fails every test still claiming the old revision until you re-check it and bump its marker. (untested: <reason>)waives a bullet nothing runnable can assert. Waivers count againstWAIVED_COUNT, so adding one is a deliberate edit there. A test claiming a waived bullet fails.- A claim proves a guard exists, not that it is strong. The
spec-auditskill is the advisory check. It proposes, never edits, and never gates a merge. - Do not generate the spec from the markers. That would review nothing.
docs/specs.md covers the procedure.
Sync
AGENTS.md is canonical. CLAUDE.md imports it and .github/copilot-instructions.md symlinks to it. Edit only this file.
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

