Configuration
The loop is the same everywhere. What it runs against is not, so six things stay with the repository rather than arriving with the install.
Test and lint commands
Section titled “Test and lint commands”implement and revise run the narrowest relevant test, then the gates
AGENTS.md names. Name them there: formatter, linter and the strictness it
runs at, test command. A repository that names none leaves an agent to
guess.
Documents tree
Section titled “Documents tree”specify writes plans and ideas that issues cite by identifier, and tstorm check frontmatter keeps those identifiers real. Which directory holds them is
a repository’s own choice. Name it in .tstorm.toml at the repository
root:
documents = "docs/internal"A repository that names none is a clean skip: the check has nothing to walk, and says so rather than failing.
tstorm looks for .tstorm.toml first and .tstorm.json second, in the
directory a command runs in and then each directory above it. The nearest
directory holding either one wins. Both names take the same settings, so a
repository that installed the loop when JSON was the only format keeps working
until somebody converts the dozen lines by hand.
The first file found is the only one read; two files do not merge. A file that
names no settings is an error rather than an empty answer, so a .tstorm.toml
created and not yet filled in says so instead of shadowing the .tstorm.json
beside it.
Base branch
Section titled “Base branch”A pull request targets one branch, and a worktree starts from the same one.
That is main unless .tstorm.toml names another:
base = "edge"A repository that merges straight to main leaves it out.
Deny rules
Section titled “Deny rules”Claude Code carries a settings file for the repository to merge. Codex loads a
PreToolUse hook from the enabled plugin. Pi loads its command gate from the
package extension.
Claude Code reads them as one rule per command. Merge the payload’s
settings.json into .claude/settings.json:
{ "permissions": { "deny": [ "Bash(gh pr merge:*)", "Bash(gh pr review:*)", "Bash(git push:*)", "Bash(git merge:*)" ] }}A bare git push is denied because pushing goes through tstorm push, which
compares the remote ref to what it is about to overwrite. Without these
rules the review sequence is a convention an agent can skip.
The Codex payload also carries rules/thunderstorm.rules, one execution-policy
rule per command:
prefix_rule( pattern = ["gh", "pr", "merge"], decision = "forbidden", justification = "only a human merges or approves a thunderstorm run",)Copy that file into ~/.codex/rules/ or a trusted project’s .codex/rules/
only when the policy should remain active while the plugin is disabled. A
forbidden rule blocks the command without a prompt. To inspect a command:
codex execpolicy check --rules ~/.codex/rules/thunderstorm.rules -- gh pr merge 12Pi ships no sandbox, but its thunderstorm extension rejects the same four
command prefixes before its bash tool runs. The extension is not process
isolation: keep Pi inside an operating-system sandbox or container when the
repository needs one.
tstorm board reads and writes a GitHub Projects board, and github-board
and triage decide what it writes. Create the board and the single-select
field carrying status, then name them in the same .tstorm.toml:
[board]owner = "your-org"number = 13statusField = "Status"
[board.status]todo = "Todo"inProgress = "In Progress"done = "Done"The three option names are whatever the board calls the states an issue moves
between. A project carrying several repositories’ work adds groupField and
groupValue to separate them; a project that is this repository’s alone
leaves both out. tstorm install --track <value> writes groupValue, and
groupField under the name Track unless --group-field names the field
your board uses instead. Reads are filtered to the repository the command
runs in, taken from its origin remote unless repository names one.
shared lists paths that nearly every change writes, such as
["CHANGELOG.md"]. tstorm board next ignores them when it checks whether
two issues write the same file, so two issues that both add a changelog entry
can still run at once.
A repository with no .tstorm.toml at all gets an error naming the install
command that writes one. A repository whose file is missing some of this gets
an error naming each setting it left out. Reading and writing a project also
needs the project scope on the token: gh auth status lists the scopes,
gh auth refresh -s project adds it.
Model assignments
Section titled “Model assignments”Which model runs which role is a decision per organization, not per package,
so it lives on the machine rather than in .tstorm.toml. The roles table,
roles.toml, sits under $XDG_CONFIG_HOME/tstorm, or ~/.config/tstorm
when that variable is unset or not an absolute path, beside the machine settings in config.toml. A
repository’s .tstorm.toml overrides neither file.
For a single dispatch, pass --model provider/model to tstorm dispatch, or
--provider and --model separately. The values come from
pi --list-models, including custom providers declared in
~/.pi/agent/models.json. Pi’s models.json supports OpenAI-, Anthropic-,
and Google-compatible endpoints, and a Pi extension can register other APIs
or OAuth flows.
Every pass starts a new session with no history from the passes before it. The same model may implement and review one change; a reviewer that carried the implementer’s context would inherit the gap that produced the defect. The harness reference covers what an agent must provide before it can carry a role at all.
The roles table
Section titled “The roles table”roles.toml lists, for each role, the models it draws from in the order they
are tried. An entry is <harness>/<model>[:<effort>], where the harness is
claude, opencode or pi, the model is what that harness calls it, and
the effort is one of off, minimal, low, medium, high, xhigh or
max. An entry without an effort runs the harness’s default. On pi and
opencode, a colon followed by anything other than an effort stays part of
the model name, so pi/ollama/qwen3:8b is a model with no effort. A Claude
model name never has a colon, so on claude that suffix is an unknown
effort.
[budget]total = 5claude = 2pi = 2opencode = 1
[slots]claude = 2pi = 1opencode = 1
[caps]"claude/claude-opus-5-5" = "medium""pi/openai/gpt-5.6-sol" = "medium"
[roles.orchestrator]models = ["claude/claude-opus-5-5:medium"]
[roles.implementer.low]models = ["pi/openai/gpt-5.6-luna:xhigh", "claude/claude-sonnet-5-5:high"]
[roles.implementer.medium]models = ["claude/claude-opus-5-5:medium"]
[roles.implementer.high]models = ["pi/openai/gpt-5.6-sol:medium", "claude/claude-opus-5-5:medium"]
[roles.reviewer]models = ["pi/openai/gpt-5.6-sol:medium", "claude/claude-opus-5-5:medium"]
[roles.adversarial-reviewer]models = ["claude/claude-opus-5-5:medium"]
[roles.reviser]models = ["same:implementer"]The implementer has one list per issue complexity: low, medium and
high. same:implementer hands the revision to whichever model implemented
the change, and only roles.reviser may use it. [budget] and [slots]
count sessions per harness and are read by the loop.
[caps] sets the highest effort a model may run at, keyed by
<harness>/<model> without an effort. tstorm dispatch lowers --thinking
to the cap and prints the level the session ran at. A cap or a model named
without its provider stands for every provider’s model of that name, because
Pi picks the provider and tstorm does not see which one: --model gpt-5.6-sol
is held to the lowest cap any provider sets on that name, and a cap on
pi/gpt-5.6-sol holds pi/openai/gpt-5.6-sol too.
tstorm roles check reads the table and lists every entry that names an
unknown harness, every same: entry outside roles.reviser, and every entry
asking a model for more effort than its cap. It reports an unknown effort on
every claude entry, and on a pi or opencode entry only where [caps]
names the model before the colon, since anywhere else the suffix may be part
of the model’s name. A table that gives no role a model, such as an empty
file, fails as well. It exits 0 when the
table passes, 1 when it breaks a rule, and 2 when there is no table. --file
checks a table somewhere else. tstorm dispatch refuses to run while the
table breaks a rule, and runs uncapped when there is no table.
Machine settings
Section titled “Machine settings”config.toml holds what the machine knows that is not a role: the GitHub
App the loop pushes as, where its private key is kept, and the person a
scheduled run credits.
[bot]appId = 123456bitwardenSecretId = "<Bitwarden Secrets Manager secret holding the App key>"keychainService = "<Keychain service holding the Bitwarden access token>"login = "<app-slug>[bot]"userId = 41898282
[driver]login = "octocat"id = 583231keychainService names a macOS Keychain entry.
Minting a token, for tstorm push --bot or any tstorm pr command, asks
GitHub which installation of the App covers origin’s repository, so one
[bot] section serves every repository the App is installed on. Origin has to
be on github.com. Where GitHub finds no installation, either because the App
is not installed or because the repository does not exist, both refuse and
give the page to install the App from.
installationId = 7890123 pins one installation: the lookup still runs, and
a repository under any other installation is refused before a token is asked
for.
login and userId are the App’s bot account, which
gh api users/<app-slug>[bot] reports. With them set, tstorm dispatch
makes the bot the author and committer of every commit a session makes, and
requires each to carry the driver’s Co-authored-by trailer in the trailers
that close the message. login has to end in
[bot]; dispatch refuses one that does not. A squash merge takes its
co-authors from those commits, so a pull request body carries no
Co-authored-by line, and tstorm check commit-message --pr refuses one
that does. It reads plain lines only, since a line in a list, a quote or a
code block is not a trailer and credits nobody.
The driver is the person a run credits. Outside a dispatched session,
tstorm takes [driver] when it is set and otherwise asks gh api user.
[driver] comes first because gh answers for GH_TOKEN or GITHUB_TOKEN
ahead of its own login, and a shell can inherit another account’s token.
When both are available and name different people, tstorm refuses and
names both. A scheduled run needs [driver], since nobody is at the
keyboard for gh to answer for.