Skip to content

Latest commit

 

History

History
1308 lines (1067 loc) · 79.5 KB

File metadata and controls

1308 lines (1067 loc) · 79.5 KB

The Agentic Harness

A reference for configuring and running the Agentic Harness — what each setting means, what accepts it, and where it is edited. This document describes what exists; it changes no setting, default, or validation rule. For spending ceilings specifically (what caps a run, in what unit, and what does not cap it), see LIMITS.md.

Find what you need

The reference is below. Where a row names a short path, that is a page stating the goal, the file it edits and the two steps that reach it — start there and come back here for the detail.

I want to... Short path Reference
Understand the run from proposal to merge — The stage sequence
Configure global defaults or one change — Two configuration files and Every key
Find a setting in the standalone app or VS Code — Where each setting is edited
Run a change with nobody watching How to Where a chain starts
Allow push, pull-request creation, and merge — The git stage
Compare agents, models, effort, and caps — Agent reference
Use your own agent definition for a stage How to stepAgents
Hand one numbered task to an agent How to taskAgents
Set a spending ceiling How to Harness Spending Limits
Run a change from a terminal How to CI CLI
Run two changes at the same time How to CI CLI
Find out what would stop a run here — openspec-ui-cli diagnose workspace, and doctor --change <id> for one change
Learn why a run failed, or whether one has gone quiet — supervisor, and openspec-ui-cli show advice
Close a task, or open a change's tasks, from its card How to openspec-ui-cli task
Answer a question an agent asked — A question for the operator, and openspec-ui-cli answer question

The harness sequences CLI-agent runs (or a mechanical action) across the stages of one OpenSpec change: propose → review → apply → verify → archive → git. Configuration lives in two JSON files — openspec/agent-harness.json (workspace-wide default) and a per-change openspec/changes/<id>/harness.json (override) — read by packages/core/src/harness-config.ts. Both hosts (the standalone app and the VS Code extension) read the same configuration through the same code; neither has a setting the other lacks.

The stage sequence

Stage What runs it What it produces
propose The stepAgents.propose CLI agent, dispatched as a plan command. Drafts or updates the change's proposal.md/design.md/tasks.md.
review The stepAgents.review CLI agent, dispatched as a review command. A review of the change's artifacts - does not itself modify them - ending with a line Review verdict: ready or Review verdict: changes needed. On changes needed the same agent then runs an update command, as part of this stage, before apply (see "A review that asks for changes" below). Left out where skipStages names it.
apply The stepAgents.apply CLI agent, dispatched as an implement command. Implements tasks.md's tasks and ticks each one as soon as its own verification has passed — the product's instruction says so, whatever a project's rules add. A before/after checkpoint of the workspace is captured around this stage so verify can be handed the actual delta; a run that changed files and ticked no task is named on the chain's timeline. A run that changed no file and ticked no task, while a task of its own is open (not Human-only, not delegated), ends the chain as failed, naming the open tasks: verify and archive do not run for work that was not done.
verify Mechanical checks first (see "Mechanical checks" below), then the stepAgents.verify CLI agent, dispatched as a verify command — only if every declared check passed. A verification report. The agent ticks each unticked task whose verification it confirmed itself — checking an effect that is not a file, such as a command that must pass — and unticks each that does not hold; it never ticks a **Human-only** or **Delegated to …** task. A failing mechanical check skips the agent entirely.
archive Mechanical. HarnessChainRunner calls openspec archive directly — no CLI agent runs, and stepAgents has no archive key to set (see "Two configuration files" below). The change moves to openspec/changes/archive/. Refuses outright unless every task in tasks.md is checked, naming the tasks that are not.
git Mechanical, and gated. Push, open a pull request, wait for its checks, merge — see "The git stage" below. Runs only when the resolved reviewGate.mode is "agent-sufficient". A merged pull request, or nothing at all under the default reviewGate.mode.

archive is mechanical and git is gated: neither is a CLI-agent stage like the first four, and a reader should not have to infer either from the table above — both are called out here explicitly.

A review that asks for changes

The review is asked to end its reply with a line of its own: Review verdict: ready if the plan can be implemented as it is, or Review verdict: changes needed if anything it found should be fixed first (ADR 0041). Only that line is read - list marks, quote marks and emphasis around it are tolerated, prose is not read, and the last such line wins. The verdict is kept on the run's audit entry (reviewVerdict).

  • In a chain, changes needed runs one update before apply, on the review stage's agent. It is reported as part of the review stage (a second stageStarted for review with updating: true): its time and spend count toward the review's, a semi-autonomous checkpoint after the review comes after the update, and a failed update ends the chain. ready, or no verdict line, goes on to apply as before.
  • By hand, update is a command of its own: in the AI panel (with a field for notes), from a card whose last run was a review that said changes needed (Update the plan), and from a terminal with openspec-ui-cli update plan <change> [--note <text>] [--agent <id>], which asks at the terminal for each permission the agent requests and denies it where nobody can be asked.

An update revises the planning artifacts the change already has - proposal, spec deltas, design, tasks - so that they answer the change's last completed review (from the audit log) and the operator's notes, keeps them coherent, runs openspec validate <name> --strict, and changes no code. Its reply says what it changed and which findings it left, and why. propose still writes only the artifacts a change does not have yet.

A question for the operator

An agent stage (propose, review, update, apply, verify) is told not to decide what the change's files leave open, and to print a line of its own for each such decision: Question for the operator: <question> (ADR 0042). Only that line is read, as the verdict line is; prose is not. local-llm-acp is told to ask in the middle of its turn instead, with its ask_operator tool, and goes on with the answer as the tool's result.

  • Where it is kept. Each question is appended to the change's decisions.md - ## Q-<run>-<n>: <question>, who asked it and when, and - Answer: (open) - in the directory the run works in, usually the change's own worktree. The answer replaces (open), with who gave it and when; the audit log records both. The file is committed with the change.
  • The wait. A run whose turn ended with a question still open does not complete, under every autonomy level, alone or in a chain: its status says it waits for your answer, and the chain does not start its next stage. The stage's timeout does not count the wait. When every question is answered, the stage runs again with the questions and answers in its prompt - update after propose or review, the same command after apply or verify - and the run or the chain goes on from there. A cancel ends the wait; the questions stay open.
  • The refusal. While a change's decisions.md holds an open question, in the checkout or in the change's own worktree, an agent run on it is refused, naming the question. Read-only commands are not.
  • Answering. On the change's card (Answer...), in the AI or chain panel watching the run, in the Human-Only Inbox, with openspec-ui-cli answer question <change> <Q-id> "<answer>" (openspec-ui-cli show questions <change> lists its open questions), or by replacing (open) in decisions.md by hand. A waiting run reads the file every few seconds, so an answer from another host or a terminal reaches it too. openspec-ui-cli show status prints each waiting run's questions with the command that answers them, and the supervisor points out a run that has waited past supervisor.waitingAfterSeconds.

Two configuration files

  • Global: openspec/agent-harness.json — the workspace-wide default. Applies to every change unless a per-change file overrides it.
  • Per-change: openspec/changes/<id>/harness.json — overrides the global file for one change only.

resolveHarnessConfig(workspaceRoot, changeName) reads both and merges the per-change file over the global one (mergeHarnessConfig):

  • stepAgents merges key by key, and each stage's entry merges field by field — a per-change file overriding only stepAgents.apply still inherits every other stage from the global file, and one that sets only an effort for apply still inherits the model, budget and custom agent the global file set for it. Every field an entry may carry is merged: the merge iterates STEP_AGENT_KEYS (packages/core/src/harness-step-agent.ts), the same list the validator reads, so the next field added to an entry arrives already merged.

    With one exception: naming a different agent for a stage inherits nothing. A stage's model, effort, budget and custom agent belong to its agent — effort vocabularies differ between agents (copilot accepts seven values, claude five, codex four, and five agents accept none), a budget is denominated in whichever unit its agent reports, and a custom agent is a definition one CLI reads — so carrying them across a change of agent would build a configuration nobody wrote.

    Until stage-override-keeps-the-rest a stage's entry was replaced outright, so setting an effort silently discarded the model. Until a-stage-override-keeps-its-custom-agent the merge named three fields where the entry carried four, so an override naming the same agent plus a customAgent resolved without it and the chain ran with no --agent flag.

  • autonomyLevel, reviewGate, checkpoints, budget, gitStageAllowlist and taskAgents are each a whole-value override — if the per-change file sets one at all, its value is used exactly as written, never merged field-by-field with the global file's own value.

  • supervisor, like branches and archive, merges key by key: a change that sets only its mode keeps the workspace's thresholds.

