Development¶
uv sync && uv run pre-commit install once after cloning. Commits then run ruff, mypy --strict,
complexity limits (complexipy, lizard, and a shell checker in tools/), shellcheck, shfmt, hadolint for
the Dockerfile, actionlint and zizmor for the workflows, spelling, the links between the docs, a build of
the docs site, a secrets scan (detect-secrets) and the unit tests; pushes also run the smoke suite. A
forbidden-terms check (tools/forbidden_terms.py) refuses staged files and commit messages that
contain a term from ~/.config/bosun/forbidden-terms.txt (one case-insensitive regex
per line, # comments), a list you keep outside the repo of names that must never be committed:
your organization, products, repos, colleagues, internal hosts and ids. Without the file it
passes. uv run pre-commit run --all-files checks everything by hand. See CLAUDE.md for the
limits and conventions.
On GitHub, every pull request to main runs the same checks (.github/workflows/ci.yml), the
smoke suite on macOS and a gitleaks scan of the new commits, and main only takes pull requests
that pass them. The unit tests there also keep a floor under coverage. For the forbidden-terms check to run there too, store your list as the
FORBIDDEN_TERMS Actions secret with gh secret set FORBIDDEN_TERMS <
~/.config/bosun/forbidden-terms.txt; the logs never print what it matched.
Issues and pull requests are welcome: see CONTRIBUTING.md and the code of conduct. Report security problems privately, as SECURITY.md describes.
The docs site¶
The guides, design notes and plans in docs/, with README.md as the home page and ROADMAP.md,
CONTRIBUTING.md, SECURITY.md and CODE_OF_CONDUCT.md, are also a website:
promptromp.github.io/bosun, built with
MkDocs and Material for MkDocs
from mkdocs.yml. .github/workflows/docs.yml publishes it each time main changes them.
uv run mkdocs serve # preview at http://127.0.0.1:8000, rebuilt as you edit
uv run mkdocs build --strict # what the docs-site hook and CI run
Write the Markdown for GitHub, with relative links between files; tools/mkdocs_hooks.py makes the root
files pages and points each link at its page on the site, or at the file on GitHub when the site does not
have it. The strict build fails on a page missing from nav in mkdocs.yml, a broken link or anchor, and
a list that starts on the line after a paragraph: GitHub shows a list there, the site one paragraph, so
put a blank line before it.
Which sandbox for what¶
Never test an install on your own Mac. install.sh, bosun init --recommended, get.sh,
bosun update and bosun uninstall change launchd, pmset, the install and your config for real. Use
the cheapest of these that can show what you changed:
| What you changed | Run | What it can prove | Time |
|---|---|---|---|
| Logic, config, the wizard | tests/sandbox.sh |
bosun's logic, on a machine with nothing installed, through a real terminal | 1 minute |
| Anything else in bosun | tests/sandbox.sh all |
also the unit tests and the smoke suite | 3 minutes |
get.sh, install.sh, launchd, pmset, anything macOS does its own way |
tests/sandbox.sh mac run |
the real thing on real macOS, except sleep | 75 seconds |
| What a new Mac lacks: Homebrew, Apple's tools | tests/sandbox.sh mac run --from-nothing |
the same from a Mac with nothing on it | 4 minutes |
| What an update does to a ledger and a knowledge base in use, and what real use does to them: their journals, ids, writes, privacy | tests/sandbox.sh mac work |
a week of work through an update on real macOS: launchd, a moving clock, racing writers, writes cut short, files changed outside the journal | 7 minutes |
| How an agent behaves: launching it, its screens and files, a task handed to it | tests/sandbox.sh mac live claude, ... live codex |
the real agents from nothing, through setup and sessions | 10 minutes, about a dollar |
| The wake itself | bosun wake-test, on real hardware |
a real sleep and a real wake | 5 minutes |
The first also runs in CI (the second's unit and smoke suites run there natively), and so does the third on
a clean GitHub macOS runner, whenever the install could have changed (.github/workflows/install.yml). The sandboxes' scripts install for real, so they
refuse to run anywhere else.
Docker: tests/sandbox.sh¶
A throwaway Linux container with a read-only copy of your checkout, and no network for the first-day scenario. It needs Docker.
tests/sandbox.sh # a new user's first day: the wizard, a morning, re-runs, uninstall and back
tests/sandbox.sh unit # only the unit tests; `smoke` only the smoke suite
tests/sandbox.sh all # the first day, the unit tests and the smoke suite
tests/sandbox.sh shell # that new user's machine, to try things by hand; it goes away on exit
Docker on a Mac is Linux, so launchctl, pmset, caffeinate and the like are stand-ins
(tests/sandbox/macos/). It proves bosun's logic, never launchd, a real wake or the Keychain.
A real Mac: tests/sandbox.sh mac¶
macOS in a Tart VM on your Mac (Apple silicon). A run clones a base image, boots it,
runs tests/install_e2e.sh in it and deletes the clone: the real installer, real launchd jobs that really
fire, the real pmset, plutil and bash 3.2.
brew install openai/tools/tart
tests/sandbox.sh mac setup # once: pulls the image (6 minutes) and prepares the bases (4)
tests/sandbox.sh mac run # on a developer's Mac: Homebrew is already there
tests/sandbox.sh mac run --from-nothing # on a new Mac: no Homebrew, no Apple tools
tests/sandbox.sh mac work # a week of work, through an upgrade: see below
tests/sandbox.sh mac live codex # the real agents: see below
tests/sandbox.sh mac shell # a window, to try the wizard as a new user would
tests/sandbox.sh mac clean # delete the base images (`tart prune` removes the pulled one)
If Homebrew refuses the tap, it wants to be told you trust it. These two are enough, not the whole tap:
brew trust --formula openai/tools/tart openai/tools/softnet.
- It costs disk once. The image is about 29 GB to download and takes about 33 GB; a clone takes no
time and no space until it writes, so keep 10 GB or so free for a run.
dudouble-counts clones: trustdf. - It is a stranger's Mac, not yours. The login is
admin, passwordadmin, with passwordlesssudoand ssh on. Never put a real token in one by hand, and never push an image you logged in to.liveis the one exception, and how it handles its keys is below. - It cannot sleep.
pmset sleepnowfails in a macOS guest (error 0xe00002e2), andtart suspendis no stand-in: launchd did not catch up on a job that fell due while the VM was frozen. To check a job's time, move the clock past it instead (sudo systemsetup -setusingnetworktime off, thensudo date): launchd fires on time. Onlybosun wake-teston real hardware proves the wake. - It only runs here. GitHub's macOS runners cannot nest a macOS VM, so CI uses the runner itself.
- Wait for the login. ssh answers before the auto-login finishes, and until then launchd has no
gui/<uid>domain andlaunchctl bootstrapfails with error 125.mac.shwaits for it; do the same if you script a VM by hand.
A week of work: tests/sandbox.sh mac work¶
run shows a new install. work shows what an update does to someone who already uses bosun, and what
real use does to the ledger and the knowledge base: on a clone of the developer's Mac it installs the
release with the piped installer, works three days of the fictional acme week
(tests/sandbox/acme_week.sh: review requests, tasks and their links, a workstream, outreach and its
reply, proposals approved, what I typed in a window through the hook, tickets, a history long enough to
be trimmed, prune; in the knowledge base the acme fixture imported, facts added, confirmed, rejected and
corrected, a fact I stated with my quote checked in a session's transcript, and a person deleted on
request), with the clock moved a day between them. Then it updates the way a user does, running get.sh
again, and checks that the first write starts each journal from what was there (or goes on with the
ledger's, when the release journals it already): the same items, entities and facts, old ids working and
new ones minted, the key of the forgotten made and kept out of the knowledge history, nothing naming a
person and none of my words in the knowledge journal file that can leave the Mac. A person deleted on
request then leaves nothing in local.jsonl, in a torn file (one is made as a power loss would leave
it) or in the git history. Two more days follow on the new bosun, then writers at once (twenty in the
ledger, ten in the knowledge base, then ten in each, under one replica id), the clock going back an
hour, fifty writes of each store killed like a power cut at random (the next knowledge write must also
clear the git lock such a cut leaves, so that its history records again), a hundred approvals (they
write both journal files) killed at points swept across their run, which it counts as torn tails,
repairs and writes lost, and the knowledge files changed outside their journal: by hand, by git checkout of the data
files and then of the whole folder, and by the release, run from a checkout of its own, forgetting a
person that the next write must not bring back. Then a morning by launchd, the console's knowledge view
with the restricted files unreadable, an uninstall that keeps the ledger, the knowledge folder, their
journals and this Mac's replica id, and a year on, when kg expire expires proposals and deletes person
facts past retention. After each, bosun ledger validate --journal and bosun kg validate must pass, and
whatever a cut command printed before it died must be there.
tests/sandbox.sh mac work # from origin/main to this checkout
tests/sandbox.sh mac work --base <commit> # from another starting point; it must be an ancestor
The base must be a bosun from before the knowledge base's journal. Its ledger may be from before the
journal too (then the first days check that the release writes no journal and makes ids like
task:<date>/1, and the update that the journal starts with the first write after it), or journaled
already, as on origin/main (then they check its journal and minted ids, and that the update goes on
with them).
It sends two bundles of this repository: the release, and this checkout (also what is not committed)
as its descendant, the head of the branch that get.sh, run again, updates to. The scenario is
tests/work_e2e.sh; what it shares with tests/install_e2e.sh is in tests/sandbox/e2e_lib.sh. It sets the clock with
sudo date and puts it back when it ends, also on failure.
The real agents: tests/sandbox.sh mac live¶
The stand-ins only behave the way you already believed. Every surprising thing about Codex in Agents was found by running the real one, and so was the first-run stall on Claude Code. So after a change in how bosun talks to an agent, or an update of one, run this:
tests/sandbox.sh mac live claude
tests/sandbox.sh mac live codex
BOSUN_SANDBOX_CODEX_MODEL=<a cheap model> tests/sandbox.sh mac live codex
On a clone of the developer's Mac it does what a new user does, following only what the installer prints:
it stops for what is missing, runs the line it gave, logs in, goes through Claude Code's first screens, and
installs. Then real windows, a restart that must come back on the same session, and a real task: the real
orchestrator hands a fix to the real repo session, which must report to the ledger, push a branch whose
tests pass and open a pull request, and the orchestrator must link it. live codex runs the repo window on
Codex and the orchestrator on Claude Code. GitHub is a stand-in and the remote a local bare repo, so nothing
leaves the Mac but requests to the agents' APIs. It is not in CI, because it costs money and needs keys.
The keys. It reads two API keys from your Keychain, one for Anthropic, and for live codex one for OpenAI.
Use keys made for this, with a spend limit. Copy each to the clipboard and store it like this, because
security add-generic-password -w with no value silently cuts what you type at 128 characters and a key is
longer. It also keeps the key out of the process list, and clears the clipboard:
printf 'add-generic-password -U -a "%s" -s bosun-sandbox-anthropic -w "%s"\n' "$USER" "$(pbpaste)" | security -i; pbcopy </dev/null
printf 'add-generic-password -U -a "%s" -s bosun-sandbox-openai -w "%s"\n' "$USER" "$(pbpaste)" | security -i; pbcopy </dev/null
A key only ever goes through a pipe: never a variable, an argument or the screen. In the clone it is a 0600
file that apiKeyHelper reads, or Codex's own login. What comes back through ssh has anything shaped like a
key masked, and the clone is deleted at the end, even on failure. The clone holds no ssh key, no GitHub
token and no shared folder of yours, and an image with a key in it is never saved.
Tart is OpenAI's since it acquired Cirrus Labs in 2026. The repository's licence is FSL-1.1-ALv2, which allows internal use and becomes Apache 2.0 after two years (the licensing page on tart.run still shows the older terms). It is only a test tool here: nothing in bosun needs it or ships it.
Testing without waiting for the schedule¶
Work up the levels; each one exercises more of the real chain.
- Unit and integration:
tests/smoke.sh. It runs the Python unit tests intests/unit/(ledger and outreach, delegate, Claude Code state, repo mask, permission guard, the watchdog's checks with an injected clock, learning's digest, ledger checks and proposals, and replays of orchestrator and repo-session interactions, rewritten as fiction in the acme domain, fromtests/fixtures/corpus/) and then end-to-end checks, including setup with a fakeaws, the morning dry run, install/uninstall with fakelaunchctlandpmsetunder a test label, andbosun updatebetween two local commits (the next version made bytests/sandbox/update_patch.sh), with fake windows brought along, and the first update converting a checkout. Hermetic: temp HOME, fixture sessions, fake Claude processes with fake status records for the refresh, orchestrator and check-in rules, ledger state machines and concurrency, a fakeclaudethat registers itself forsend --newand--start(long, multi-line prompts arrive intact; failures are reported), and a fakeclaudefor the chores dry run, and one for the learning analysis. Isolated tmux socket; no Claude calls, no launchd or pmset changes. - Config sanity:
bosun doctor, thenbosun morning --dry-run. Once the session exists, the dry run also prints the refresh action for each window.bosun watchdog --statusand--dry-runshow what the watchdog sees and would alert, without sending. - Session start, now:
bosun start, thenbosun attach. Preview the steady-state path withbosun refresh --dry-run, then run it for real. - Through launchd, now:
launchctl kickstart gui/$(id -u)/com.bosun.morning, thenbosun status. - Real sleep and wake:
bosun wake-test 3loads a temporary launchd job due in 2 minutes, schedules a one-off wake in 3 minutes (the same order as production) and puts the Mac to sleep, retrying if a touch or notification cancels the sleep. Scheduling the wake needs admin rights:sudoin a terminal, or the macOS password dialog when there is no terminal (e.g. Claude Code's!prefix). The wake is scheduled first, so a cancelled prompt changes nothing. Keep your hands off the keyboard and trackpad during the 20-second countdown: any touch as the Mac goes to sleep cancels the sleep. The test detects this and tells you. Afterwards runbosun wake-test --reportto see the Wake/Sleep events, the keep-awake assertion, the morning log and the test session's windows, then--cleanup. By default the test windows only print theclaudecommand they would run.--fullalso runs setup and launches Claude for real. - Orchestrator chores, no side effects:
bosun chores morning --dry-run(orcheck-in). It runs the real procedure headless against real Slack, GitHub and Confluence reads, and prints the plan, the ledger changes and any blocked tool calls. The safeguards: a scratch copy of the ledger (--freshstarts empty),bosun sendonly logs, and outward-facing tools are denied (by headless mode and by name). It uses the checkout you run it from, also for thebosuncalls Claude makes, so it rehearses a branch before you install it. To check idempotency, run it again with--continue, which starts from the previous rehearsal's ledger. Nothing already processed should be processed again. - What I say, translated:
uv run python tests/llm/free_text.py [--runs 2] [--only O1,R4]runs the real, headless Claude (the orchestrator with its policy, settings, model and effort; repo sessions with their real task text) on things I say in my own words ("ask Dana whether ...", "ping Priya again", "Marco answered, go ahead with b", and things that must not become outreach) and checks the resulting ledger. It runs with no MCP servers (--with-mcploads yours, read tools only). Each run has a scratch world: scratch HOME, ledger and tmux server, sends only logged, outward tools denied. It costs money (about $0.3 per case and run, for each case intests/llm/cases.py), so neither the smoke suite nor the hooks run it; run it after changingpolicy.md,chores.mdor the task files. - What learning finds:
uv run python tests/llm/learn_eval.py [--runs 2]builds a fictional world (acme's invoice exporter, three days) with planted problems (an overnight permission prompt, a repeated guard denial, a duplicate hand-over, a correction made twice, a missing report, an unanswered question with no nudge, an idle delegated window, drafts never proposed, duplicate and mis-scoped outreach) and checks thatbosun learn's analysis finds them (recall), invents nothing (precision) and proposes well-formed changes. Real Claude, no MCP servers; about $0.35 per run with opus. Run it after changingorchestrator/learn.md. - Orchestrator for real:
bosun start, attach, and watch theorchestratorwindow run/bosun:chores morning. Nothing outward-facing happens until you approve it there or from your phone.bosun chores check-intriggers a check-in on demand.