Skip to content

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.

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.

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.

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.

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:

Terminal window
codex execpolicy check --rules ~/.codex/rules/thunderstorm.rules -- gh pr merge 12

Pi 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 = 13
statusField = "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.

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.

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 = 5
claude = 2
pi = 2
opencode = 1
[slots]
claude = 2
pi = 1
opencode = 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.

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 = 123456
bitwardenSecretId = "<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 = 583231

keychainService 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.