Five settings a global openspec/agent-harness.json may not set — each one raises a dedicated InvalidHarnessConfigError subclass naming the reason if a global file tries:

Setting Global file may set it? Why
autonomyLevel: "autonomous" No — GlobalAutonomousAutonomyLevelError An unattended chain (no checkpoint, ever) is a decision one change opts into deliberately, not something a workspace default should hand every change silently.
reviewGate.mode: "agent-sufficient" No — GlobalAgentSufficientReviewGateError This is what allows the git stage to push/PR/merge without a human present. A workspace default must never grant that; only a specific change's own file can. Two exceptions, each narrow: branches.rebaseWhenBehind, which pushes only what the server already has, onto a newer base (ADR 0034); and archive.whenLanded, which pushes only openspec archive's result over changes the default branch says are finished, through a pull request and its checks (ADR 0035).
checkpoints.requireConfirmationBetweenSteps: false No — GlobalCheckpointsDisabledError Same reasoning as autonomyLevel: "autonomous", one field over: skipping the pause between stages is a per-change opt-in.
gitStageAllowlist (the key itself, any value) No — GlobalGitAllowlistError The allowlist is what a real git push/gh pr create/gh pr merge is checked against. A workspace-wide allowlist would apply to every change's git actions by default, which is exactly the blast radius this setting exists to avoid.
taskAgents (the key itself, any value) No — GlobalTaskAgentsError Not too powerful, but meaningless: a task number belongs to the change whose tasks.md wrote it, so the same statement made workspace-wide is about a different piece of work in every change.

A per-change file may set any of the five above without restriction — including a budget (see below) higher than the global file's. There is no equivalent restriction on the chain-level budget field itself: any file, global or per-change, may set any positive value for it.

Every key and its accepted values

Read from packages/core/src/harness-config.ts and packages/core/src/harness-step-agent.ts — the two modules that validate a harness configuration file. A top-level key outside this list is rejected outright (unrecognized top-level key), naming the key and, if it matches a known stage name, suggesting stepAgents.<key> instead.

Top-level keys: stepAgents, autonomyLevel, reviewGate, checkpoints, budget, timeout, skipStages, maxStageAttempts, gitStageAllowlist, taskAgents, steps, hints, supervisor, allowAgentMessages, branches. Nothing else is accepted, at either file.

stepAgents

An object whose keys are stage names — propose, review, apply, verify — and whose values name what runs that stage. Not archive or git: each is a mechanical or dedicated-sequence stage with nothing to configure, and a stepAgents.archive/stepAgents.git entry from before this restriction existed is read and dropped with a warning, not rejected outright.

Each entry is either a bare agent-id string ("claude-cli") or an object:

{ "agent": "claude-cli", "model": "claude-opus-4-6", "effort": "high", "budget": { "maxCostUsd": 5 }, "customAgent": "reviewer" }
Field Accepted values
agent Any id in the agent table below, plus "vscode-chat" (only valid when the resolved autonomyLevel is "assisted" — a chain can never select it).
model A string matching /^[A-Za-z0-9][A-Za-z0-9._:-]*$/, and only for an agent whose registry entry declares a modelFlag (claude-cli, copilot-cli, claude-cli-acp, copilot-cli-acp).
effort One of none, minimal, low, medium, high, xhigh, max — restricted per agent; see the reference table below.
budget { "maxCostUsd": <positive number> } or { "maxAiCredits": <positive integer, minimum 30> } — whichever field the chosen agent's own capabilities accept; the other field is rejected.
customAgent The name of a custom agent you defined yourself, passed to the CLI as --agent <name>. A string matching /^[A-Za-z0-9][A-Za-z0-9._:-]*$/ — the same rule model obeys, and for the same reason: both reach the CLI as the value of a flag, so a value beginning with - could be read as a second flag. Only for an agent whose registry entry declares a customAgentFlag — the claude-cli and copilot-cli families, raw and ACP alike. Setting one for any other agent is rejected rather than dropped.

