Workspaces
A throwaway clone of one git repository for one run: set up by the repository itself, changed through the workspace tools, pushed to a branch, and deleted when the run ends.
What a workspace is
A workspace is a clone of one git repository, made for one run and deleted when that run ends. A member reads, changes, tests and commits code in it, and pushes the result to a branch. Nothing in it outlives the run except what was pushed.
A run gets a workspace only when its task declares workspace tools: workspace.read_file, workspace.edit,
workspace.run_command, workspace.git_push and the rest, each listed by name like any other tool. A task
without them never clones anything. What each tool takes and returns is in the
workspace tools reference.
Everything a workspace does is plain git and the repository's own tooling: it clones, branches, commits, merges and pushes through the repository's remote, and sets up and checks the code with the commands the repository declares (see What the repository provides). None of that depends on where the repository is hosted: the remote's address says which host it is, and the credential for it comes from the code host tool you bind. One thing still reaches a code host's API of its own: a branch named after an issue. See Where the repository lives and The branch.
The workspace tools only touch the clone. Issues, pull requests, reviews and merging happen on your code host, through the host's own MCP server, bound like any other tool: GitHub has a setup guide, and any other host's server can be bound from Tools.
Where the repository lives
A run names its repository by its git remote address, on any host that serves git: GitLab, Bitbucket, Azure Repos, a self-hosted server, or GitHub.
{ "repo": "https://git.example.com/acme/website.git" }
Nothing about the host is set in the OS. The workspace reads what it needs from the remote itself: the host from the address, and the default branch and the branches from git.
The repository is cloned the first time the run needs it, with its last 50 commits.
The credential comes from the code host tool you bind
The clone and every push use the code host tool your enterprise or team binds on Tools. A workspace reads these custom props off the binding: key → value settings added behind Custom props on its form.
| Key | What it is | GitHub's |
|---|---|---|
git.host |
The host this binding's token is valid for | github.com |
git.username |
The username git sends alongside the token | x-access-token |
git.repoTemplate |
A clone address with {repo} in it, applied to a short owner/name input |
https://github.com/{repo}.git |
git.repo |
The repository the binding's layer works in, for a run whose input names none | Usually left out |
The workspace takes the binding, among those the run reaches (task, role, member, team, enterprise, in that
order), whose git.host matches the remote's. Each tool counts once: the nearest binding of it the run reaches, so
a member's own binding without the props hides the enterprise's. Its token is given to git scoped to that host, and only over
https://, so a prompt from any other host is answered with nothing. A remote on a host no binding names is cloned
with no credential: a public repository still clones, and nothing pushes. The GitHub guide
says what to set for GitHub.
A binding may also name the repository its layer works in (git.repo), and then a run needs no repo input at
all; a run that gives one overrides it. Because a binding can carry git.repoTemplate, a short input keeps working:
{ "repo": "acme/website" }
A run with no repository — neither in its input nor on a binding it reaches — fails workspace_repo_missing
before it calls anything. A run that names a short owner/name no binding's git.repoTemplate expands fails
workspace_repo_no_clone_address when it opens its workspace: the OS never guesses a host for it. Tools says when
tasks work in a workspace and no binding has git.* props, and flags a binding without them when another binding
of the same tool has them.
The branch
A run works on one branch, chosen in this order:
| The run works on | When |
|---|---|
Its branch input |
The run was started with one, or the task before it handed one on. |
| The task's branch template | The task's workspace sets branch. Each {key} in it is filled from the run's input: fix/{issue} with { "issue": 42 } is fix/42. |
os/<task slug>-<first 8 characters of the run id> |
Neither. A new branch. |
A template with {issue_slug} in it is worked out once, from GitHub. When the run's input names a pull request
(number) and the task can read pull requests, the run takes that pull request's own branch. Otherwise it names
the branch after the issue in the input (issue): os/<issue number>-<the issue's title as a slug>. The name is
stored as the run's branch input and every task after it inherits it, so renaming the issue later never moves
the branch. The task has to be able to read the issue or pull request (github.issue_read,
github.pull_request_read, or all of GitHub); if the branch cannot be worked out, the run fails
workspace_branch_unresolved before it starts work.
A branch that exists on the remote is checked out as it is there. One that does not is created from the default branch: the branch the clone checks out, which is the repository's own default.
Kept up with the default branch
When the run's branch already exists and is behind the default branch, the default branch is merged into it and pushed before setup runs and before the member does anything, so an open pull request never falls more than one run behind. It is an ordinary merge and an ordinary push. If the merge conflicts, or the remote refuses the push, it is undone and the branch is left exactly as the remote has it, for the run to deal with.
What the repository provides
How the code is set up and checked belongs to the repository, not to the task. Tasks are shared between enterprises through the catalog and pointed at other repositories, so a task's skill never names one repository's scripts.
Setup
After cloning, the workspace runs the repository's setup. With no setup file, it works out what to run, and runs both lines when both apply:
| The repository has | Setup runs |
|---|---|
mise.toml or .tool-versions |
mise install |
package.json with a packageManager of npm, and no pnpm-lock.yaml |
npm ci, or npm install without package-lock.json |
Any other package.json |
pnpm install --frozen-lockfile, or pnpm install without pnpm-lock.yaml |
To choose for yourself, add .os/workspace.yml (or .os/workspace.yaml, or .os/workspace.json) at the root of
the repository:
# Leave setup out to use the list above. `setup: []` skips setup altogether.
setup:
- [pnpm, install, --frozen-lockfile]
- [pnpm, run, generate]
teardown:
- [node, scripts/clean-up.mjs]
- Each step is one line,
- [binary, argument, …]: no shell, and no other YAML form. In this form an argument cannot contain a comma. - The binary is one of
git,node,npm,pnpmandmise. Anything else a repository needs (another language, a particular version of Node) comes through mise: declare it inmise.toml, andmise installinstalls it. setupruns in order after every clone. If a step exits non-zero, the clone is thrown away and the workspace call fails withworkspace_setup_failed, naming the binary and the start of its output. The member is handed the error, and its next workspace call tries again.teardownruns when the workspace is deleted. A step that fails there is ignored.- The JSON form is the same two lists:
{ "setup": [["pnpm", "install"]], "teardown": [] }.
Git hooks
The repository's own git hooks run on the commits and pushes a member makes, once setup has installed them (a
prepare script that installs hooks runs as part of pnpm install or npm ci). The workspace tools never skip
them, and refuse the usual ways around them. A hook that fails refuses the commit or the push, and its output goes
back to the member to fix.
Commands for the engineering tasks
The engineering tasks in the catalog (writing an issue's tests, implementing it, validating it) never run a
repository's scripts by name. They call three mise tasks, and the repository's
mise.toml says what each one does:
| Command | Called as | What it has to do |
|---|---|---|
os:red |
mise run os:red <issue> |
Run the issue's tests: the test files the branch adds or changes. Print each file's path. Exit 0 when every test passes, 3 when the branch has no tests to run, anything else when a test fails. Its mise description says where this repository keeps tests and which framework runs them: the task that writes tests reads it. |
os:check |
mise run os:check [issue] |
Everything to run once before committing: lint, typecheck, tests, and whatever build they need first. Exit 0 when it all passes. |
os:coverage |
mise run os:coverage [issue] |
Measure test coverage of the lines the branch changed, against the repository's own thresholds. Exit 0 when they are met; otherwise list the uncovered lines. |
os:check and os:coverage may also exit 6 when every failure is a test that fails on the default branch too,
printing each as pre_existing_failure: <file> › <test>. The tasks then treat the default branch as broken rather
than the pull request, and look again later.
A repository with no os:red stops those tasks with a message saying it needs one. Any task can run whatever else
the repository's mise.toml defines, through workspace.run_command.
A check has to fit one command
Every command a member runs has limits: ten minutes, and 32,000 characters of output. A check that runs past the first is stopped before it can say what failed, and one that prints past the second is cut short. So:
- Print what failed, not everything. Keep the full output in a file in the clone and print where it is. A member can read the file when it needs to.
- Split a check that cannot finish in ten minutes.
mise run os:check --partsprints the name of each part, one per line, andmise run os:check --part <name>runs that part over the whole repository, with the same exit codes. A task that checks a whole branch asks for the parts and runs each in turn. A repository that does not answer--partsis checked with onemise run os:check.
Commands for a dependency refresh
Tasks that keep a repository's dependencies current call three more mise tasks. They are optional: a repository without them is not offered a refresh. Which package manager it uses, what counts as out of date and how a version is changed are the repository's own. The tasks name no package manager, so the same tasks work on any repository that answers these commands, and a repository can cover container images or CI actions by changing its own script.
| Command | Called as | What it has to do |
|---|---|---|
os:deps |
mise run os:deps |
Print what is out of date, in the form below. Exit 0 when the report is complete, with or without updates. Exit non-zero when the scan could not be completed: never print an empty report for a scan that failed. |
os:deps:apply |
the lines os:deps printed |
Move each named dependency to exactly the version named, and update the lockfile. Skip one that is already there, so a line can be run again. Exit non-zero, changing nothing, when an argument matches nothing the repository declares. |
os:deps:verify |
mise run os:deps:verify |
Exit non-zero, naming each one, when a dependency now resolves lower than on the commit the branch started from. |
The report is markdown, because a task copies parts of it into issues as they are:
updates: 2 security: 1 majors: 1 held: 1
## Updates to apply
| Package | Current | Target | Level | Security | Note |
| --- | --- | --- | --- | --- | --- |
| `left` | 1.0.0 | 1.0.1 | patch | high | |
| `right` | 2.1.0 | 2.2.0 | minor | | |
Apply in this order, running the checks after each group:
1. Security: `mise run os:deps:apply [email protected]`
2. Minor: `mise run os:deps:apply [email protected]`
## Majors
### Major upgrade: framework 3 to 4
- `framework` 3.2.0 to 4.1.0 (released 2026-08-25)
- Release notes: https://example.com/framework/releases
- Apply: `mise run os:deps:apply --major [email protected]`
## Held back
- `odd` 1.0.0: the registry names no version to compare
What the tasks rely on:
- The first line is the counts.
updatesandmajorsboth 0 means there is nothing to do. ## Updates to applyholds the changes that are safe to make together: a table, then one runnablemise run os:deps:apply ...line for each group, numbered in the order to apply them. These become one issue, and one task runs the lines exactly as printed. What an argument looks like is the repository's choice.## Majorshas one###heading for each upgrade that may break the code, in the order to take them. The heading is used as the issue's title, so it must read the same on every scan for as long as that upgrade is on offer. A person rejects an upgrade by closing its issue as not planned, and a title that is already on an issue, open or closed, is never filed again. Under the heading: what moves, where to read what changed, and its own apply line.## Held backnames anything the scan could not decide, with the reason. Nothing is done with these.- The whole report fits in 32,000 characters. When it would not, leave out the least urgent updates and say how many were left out.
Deciding what is safe is the repository's job too. Things worth deciding there: how old a release must be before it is offered, whether a fix for a known vulnerability waits that long, and never offering a prerelease or a withdrawn release.
What a task decides
A task version's Workspace decides which branch its runs work on and which files they may change. On a task in the portal it is Workspace JSON:
{
"branch": "fix/{issue}",
"writeAllow": ["src/**", "tests/**"],
"writeDeny": ["src/generated/**"]
}
| Field | What it does |
|---|---|
branch |
The branch template (see The branch). |
writeAllow |
Paths the run may change freely. Once it is set, every path it does not match is refused, unless appendOnly matches it. |
rewriteAllow |
Paths the run may change freely, without refusing everything else. |
writeDeny |
Paths the run may never change. It wins over every other field. |
appendOnly |
Paths the run may add files and lines to, but where no line that was there when the run started may change or go. |
requireFiles |
Paths that must exist in the clone before the run starts work; each {key} is filled from the run's input. If one is missing, the run fails workspace_file_missing. |
Paths are globs: * stays inside one folder, ** crosses folders. A task with none of the path fields set may
change anything in the clone.
The rules are checked when a file is written, edited or deleted, again on what a commit would include (a command
can change files too), and, for appendOnly, once more on the run's own commits when it pushes. A refused write
changes nothing; a refused commit commits nothing. During a merge, a file the merge brings in may be written and
committed as the merge has it, whatever the rules say, so a run can finish a merge; a line of the run's own in
such a file is still refused.
Commits and pushes
Commits are authored and committed as the member the run belongs to: its name and the email address on its
member page. A run with no member commits as the enterprise itself, under your enterprise's name, from an os@
address on your enterprise's Zero Human mail domain.
Code hosts link a commit to an account by its email address. For commits to show under the member's own account on your host, add the member's address to that account and verify it (for GitHub, see GitHub). The push itself is made by whichever account the credential belongs to.
workspace.git_commit stages what the run changed and commits it; a new file that only a command created may need
a git add first. workspace.git_push pushes the branch to the repository's remote under the same name, as an
ordinary push: it never overwrites work someone else pushed.
If a run of a task that lists workspace.git_push finishes without a successful push while its workspace holds
changes or commits the remote does not have, it fails: git_push_missing, or the reason the commit or push was
refused. Work is not lost behind a success. If a run runs out of steps, or its model call fails, the OS commits and
pushes what it had before the run fails, where the task's rules allow, so a retry picks up from there.
What is never allowed
- Pushing the default branch.
workspace.git_pushrefuses the repository's default branch, and any branch namedmainormaster(default_branch_refused). A member has no other way to push:workspace.run_commandrefuses a command withgit pushanywhere in it, including throughmise,pnpmornode(git_push_via_run_command). - Skipping the repository's hooks. A git command with
--no-verify,git commit -n, or acore.hooksPathoverride is refused (git_hooks_skip_refused), and the file tools write nothing under.git/hooks. - A shell.
workspace.run_commandruns one binary with its arguments: no pipes, no redirects, nocd, no&&. The binary is one ofgit,node,npm,pnpmandmise, named, not given as a path (bin_not_allowlisted). - Files outside the clone. Every path the file tools read or write is inside the clone. A path that resolves outside it is refused.
What waits for you
Nothing a run does in its workspace waits for you. The OS answers every workspace.* and memory.* call before it
reads the task's gates, so a gate declared on one of those tools is saved but never waits: every write, commit and
push goes ahead as the task's rules allow, and only ever to a branch that is not the default one. See
Gates and grants in the built-in tools reference.
The decision that waits is the merge. Getting a branch into the default branch goes through your code host: a pull
request, or merge request, and the host's merge tool, which a task that merges should gate. On GitHub it has to: a
task that names github.merge_pull_request cannot be saved without a gate on it. See
Gates and tool scopes.
Planned
What a merge needs will be set on the task's own gate, for any host's merge tool: that the gate is required, that the run writes a summary before it waits, and that approving it ends the run. The OS will not know GitHub's merge tool by name.
Limits
| Limit | Value |
|---|---|
| One command | 10 minutes, then it is stopped with everything it started. |
| A command's output | At most 32,000 characters go back to the member. The exit code always does. |
| Setup and teardown | 8 steps each, each at most 16 words. |
| Clone depth | The last 50 commits. |
| Reading without changing | 10 repeated reads (the same file, folder or search again, or an ad hoc command) with no write, edit or commit between them, and a per-run budget of 150 distinct files, folders and searches. The next one past either is refused (exploration_budget_exceeded), and the member is told to act on what it has read. |
| Search | 100 matches by default, 500 at most. |
Running mise run …, git status, git diff, git log or git commit is progress, not reading: it starts the
count of repeated reads again. Nothing resets the per-run budget, but a file, folder or search the run has already
read never counts against it twice.
Troubleshooting
| What you see | Why | What to do |
|---|---|---|
workspace_setup_failed |
A setup step exited non-zero | Run the same step in a fresh clone of the repository and fix it there, or change .os/workspace.yml |
workspace_manifest_invalid |
.os/workspace.yml is not in the form above |
One step per line, - [binary, argument], under setup: or teardown: |
workspace_setup_bin:<name> |
A setup step uses a binary that is not allowed | Run it through mise, or a package.json script |
tool_auth_denied:workspace.… |
The host refused the clone or the push | Check that the code host binding's account can write to the repository, and that its token has not expired |
workspace_repo_missing |
Neither the run's input nor a binding it reaches names a repository | Give the run a repo, or set the repository on the code host binding |
workspace_repo_no_clone_address |
The run names a short owner/name, and the nearest code host binding it reaches has no git.repoTemplate to expand it |
Add git.repoTemplate under Custom props on that binding on Tools, or give the run a full repo address |
| The clone asks for a password, or the push is refused | No binding the run reaches names the remote's host | Add git.host and git.username under Custom props on that code host's binding, which holds the token — see Where the repository lives |
default_branch_refused |
The run was working on the default branch | Give the task a branch template, or start the run with a branch input |
git_push_missing |
The run finished with work it never pushed | Read the run's last commit and push calls: a refused commit or push says why |
workspace_write_not_allowed or workspace_commit_not_allowed |
A path outside the task's rules | Change the task's Workspace, or the work |
workspace_append_only |
The run changed or removed a line in an add-only path | The run has to add lines, not change them, or the path needs writeAllow |