Custom agents are discovered from the directories the CLIs themselves read: .claude/agents/*.md for Claude, in the project and for the user, and .github/agents/*.md for Copilot. A name defined in both the project and the user directory is offered once, with the project's winning — it is the one its own CLI would use. Neither CLI has a command that lists them, and neither needs one: the definitions are files. A definition whose file name the rule above would refuse is reported as found and not offered, rather than dropped without a word — discovery and validation say the same thing about the same name.

Gemini and Codex cannot be given one. Both CLIs have custom agents and both read them from directories, so what is missing is not discovery — it is selection. Read from each CLI's own published documentation on 2026-09-12:

CLI Where its definitions live How one is selected
Gemini .gemini/agents/*.md and ~/.gemini/agents/*.md — Markdown with YAML frontmatter, the same shape Claude uses @name at the start of the prompt, or the interactive /agents command. No documented flag.
Codex .codex/agents/*.toml and ~/.codex/agents/*.toml — TOML, whose name field rather than the file name is the agent's name Named in the prompt. No documented codex exec flag.

A custom agent reaches its CLI here as the value of a flag on an allowlisted invocation (customAgentFlag in the agent registry), so neither of these has anything to bind to. A prompt-prefix mechanism would put the agent's name inside the prompt rather than in the argument list, which the allowlist does not constrain — a different security question, and one nobody has asked for. If either CLI gains a flag, adding it here is a line: the conventions in packages/core/src/custom-agents.ts are data.

They are chosen in the standalone UI's harness settings, beside the stage's agent, effort and budget — one picker per stage, listing only the definitions that stage's own CLI accepts. A stage whose agent takes none says so instead of showing an empty control, as does a workspace that defines none, which also names the directories that were read. A name in the file that the discovery no longer finds stays selected and is marked as not found rather than being replaced.

In VS Code they are chosen in the same view, in the panel — see "Where each setting is edited" below.

Gemini and Codex accept no custom agent here. Their CLIs were not installed on the machine this was verified on, so their convention could not be checked, and offering one that cannot be passed is the same defect as a ceiling that cannot act.

stepAgents.git is not accepted by this schema. HarnessChainRunner's runStage routes the "git" stage straight to its own push/PR/merge sequence (runGitStage); no CommandKind exists for it, and no CLI agent runs during this stage under any configuration. Neither settings surface offers an agent, effort or budget control for it. A stepAgents.git entry written before this restriction existed is read and dropped with a warning, not rejected outright, the same way stepAgents.archive is. See "The git stage" below for what actually runs it.

VS Code's wizard does not offer either archive or git: HARNESS_TEMPLATE_STAGES in commands.ts lists propose, review, apply, verify and archive, and its loop skips any stage that is not a stepAgents-configurable one — so it never asks about archive or git either. Both hosts agree.

autonomyLevel

"assisted" | "semi-autonomous" | "autonomous". "assisted" runs one stage at a time from the picker; a chain command requires "semi-autonomous" or "autonomous" and fails immediately, rather than silently running one stage, against an "assisted" change. See "Where a chain starts" below for how each behaves.

reviewGate

{ "mode": "human-required" | "agent-sufficient" }. Default "human-required". Only "agent-sufficient" lets the git stage run at all — see "The git stage" below.

checkpoints

{ "requireConfirmationBetweenSteps": <boolean> }. Optional; absent means "confirmation required" wherever a semi-autonomous chain would otherwise pause. See "What a checkpoint offers" below.

hints

{ "enabled": <boolean> }. Optional; absent means enabled, which is what every configuration written before this key existed says.

The suggestions are derived from the readiness report — which changes can be started alongside each other, which is ready with nowhere to run, which workspace is held by a run that stopped reporting itself. They are shown in the Pipeline tab and printed by openspec-ui-cli show advice, and they create nothing and start nothing: each carries the fact it came from and the commands a person would run.

false means not computed, never computed-and-hidden: the payload carries no suggestions at all. Read from the workspace file rather than from a change's — the report they come from is about the whole repository, so a per-change value would be answering a different question.

supervisor

{ "mode"?: "advise" | "off" | "act", "silentAfterSeconds"?: <positive integer>, "waitingAfterSeconds"?: <positive integer>, "fallback"?: { "<stage>": ["<agent id>", ...] }, "allowCostIncrease"?: <boolean>, "allowProviderChange"?: <boolean> }. Optional; absent means advise, 600 and 60, no fallback and nothing allowed. Accepted in both files, and a change's own object is merged key by key over the global one: a change that turns it off keeps the workspace's thresholds. Set the mode from either Harness Settings view; the thresholds are set in the file. act, fallback and the two allowances are a change's own only — see Act below.

The supervisor (ADR 0039) is rules over records that already exist. It is not a process and not a model, and it adds no timer: its suggestions are computed where the Pipeline and openspec-ui-cli show advice already read. Under advise it points out three things, as suggestions beside the others:

Suggestion When Commands
A run has said nothing new for N Its heartbeat is alive, it waits on nobody, and its activity is older than silentAfterSeconds status for it, and stop for it with the reason filled in
A run has waited on a person for N It waits on a checkpoint or a permission for longer than waitingAfterSeconds status for it
A change's last run failed, and repeating it will not help Its diagnosis (below) says so, and no run is working on the change The remedy's, where it has one

It never stops, starts or changes anything, and it never calls a run hung or stuck: a long turn and a hang look the same from outside (ADR 0028). A threshold is checked when a reading is made, so a run is pointed out at the next reading after it crosses one. off computes none of the three, and hints.enabled: false still computes no suggestion at all.

A failed run says what is known about why. Every run of every agent passes through one runner, which matches the failure's reason and the last 8 KiB of what the run printed against causes that have been seen. The diagnosis rides the failed event, the audit entry, the run's log and the change's last run. It is shown beneath the failure in both hosts, on the change's card, and by openspec-ui-cli run change:

Cause Repeating Matched on
The agent is not installed will not help ENOENT, "not recognized as an internal or external command", "command not found"
The agent is not signed in will not help "Authentication required", "not logged in", "please log in", /login, "Unauthorized", "invalid API key", status 401
The machine blocked the agent will not help EPERM, EACCES, "Access is denied", "operation not permitted"
The network could not be reached will not help ECONNREFUSED, ECONNRESET, ENOTFOUND, ETIMEDOUT, EAI_AGAIN, "fetch failed", "proxy"
The service rate-limited the agent is likely to help "rate limit", "Too Many Requests", "quota", status 429
The service failed is likely to help "Internal Server Error", "Service Unavailable", "Bad Gateway", "Gateway Timeout", "overloaded", status 500, 502, 503, 504

The first cause in that order wins. A status code counts only after HTTP, status, code or error, or before its own phrase, since a bare number in output is usually a duration or a count. The diagnosis quotes the line it was found in, so it can be checked. A failure matching none is diagnosed as unknown, and nothing more is said beneath it.

Act

Under act (the-supervisor-changes-agents, ADR 0039 decision 4) the supervisor also does something about a stage whose agent failed, from the failure's diagnosis and the change's own policy:

Diagnosis What it does
Repeating is likely to help (rate limit, service failure) Attempts the stage again on the same agent
Repeating will not help (not installed, not signed in, blocked, no network) Moves the stage to the first agent in fallback.<stage> that the policy allows and the stage has not run on in this chain
Not known Nothing: the chain ends with the failure, as under advise

Every repeat or move is another attempt of the stage, so it is spent from maxStageAttempts; with no attempt left the chain ends with the failure. A moved stage runs its new agent with that agent's own defaults: the old entry's model, effort, budget and custom agent are not carried over.

The policy:

  • allowProviderChange — a move to an agent of another provider (Anthropic, GitHub, OpenAI, Google, DeepSeek, or the local model). The change's files and prompts then go to that provider.
  • allowCostIncrease — a move to any agent but the local model. Nothing here knows one service's price against another's, so every move but one to the local model counts as one that may cost more.

Both are false where absent, and a fallback the policy refuses is skipped and named. act is refused in the workspace file, and in a change's file that does not itself set autonomyLevel: "autonomous": under a level that asks a person, the person decides. A configuration whose act would have one attempt per stage is refused when it resolves. A fallback list names only stages that run an agent, only registered agents, never vscode-chat, and no agent twice.

What it does is always said and recorded: a progress line ("the supervisor moved apply from copilot-cli-acp to claude-cli-acp: …"), the next attempt's stageStarted naming the new agent with the reason, and an audit message entry from supervisor carrying supervisorDecision. A chain yields no failed event for a stage it goes on to attempt again. Under advise, a change's last-run suggestion names the fallback act would move the stage to, and changes nothing.

In a change's Harness Settings, Act is offered only where the change's own autonomy level is Autonomous; choosing it asks for a second attempt where the change has one, and shows the fallback fields and the two allowances with a note on cost and providers. openspec-ui-cli run change prints the move, and the reason under the next attempt's heading.

An edit that lowers the level turns Act off (applying-a-configuration-turns-act-off). Applying a named configuration to a change - every one sets Semi-autonomous - or saving a change's Harness Settings with an autonomy level other than Autonomous removes supervisor.mode where it was act, so the change follows the workspace's mode, and says so where it says what was applied or saved. The fallback agents and the two allowances stay in the file: choose Autonomous and Act again to bring it back as it was. Only a file written by hand that says both act and another level is not run and is not written, since nothing chose between them; it still opens in the change's Harness Settings, which say Act turns off and put it right on Save.

branches

{ "rebaseWhenBehind"?: <boolean>, "followMain"?: <boolean> }. Optional; absent means every default below. Allowed in the global file and in a change's own file, which overrides it key by key.

rebaseWhenBehind - absent means true. A change's branch that has fallen behind the default branch is rebased onto it and pushed with --force-with-lease, by the sweep that also removes a working directory whose work has landed (ADR 0034). Its pull request's checks then run again, against the current default branch - which is what closes the gap left by main no longer requiring an up-to-date branch: two pull requests green apart, broken together.

It happens only when every one of these holds, and each is checked:

  • the branch bears the name of a change - a branch this product did not name is never touched;
  • it was pushed, and is still on the server;
  • it is equal to its upstream: nothing of it exists only here, and nothing of the server's is missing here;
  • its working tree is clean, and no run is recorded against it;
  • it is behind the default branch.

A conflict is never resolved: the rebase is aborted, the branch is left as it was, and the sweep says which files conflict. A push the lease refuses - somebody else pushed first - puts the branch back where it was.

Every host that sweeps says what it did: the editor in its output channel, and a warning for a conflict; the standalone under "Done for you".

This is one of the two workspace defaults that let the product push (the other is archive.whenLanded), and an exception to the rule in the table above. It is safe to have on because a rebase adds nothing to the server: it moves commits that are already there onto a newer base, and the lease makes it unable to take anything away. It does not widen gitStageAllowlist or reviewGate, and it cannot push a commit the server has never seen.

Turn it off - "branches": { "rebaseWhenBehind": false } - where change branches are shared between people: somebody with the branch checked out elsewhere sees its history rewritten, and each rebase costs a full run of the pull request's checks.

followMain - absent means true. The same sweep brings the main working directory's main up to origin/main, by fast-forward alone, so the views show what the server has: a change that landed, or an archive the sweep opened, appears or leaves without anybody pulling (main-follows-what-landed). It happens only in the checkout the host has open, only where that is the main working directory on main, only where its tree is clean and main has no commits of its own, and never while a run works in it. Otherwise main is left where it is, and the sweep says how far behind it is and why. It pushes nothing. Pipeline's drift line also names the changes on origin/main this checkout does not hold. Turn it off - "branches": { "followMain": false } - where the main checkout is moved only by hand. Read from the workspace file.

archive

{ "whenLanded"?: <boolean> }. Optional; absent means every default below. Allowed in the global file and in a change's own file, which overrides it key by key.

whenLanded - absent means true. A change that has landed and owes nothing is archived for you, by the same sweep (ADR 0035). A change is finished when, on the default branch as the server has it:

  • its directory is still in openspec/changes/;
  • its tasks.md has at least one item, and every item is closed and says how - the reading the merge gate and the archive command use;
  • no pull request from a branch named after it is open.

Every finished change goes into one pull request per pass, on a branch named archive-landed-<date>. The pull request is made in a directory outside the workspace, and the directory and the local branch are removed. While such a pull request is open, no other is made. A change whose archive fails is left out and named.

The product merges it itself (ADR 0036). No forge is asked for an automatic merge, so a repository's merge settings change nothing. From the next pass on, the sweep reads the pull request's checks:

The checks The sweep
still running waits
every one that ran passed merges: squash, else merge, else rebase, as the repository allows
none ran at all merges - an archive only moves what openspec archive wrote
one failed does not merge, and names it

A repository may merge only what is up to date with its default branch: GitHub's "require branches to be up to date", GitLab's fast-forward merge, Gitea's "block merge on an outdated branch". An archive pull request such a forge refuses while the default branch has moved on would be refused on every pass after. So the sweep makes the archive again on the default branch as it is now, and moves its own branch there with a lease; the checks run again, and the next pass merges it.

A forge that refuses the merge for any other reason - a required approval, a protected branch, a token that may not merge - leaves the pull request open. The sweep says the forge's own reason (in the editor as a warning, once per reason) and merges once it can. While an archive pull request is open, the editor and the standalone sweep again every five minutes. A merge is fetched at once, so it reaches the main checkout in the same pass.

A change whose own pull request merged while it still owes something is never archived. The sweep names what it owes: in the editor as a warning, once a session, and in the standalone under "Done for you". The merge gate should make that impossible, so it is an alert, not a state.

This is the second workspace default that lets the product push, after branches.rebaseWhenBehind. It pushes only the result of openspec archive over changes the default branch says are finished, and that lands only through a pull request and its checks. It does not widen gitStageAllowlist or reviewGate.

Turn it off - "archive": { "whenLanded": false } - where archiving is somebody's deliberate step. Set it in one change's own file to keep that change live after it lands.

It needs the openspec CLI and a forge the product can ask. Where either is missing, nothing is archived and the sweep says why.

Which forge. The one origin is on (the-forge-is-gitlab-or-gitea-too):

origin on Asked through Credentials
github.com GitHub's REST API where GITHUB_TOKEN or GH_TOKEN is set; gh otherwise, as before a token with repo (and workflow where the repository has workflows), or a signed-in gh
gitlab.com GitLab's REST API (/api/v4) GITLAB_TOKEN: a personal access token with api, or a fine-grained one that may read and create projects, read and write merge requests, and write the repository
another host whichever answers: Gitea's /api/v1/version, else GitLab's /api/v4/version GITEA_TOKEN (repository and issue read and write) or GITLAB_TOKEN

An ssh origin does not say where the forge's web root is: set GITEA_URL or GITLAB_URL to it (for example http://gitea.local:3000). A host that answers neither is taken for GitHub, as every workspace was before. Tokens are read from the environment the host runs in - in the editor, restart it after setting one - and are sent only to the forge they belong to.

The same forge answers the standings (which change's pull request is open or merged), the directory sweep, and the git stage's pull request, its checks and its merge (github-without-gh). With neither a token nor gh, the product says so: "gh is not installed, and GITHUB_TOKEN is not set".

allowAgentMessages

<boolean>. Optional; absent means false.

Whether a run in this workspace takes notes and questions written by another run, rather than only by a person. A person's messages are always taken.

Runs can see each other in the roster, so they can address each other; that is exactly why this is off. A run that takes instructions from another run has a second operator nobody chose, and nothing in the audit would distinguish the two. A refused message is recorded with its id and the reason, and left where it is rather than removed - a run whose configuration changes may take it later.

What a taken message does: it is handed to the agent in the prompt of the next stage that starts, marked as words from a person rather than as content read from the repository, and a question is answered by what that stage says when it ends. See the-operator-can-say-something-to-a-run.

budget (chain-level)

{ "maxCostUsd"?: <positive number>, "maxTokens"?: <positive integer> }. Both fields optional and independent. This is the chain-level ceiling HarnessChainRunner checks between stages — distinct from a stepAgents.<stage>.budget, which caps one CLI invocation. See LIMITS.md for the full distinction, including why there is no single budget: number.

maxStageCostUsd and maxStageTokens bound one stage, enforced here rather than by the agent's CLI. Checked when a stage ends and stopping the chain — not the stage, which a spending ceiling cannot do. Neither may exceed its whole-chain counterpart.

maxContextShare is the one field here that is not a spending ceiling: a share between 0 and 1 (0.8 is eighty percent) of how much of the model's context window the run may fill. It reads ACP's usage_update, which arrives during a stage, so unlike every other field here it stops a stage already running, ending the run as cancelled with the ceiling and the reading named. It acts only over an agent that sends that update; see LIMITS.md for which do, and for why a context gauge is not counted as a spend.

timeout

{ "maxRunSeconds"?: <positive integer>, "maxStageSeconds"?: <positive integer> }. Both optional and independent; absent means unbounded.

Unlike budget, this ceiling stops a stage that is already running — elapsed time is known during a run where a run's cost is not. It is also the only ceiling with any force over an agent that reports no usage, which is six of the ten supported. Reaching it ends the run as cancelled, with a reason naming the ceiling and its value, rather than as a failure.

Time counts while a stage runs and not while the chain waits at a checkpoint. maxStageSeconds may not exceed maxRunSeconds — the run ceiling would stop the chain first, so the stage ceiling could never fire. See LIMITS.md for the full picture, including how a run ceiling under five minutes interacts with the git stage's own check-polling.

skipStages

An optional list of stage names a chain leaves out, for example ["review"]. review is the only accepted name: propose is already left out when the proposal is written, and verify is the only check that apply did the work it ticked. archive and git are governed by their own gates.

The setting is accepted in both the global file and a per-change file. A per-change list replaces the global list as a whole; [] puts review back into the chain. When skipped, the chain timeline says review skipped: skipStages leaves it out. A declared step placed before or after a skipped stage is refused when the configuration resolves.

The Harness Settings views show when the resolved configuration skips a stage, but do not set skipStages; edit the harness file to change it.

maxStageAttempts

<positive integer>, counting the first attempt. Absent means one, which is today's behaviour.

Deliberately one number covering every reason a stage is attempted again, rather than one per reason: separate ceilings multiply, and three attempts for a time cut plus three for another reason would produce nine runs of a stage nobody configured. Each attempt records its own reason.

gitStageAllowlist

{ "remotes": string[], "branches": string[] }. Both arrays non-empty; entries are exact strings or simple * wildcards. Per-change only — see "Two configuration files" above. Detailed in "The git stage" below.

taskAgents

Which agent runs one numbered task of this change, keyed by the task's number exactly as tasks.md writes it:

{ "taskAgents": { "5.4": { "agent": "copilot-cli", "customAgent": "reviewer" } } }

The value is the same entry a stepAgents stage takes — the bare string form or the object with model, effort, budget and customAgent — validated by the same rules, including the character rule on customAgent. Two rules are this section's own: a key must be a task number (1, 1.1, 1.1.1), and vscode-chat is refused, because a delegated item's run spawns a CLI and cannot be handed to the editor's chat.

Per-change only. The global openspec/agent-harness.json may not set it (GlobalTaskAgentsError) — not because the value is too powerful for one file to set for every change, which is why autonomyLevel: "autonomous" is refused there, but because it would be meaningless: a task number belongs to the change whose tasks.md wrote it, so "5.4 runs on copilot-cli" stated workspace-wide is a statement about a different piece of work in every change.

Precedence, highest first:

  1. taskAgents["<number>"] in this change's harness.json;
  2. the **Delegated to <agent-id>** marker in the task's own text;
  3. nothing — the item is not delegated and is offered no run.

Where the file and the task text name different agents, the file wins and both are reported: the row says "claude-cli (this change's harness.json names it; the task text names copilot-cli)". A disagreement between two statements about one task is worth seeing rather than resolving in silence.

A key matching no open task line is reported as unmatched rather than ignored — in the inbox's own sentence, since it is otherwise visible nowhere. Two reasons are distinguished, because they are fixed differently: no open task carries that number (a renumbered or deleted task — change the key), or the task it names is marked **Human-only** (remove the key; an item marked for a person is never offered a run, whatever the file says).

What running one checks, and what it does not. An item run this way goes through the same allowlist, working-directory sandbox and audit log as any stage, and its audit entry carries the change and the taskNumber. Afterwards the item's line and indented body are compared with what they were: an item that came back ticked while saying nothing it did not say before has the tick reverted and the run reported as refused. That is the whole of the check. It does not judge whether written evidence is true — nothing mechanical can — so a passed gate means something was recorded, never that it was verified.

Where each setting is edited

Neither UI is a full editor for every field above — some settings have no control in either host and must be hand-edited in the JSON file directly. State this plainly rather than let a reader discover it by searching a settings screen that doesn't have the control:

Setting Standalone (webui) VS Code
stepAgents.<stage>.agent, .model, .effort, .budget, .customAgent The global file in the Harness Settings tab (GlobalHarnessSettingsView.tsx). A change's own file in the Change Editor's Harness tab, for the loaded change (ChangeHarnessSettingsView.tsx), where each inherit option names the value it resolves to. The model, effort, budget and custom-agent fields only appear once a stage's agent accepts them; the model is typed as the agent's CLI names it (claude-sonnet-5), since no agent lists its models. OpenSpec Workbench: Configure Workspace Harness opens the global file in a panel of its own, OpenSpec Workbench: Harness Settings. OpenSpec Workbench: Configure Change Harness opens that change's file in its own panel, Harness: <change>, already loaded; each change gets its own. Both files stay hand-editable and each view names its file.
autonomyLevel Both views. autonomous is offered only in a change's view: a workspace-level file may not set it, and the global view says so beside the select. Both panels, on the same rule. The separate OpenSpec Workbench: Initialize Workspace Harness... command has a guided Quick Pick flow for the global setup, but it is not the general config editor.
reviewGate.mode A change's view only (the global value is fixed at "human-required" and shown, not editable). A change's panel only.
checkpoints.requireConfirmationBetweenSteps Not editable in either UI. Hand-edit the JSON file. Same — not editable in either UI.
budget (chain-level maxCostUsd/maxTokens) Not editable in either UI. Hand-edit the JSON file. Same — not editable in either UI.
gitStageAllowlist Not editable in either UI. Hand-edit the per-change JSON file. Same — not editable in either UI.
taskAgents Not editable in either UI. Hand-edit the per-change JSON file. The resolved answer is visible: the "Waiting on somebody" block names the agent each open item resolves to, and offers a Run button where that agent is one this build carries. Same — not editable. The Human-Only Inbox view names it per row, and a row naming a registered agent carries OpenSpec Workbench: Run Item.
archive.whenLanded Not editable in either UI. Hand-edit the global or the per-change JSON file; absent means on. What the sweep archived, and a change that landed owing something, is said under Done for you in the Summary. Same - not editable. What the sweep did is said in the output channel; an archive pull request it opened is also raised as a notification, and a change that landed owing something as a warning.
branches.followMain Not editable in either UI. Hand-edit the global JSON file; absent means on. What the sweep did is said under Done for you in the Summary, and the Pipeline's drift line names what landed and is not shown here. Same - not editable. What the sweep did is said in the output channel.
branches.rebaseWhenBehind Not editable in either UI. Hand-edit the global or the per-change JSON file; absent means on. What the sweep did with a branch is said under Done for you in the Summary. Same - not editable. What the sweep did is said in the output channel, and a conflict is also raised as a warning.

Standalone settings, in pictures

The captures below come from the running application, not a mock. Select either image to open the original PNG at full resolution.

Global defaults

Standalone Harness Settings tab: global default with per-stage agent, effort and budget controls, the mechanical archive row, and the autonomy level select

The global view: a named configuration chosen from one list, then the stage runner, effort, and per-invocation budget controls together with the workspace autonomy level. Nothing on it is about a single change; a change's own settings are in the Change Editor. The footer records the package versions rendered by the capture.

One change's own settings

The Change Editor's Harness tab for a loaded change: a named configuration with its description, inherit options naming the global value, and the semi-autonomous autonomy level

The Change Editor's Harness tab, for the change loaded there. A field left to inherit says which value it inherits and from where, and explicit values show exactly what the change overrides. Each autonomy level is named by what running under it does, and autonomous appears only here — a workspace-level file may not set it, so the global view does not offer it. See Where a chain starts.

A named configuration is chosen the same way in both views and in the run dialog: select it, read its description beneath the list, and press Apply. In a settings view applying fills the fields and nothing is written until you save. In the run dialog it writes the change's harness.json, and the dialog then shows what the change resolves to.

Both are produced by packages/server/e2e/harness-screenshots.spec.ts, which also produces the run dialog picture and the checkpoint screenshot in "What a checkpoint offers" below. Regenerate them with, from packages/server:

npm run test:browser -- harness-screenshots.spec.ts

VS Code settings, in pictures

Each file has a panel of its own. OpenSpec Workbench: Configure Harness Settings opens the global one:

VS Code panel titled OpenSpec Workbench: Harness Settings, showing the global harness settings

OpenSpec Workbench: Configure Change Harness, from a change's context menu, opens that change's own, titled with its name and already loaded:

VS Code panel titled Harness: a-change-in-progress, showing one change's harness settings

Both are produced by packages/extension/e2e/editor-screenshots.spec.ts. Regenerate them with npm run test:pictures from packages/extension.

Mechanical checks

Before the verify stage's agent runs, HarnessChainRunner runs every mechanical check the change's own tasks.md declares (runMechanicalChecksForVerify, packages/core/src/mechanical-checks.ts). A failing check skips the verifying agent entirely — the stage fails immediately with the failing checks' reasons, and no agent run is spent reviewing work a mechanical check already found broken. A tasks.md that declares no checks at all runs verify exactly as before this capability existed.

The complete, closed set of six check names (MECHANICAL_CHECK_NAMES):

Name What it runs
validate-change openspec change validate --strict <changeName>
typecheck npm run typecheck
test npm run test
lint npm run lint
path-unchanged git diff --quiet -- <path> — requires a repository-relative path parameter
changeset-present A pending .changeset/*.md file exists for the change

A task line declares one with a trailing inline-code span:

- [ ] 6.1 `openspec change validate --strict my-change` `check(validate-change)`
- [ ] 6.5 No source changes outside this path. `check(path-unchanged, packages/core/src)`

`check(name)` or `check(name, param)`, matched by TASK_CHECK_DECLARATION_RE at the very end of the task's text. A name outside the six above fails to parse (UnknownMechanicalCheckError) rather than silently becoming an ordinary, unchecked task.

Where a chain starts, and what a user can steer

One way in

There is one entry, in both hosts: Run — the openspec-ui.runChange command in VS Code (the id is unchanged, so existing keybindings still work), one button in the standalone Change Editor.

It is named Run and not after any one of the three paths it offers. Naming it after one of them is how it came to sit beside a second entry named after another.

A run can also be asked for at a time rather than now. The schedule is kept in .openspec-ui/scheduled-runs.json, gitignored beside the audit log: "start this one at six" is one person's intent on one machine, not project configuration.

It needs the application open at that time. If it is closed, opening the application is enough: the workspace is read on open and the schedule with it, so the run starts on the next open with nothing else done, and says how late it is. It starts on the path that was chosen when it was asked for, not on whatever the configuration resolves to at that hour; where that path is no longer offered for the change, the dialog opens for a choice and says the configured paths changed. The entry leaves the file only once the run has been opened — a failure to open reports itself as that, and the schedule keeps the run.

A change archived after being scheduled drops its schedule, and the drop says it was archived — distinct from a change that was deleted. A chain does not run against an archived change: its work is done by definition. A run due behind such an entry starts on the same reading.

What to do with a schedule is decided in one place, planScheduleFiring in packages/core/src/scheduled-runs.ts; each host performs the effects it is handed and decides nothing itself. See a-run-can-be-scheduled, corrected by a-schedule-keeps-its-promise.

It is a panel in both hosts. In VS Code it used to be a quick-pick, which gives one line per item and cuts the rest without saying so — measured from a screenshot on 2026-09-08, every named configuration's intent ended mid-word. The panel renders the same components the standalone shell does, so neither host shows less than the other. See run-dialog-in-the-panel.

It shows what the change's configuration resolves to before starting anything: which path will run and why, which agent each stage will use, whether every ceiling can act (said either way, so "examined and fine" never looks like "not examined"), and which named configuration is recommended, with the observations behind it.

The recommendation appears in both hosts. It reads the change's open task count — over HTTP in the standalone shell, from the file in the editor — and, in the editor, the audit log as well. Where a host cannot read the run history, the recommendation says there is no previous run to go on rather than implying it looked.

Four named configurations are offered in the same place — Thorough, Careful, Balanced, Economy — each with what it is for, when it is the wrong choice, and where each of its ceilings came from.

They are named by the effort they ask for, and each carries a level rather than a value: a position in the agent's own range, at the top, two thirds up, a third up, or at the bottom. The value is resolved when the configuration is applied, against the agent that stage will use — max for claude-cli and high for codex-cli are both "highest", and five of the ten registered agents accept no effort at all, for which the surface says the configurations differ in their ceilings alone rather than showing a dial that does nothing.

The positions are thirds, not quarters, and the words are the position rather than a synonym for it: "the middle" of a seven-value range is not where medium lands. Resolved per registered agent, from HARNESS_AGENT_CAPABILITIES (this is presets-by-effort's own table, carried here so a reader sees the value before choosing):

Agent Thorough (highest) Careful (high) Balanced (medium) Economy (lowest)
claude-cli max xhigh medium low
claude-cli-acp max xhigh medium low
codex-cli high medium low minimal
copilot-cli max high low none
copilot-cli-acp max high low none
codex-cli-acp — — — —
deepseek-cli-acp - - - -
gemini-cli — — — —
gemini-cli-acp — — — —
local-llm — — — —
local-llm-acp — — — —
vscode-chat — — — —

Even thirds — 1, 2/3, 1/3, 0 — rather than the tidier-looking 1, 0.75, 0.5, 0: over codex-cli's four values those two put high and medium on the same one and left low unreachable. The mapping lives in packages/core/src/harness-effort-level.ts, and effortLevelCollisions reports where two levels still resolve alike.

None of them sets a model. The model is whichever the workspace already configured, and each says so in its own text rather than leaving it to be inferred from an absence.

Applying one writes the change's harness.json and starts nothing: a path is chosen for one run, a configuration is chosen until someone changes it, and the run that follows should be the one the file describes. What is written is the change's existing file with the configuration laid over it — including the stage's agent beside the resolved effort, since an effort without its agent means nothing — so a key the configuration does not mention, gitStageAllowlist above all, is kept.

Three paths are offered, with the configured one pre-selected:

  • Run the chain — propose → review → apply → verify, pausing where the configuration says to. This is what semi-autonomous and autonomous resolve to.
  • Run one stage — the single-stage picker. This is what assisted resolves to. The picker lists the stages an agent runs under OpenSpec's names, propose, review, apply and verify, and opens on the one the dialog says the run begins at. propose writes the change's proposal, spec deltas, design and task list where they are missing. Archiving is not an agent's stage: use Archive Change, or the chain.
  • Apply in VS Code Chat — the apply stage run by vscode-chat: the change is handed to VS Code's own Chat in agent mode, with the model chosen there, so none of the agents configured for the stages runs. Offered in VS Code only; the standalone shell has no VS Code Chat to open.

Choosing a path other than the configured one applies to that run alone and writes nothing to harness.json. A run is not a configuration change.

The third path used to be its own command, Implement with VS Code Agent. It no longer appears in any menu: two entries whose correct choice depended on a file one of them never read is what made picking between them guesswork.

Explain Change Harness and Recommend Change Harness also leave the active-change menu, because the Run dialog now shows both. They remain in the command palette, and on an archived change — which cannot be started, so there they are the only way to ask.

Resuming, not always starting at propose

A chain does not always start at propose. determineStartStage (harness-chain-runner.ts) reads the change's own artifacts and task counts and enters at the first stage that still has work:

  • propose, while openspec status's proposal, design, or tasks artifact is not yet "done"/"complete".
  • apply, once those three are done but at least one tasks.md checkbox is still unchecked. (review is never a resume point on its own — it has no durable artifact of its own; a chain that resumes after propose finished goes straight to apply.)
  • verify, once every task is checked but the change is not yet archived.

This is resuming, not restarting: running the same chain command twice against a partially-worked change picks up where the previous run left off, rather than repeating finished work.

The unknown-progress case enters at apply, deliberately

When the change's tasks.md cannot be read at all, determineStartStage returns "apply" rather than failing or guessing "verify"/"archive". This is a safety choice, not an arbitrary default: a redundant apply costs one wasted run; a wrong archive costs an unimplemented change being marked done. The cheaper mistake is the one the harness risks.

A chain runs forward, with one exception it can justify

There is no control that steps a running chain back to an earlier stage, and there is exactly one edge the chain takes on its own. Three things answer "can I move around inside a change":

  • A chain (propose -> review -> apply -> verify -> archive -> git) advances. Cancelling ends it; there is no "go back one stage."
  • verify may send work back to apply, and only that. verify writes each declared mechanical check's result onto its own task's checkbox, so a failing check unchecks the task — which archive would then refuse. Where maxStageAttempts allows another attempt, the chain returns to apply instead of failing at the next stage; where it does not, archive refuses exactly as before. This is not a general step-back: it is one condition the machine can evaluate, from the one stage that produces a machine-checked statement that earlier work is unfinished.
  • The panel's per-stage commands (plan, review, implement, verify) go through RunController directly, independent of any chain. Running review again by itself, after a chain has already moved past it, is how you "go back" anywhere else — as a fresh, standalone run, not as a rewound chain.

What a checkpoint offers

Under semi-autonomous (with the default checkpoints.requireConfirmationBetweenSteps, i.e. not explicitly set to false), a chain pauses between stages and waits for a confirmCheckpoint or cancel command. Confirming continues to the next stage; cancelling ends the chain cleanly without starting it. There is no third option — a checkpoint never offers "redo the previous stage."

Standalone Change Editor: "Run with Agentic Harness" paused at a checkpoint between the apply and verify stages, showing the Continue/Cancel choice and the event log so far

A real chain paused before verify. Select the image to inspect the event log and checkpoint controls at full resolution.

Only one mutating run at a time, for the whole workspace

WorkbenchProcessScheduler holds a single mutationLocked flag and a queue per workspace — starting a second mutating run (an implement, a chain, anything that writes) while one is already active enqueues it rather than running both concurrently. ADR 0010's cross-host lease extends the same rule across a second editor or a second host open on the same workspace root. This is easy to assume away — "the UI is stuck" reads differently from "the queue is doing its job" — so it is stated here directly.

What is concurrent: a non-mutating run (status, list, show, validate) is never blocked by a mutating one. That distinction is the whole difference between the two symptoms above.

The git stage

The last stage in the sequence, after archive. When it runs, it pushes the change's current branch, opens a pull request, waits for that pull request's checks, and — only if they pass — merges it.

It runs only when the resolved reviewGate.mode is "agent-sufficient", which only a per-change harness.json may set. Under the default "human-required", a chain ends cleanly after archive and nothing is pushed — stated first, here, because a reader must not have to work out for themselves that the default is safe.

The allowlist

A per-change gitStageAllowlist gates every push/PR/merge action before any git or gh process starts — an action matching nothing in it is blocked, not attempted:

{
  "gitStageAllowlist": {
    "remotes": ["origin"],
    "branches": ["feature/*"]
  },
  "taskAgents": {
    "5.4": { "agent": "copilot-cli", "customAgent": "reviewer" }
  }
}

remotes/branches are non-empty string arrays; each entry may use a simple * wildcard. A global openspec/agent-harness.json may not set this key at all (see "Two configuration files" above).

The merge waits for checks, and refuses without them

The merge waits for the pull request's own checks and refuses one whose checks have not passed. This is not configurable — no setting and no allowlist entry permits merging past a red check (ADR 0014). An absent check result, or a pull request where every check merely skipped, is treated as a refusal, not as permission: gh-pr-gateway.ts's parseCheckStatus requires at least one check to have actually passed. On any refusal, the pushed branch and the already-open pull request are left exactly as they are — the work is not lost, it waits for a person to pick up.

Prerequisites

The stage opens its pull request, reads its checks and merges it on the forge origin is on: GitHub, GitLab or Gitea (see archive above for how it is chosen). It needs that forge's token in the environment - GITHUB_TOKEN or GH_TOKEN, GITLAB_TOKEN, GITEA_TOKEN - or, for GitHub, a gh on PATH that is already authenticated (gh auth login). The push is git's own, with whatever credentials git has on that machine. This project stores no credential: a token is read from the environment when it is needed and sent only to its forge.

Audit

Every push, pull-request creation, and merge this stage attempts — including a blocked attempt — is written to the audit log at .openspec-ui/audit.jsonl under the workspace root.

Nobody has run this stage end to end yet

State this plainly: no one has run the git stage against a real, running pull request from this repository. agentic-harness-git-stage task 4.4 (a live end-to-end run) is still open, and three of the four defects found during that change's own review sat directly behind this untested path. Anyone deciding whether to let a chain merge their code unattended is entitled to know that before deciding, not after.

Agents, models, effort, and spending caps

One row per registered agent id — eleven in total: five raw adapters, six ACP-flavored adapters, and the VS Code chat dispatch target. Columns below come from HARNESS_AGENT_CAPABILITIES (packages/core/src/harness-step-agent.ts — the single source of truth both the validator and each settings UI read) and modelFlag (packages/core/src/agents/registry.ts); the "run against the real binary here" column repeats README.md's own agent table.

id What it runs Accepts a model Accepts an effort, and which values Accepts a spending cap, and in which unit Run against the real binary here?
claude-cli claude Yes (--model) low, medium, high, xhigh, max maxCostUsd (--max-budget-usd, Claude Code v2.1.217+) Yes — used continuously in development
copilot-cli copilot Yes (--model) none, minimal, low, medium, high, xhigh, max maxAiCredits (--max-ai-credits, minimum 30) Yes
codex-cli codex No minimal, low, medium, high (from OpenAI's documented config, not live-verified here) No No — never
gemini-cli gemini No No mechanism No No — never
local-llm HTTP to an OpenAI-compatible /v1/chat/completions, with a bearer key where one is set; where, which model and which key are set outside the harness file, see "The local LLM" below Yes, optional (in the request); see "The local LLM" for the order No mechanism No Yes, 2026-09-25: a Qwen server on the LAN answered a prompt with its key, and refused it without one. Answers in text and can search/fetch public HTML; it edits no file
local-llm-acp Nothing: a coding agent built into the product (ADR 0038), run in process against the local LLM of "The local LLM" below, with tools to read, write, replace in a file, list, search, run a command, search the web and fetch HTML, with repository tools confined to the run's working directory Yes, optional (in the request); see "The local LLM" for the order No mechanism No mechanism Yes, see the change local-llm-codes-in-process for the run that verified it. Asks before a command only when told to (below)
claude-cli-acp claude --input-format stream-json --output-format stream-json Yes (--model) Same as claude-cli Same as claude-cli (maxCostUsd) Progress only — no permission gate, see below
copilot-cli-acp copilot --acp Yes (--model) Same as copilot-cli Same as copilot-cli (maxAiCredits) Yes
codex-cli-acp externally installed codex-acp No No mechanism (deliberately empty — see below) No mechanism (deliberately empty) No — never
gemini-cli-acp gemini --experimental-acp No No mechanism (deliberately empty — see below) No mechanism (deliberately empty) No — never
deepseek-cli-acp dsh --profile acp (the DeepSeek CLI, @deepseek-ai/dsh) No: the model is an ACP session option, DeepSeek-V4-Flash by default No mechanism No mechanism Yes: an implement run on 2026-09-22 wrote its file and ticked its task in 22 s. Needs Node 22.18+ on the 22 line, or 24.2+, first on the PATH, see below
vscode-chat Dispatches the stage to VS Code's own Chat panel — spawns no CLI process at all No No mechanism No mechanism Not applicable — only valid under autonomyLevel: "assisted"

The local LLM

local-llm and local-llm-acp are set outside agent-harness.json: that file is committed, an address on the LAN is one machine's, and a key committed is a key published.

Setting In VS Code In the standalone and the CLI When unset
Base URL, with its /v1 or without openspec-ui.localLlm.baseUrl OPENSPEC_UI_LOCAL_LLM_BASE_URL http://localhost:30000
Model, as its server names it openspec-ui.localLlm.model OPENSPEC_UI_LOCAL_LLM_MODEL the server is asked (below)
API key OpenSpec Workbench: Set LLM Key..., kept in the editor's secret storage OPENSPEC_UI_LOCAL_LLM_API_KEY no Authorization header
Ask before each command (local-llm-acp) openspec-ui.localLlm.agent.askBeforeCommands OPENSPEC_UI_LOCAL_LLM_ASK_BEFORE_COMMANDS=1 commands run without asking
SearXNG search endpoint (local-llm, local-llm-acp) Environment: OPENSPEC_UI_SEARXNG_URL OPENSPEC_UI_SEARXNG_URL search reports that the endpoint is not configured; page fetch still works

In VS Code the editor's value wins and the environment variable is the fallback. All of them are read when the window opens, or when the server or the CLI starts. Agent detection in VS Code - the AI panel, the repository-setup facts and OpenSpec Workbench: Initialize Workspace Harness... - looks for the local LLM at the same address, with the same key and proxy switch, read when it asks. The setup offers local-llm-acp even where the server does not answer yet, marked so in the list; a CLI agent is offered only where it is installed. It does not offer local-llm, which answers in text and edits no file: both of its questions cover stages whose work is files. Set local-llm on review in Harness Settings; on propose, apply or verify the configuration's findings say the stage writes nothing on it (the-local-model-is-offered-where-it-is-set). The key goes to the request's Authorization: Bearer header and nowhere else: not to the audit log, not to a run log.

The model is optional. A run takes the first of: the stage's own model (stepAgents.<stage>.model, which both agents accept); the setting above; the model the server lists at /v1/models (the first, where it lists several; asked once per address while the window or the server is open); default. The run's first output names the model and where it came from, for example Model QuantTrio/Qwen3.6-35B-A3B-AWQ (the model the server serves).

local-llm answers in text and has only two tools, search_web and fetch_webpage; it can review and gather sources, but cannot write a proposal or tick a task. Its tools do not include repository or shell access.

local-llm-acp is a coding agent built into the product (ADR 0038): nothing to install. It runs the model in a loop with eight tools: read_file, write_file, replace_text, list_dir, search_text, run_command, search_web and fetch_webpage; it streams each call and its result as the run's updates.

  • Every path is resolved by its real location, links included, and refused when that lies outside the run's working directory.
  • A command runs in the working directory, is ended at OPENSPEC_UI_LOCAL_LLM_ACP_COMMAND_TIMEOUT_SECONDS (60 s by default), and its output is cut at OPENSPEC_UI_LOCAL_LLM_ACP_MAX_COMMAND_OUTPUT_CHARS (12000 by default).
  • With ask before each command on, a command waits for Allow in the run's permission prompt; leave it off for a chain nobody watches. Writes inside the working directory never ask.
  • A tool call the model wrote as text, which a server whose tool-call parser does not match the model passes through in content (Qwen3-Coder's <function=...><parameter=...> form, or Hermes' JSON, inside <tool_call>), is read as a call.
  • Its loop is bounded by the OPENSPEC_UI_LOCAL_LLM_ACP_* limits in LIMITS.md.

Both web tools return JSON artifacts marked trust: "untrusted". search_web uses only the configured SearXNG JSON endpoint. fetch_webpage accepts public HTTP(S) HTML, follows only validated public redirects, does not run JavaScript, and returns extracted Markdown with source URL, fetch time and available page metadata. Every data table is written as a Markdown table, without its HTML attributes: cells that span rows or columns are repeated, stacked header rows are joined per column, and a one-column (layout) table is read as text. A page is read up to 1 MB and its Markdown up to 32,000 characters; where either is cut, the artifact says truncated: "page" or truncated: "markdown", and a page cut at 1 MB ends with a line saying so.

Ignoring the system proxy

openspec-ui.agents.ignoreSystemProxy in VS Code, or OPENSPEC_UI_IGNORE_SYSTEM_PROXY=1 for the standalone server and the CLI, tells agents to ignore the system proxy. Off by default. Turn it on when the proxy cannot reach your model, such as a server on your LAN behind a proxy that resets such requests.

  • local-llm, local-llm-acp and the check that says whether the local LLM is there connect directly, through a connection pool of their own, whatever proxy the editor applies to its own networking.
  • A CLI agent is started with HTTP_PROXY, HTTPS_PROXY and ALL_PROXY removed from its environment, in either case, and NO_PROXY=*. Only a CLI that reads those variables honours that.
Agent Honours the switch
local-llm, local-llm-acp Yes: they run in the product
claude-cli, claude-cli-acp, copilot-cli, copilot-cli-acp Through the environment: their vendors document reading the proxy variables
codex-cli, codex-cli-acp, gemini-cli, gemini-cli-acp, deepseek-cli-acp, vscode-chat Unknown: not checked here; such a CLI may keep proxy settings of its own

The column is HARNESS_AGENT_CAPABILITIES[*].systemProxy. On the "run against the real binary here" column, plainly: codex and gemini have never been run by this project at all, raw or ACP-flavored — see README.md's "Agent Selection" section for the full statement. If you configure either and it misbehaves, that is the most likely reason.

ACP effort and budget capabilities

copilot-cli-acp and claude-cli-acp accept the same effort and per-invocation budget fields as their plain counterparts. This is read from HARNESS_AGENT_CAPABILITIES, so configuration validation and both settings surfaces use the same capability set as the table above.

What changes when an -acp id is chosen

Choosing claude-cli-acp/copilot-cli-acp/codex-cli-acp/gemini-cli-acp instead of the plain counterpart changes three things:

  1. Structured progress instead of scraped text. The adapter speaks the Agent Client Protocol's session/update notifications rather than parsing free-form stdout. The AI panel, the VS Code output channel and the terminal show the agent's words as prose, each tool call by what it acts on (Edit packages/core/src/index.ts, Bash: npm test), a failed call as failed, and a plan by its progress. claude-cli-acp has no ACP mode of its own; its adapter translates Claude's stream into the same updates, so it is shown exactly as a native ACP agent is.
  2. A permission gate, where the agent offers one. ACP defines session/request_permission; the UI can answer it when the agent actually sends it.
  3. A recorded agent version, compared against the version this project verified against, rather than no version check at all.

Documented exception: claude-cli-acp never emits a permission request. claude has no native ACP mode; its adapter translates claude's own structured output into ACP shape, and that translation never produces session/request_permission — its own permission gate is not something to rely on. Separately (not a documented exception, a live-verified fact): copilot --acp completes file writes and shell commands without ever asking for permission either, despite ACP supporting the mechanism — see README.md's "Agent Selection" section.

deepseek-cli-acp needs Node 22.18 or newer on the 22 line, or 24.2 or newer. dsh runs on whatever node its shim finds, and below that floor it exits with code 0 before answering, without a word - not even for --version, and with nothing on stderr. Its entry point ends with if (import.meta.main) await runCli();, and Node added import.meta.main in 24.2.0, backporting it to 22.18.0; below that it is undefined and the command is never run. Nothing on the 23 line has it. This repository pins 22.11 with Volta, below the floor, and Volta puts that Node first on the PATH of every process it starts, so a server started with npm inside the repository cannot run dsh. The editor's host is not started by Volta and finds the system Node. The Node that would start dsh is asked its version before anything is spawned, and a run that cannot work is refused at once, naming the version found. Where that version cannot be read the run goes ahead, and a process that closes without a word still says which Node it met. Like copilot --acp, dsh wrote its file without asking for permission. Every prompt it is given starts with a short instruction to follow the steps literally and in order, since it does best with instructions taken that way.

codex-cli-acp and gemini-cli-acp carry a deliberately empty capabilities entry ({}) — their adapters render neither an effort flag nor a budget flag at all, by design, not by omission (see agents/codex-acp.ts and agents/gemini-acp.ts's own header comments). This is different from the copilot-cli-acp/claude-cli-acp defect above: here, an empty row is the documented, intended state, not a gap between two tables that drifted apart.

Worked examples

Both examples below are asserted to load through resolveHarnessConfig without error — an example that does not parse is worse than none (see packages/core/src/harness-config.test.ts's describe("HARNESS.md") block).

A worked openspec/agent-harness.json (global)

{
  "stepAgents": {
    "propose": "claude-cli",
    "review": { "agent": "copilot-cli", "effort": "medium" },
    "apply": "claude-cli",
    "verify": "claude-cli"
  },
  "autonomyLevel": "assisted"
}

A worked openspec/changes/<id>/harness.json (per-change)

{
  "autonomyLevel": "semi-autonomous",
  "reviewGate": { "mode": "agent-sufficient" },
  "checkpoints": { "requireConfirmationBetweenSteps": true },
  "budget": { "maxCostUsd": 25, "maxTokens": 2000000 },
  "stepAgents": {
    "apply": { "agent": "claude-cli", "effort": "high", "budget": { "maxCostUsd": 10 } }
  },
  "gitStageAllowlist": {
    "remotes": ["origin"],
    "branches": ["feature/*"]
  }
}

This per-change example is the one configuration shape in this document that actually reaches the git stage (reviewGate.mode: "agent-sufficient" plus a gitStageAllowlist) — see "The git stage" above for what that stage does and its current, unverified state.