diff --git a/.changeset/a-change-is-committed-where-it-is-made.md b/.changeset/a-change-is-committed-where-it-is-made.md
new file mode 100644
index 00000000..121370bf
--- /dev/null
+++ b/.changeset/a-change-is-committed-where-it-is-made.md
@@ -0,0 +1,8 @@
+---
+"@openspec-ui/core": minor
+"@openspec-ui/webui": minor
+"openspec-ui-vscode": minor
+"@openspec-ui/server": minor
+---
+
+**A change is on the server from the moment it is made** (a-change-is-committed-where-it-is-made, ADR 0043 amended). Create Change, in both hosts, now commits the new change on its own branch and pushes it, and says so - or says why the server refused it. The rules written into `CLAUDE.md` and `AGENTS.md` tell every agent *when* to commit, not only where: the planning artifacts on the change's branch with `git push -u origin ` as soon as they are written, the work of every task ticked, nothing left unpushed at the end of a turn, nothing of a change on `main`; run **Write Agent Workflow Rules** to give an existing repository the new rules. The `git` stage commits what the stages left before it pushes. A card whose change's branch is not on the server says "not on the server", and a new action, **Commit Change**, on the card and in Show Actions..., commits everything the change's worktree holds and pushes it. A card of a change's own worktree now offers its actions in the Pipeline's arrangement by step as well, not only on the board by stage, and **Run Change...**, which runs the change in its worktree - before, such a change could be acted on but never run from its card. New messages `OSW-GIT-001`..`OSW-GIT-203`.
diff --git a/README.md b/README.md
index f56b9150..e3ad6ab5 100644
--- a/README.md
+++ b/README.md
@@ -75,6 +75,13 @@ the changes and goes to them: choosing one shows its card in the Pipeline,
where everything is done to it, and its one menu entry, **Show Actions...**,
lists the same actions (ADR 0044).
+A change is on the server from the moment it is made: Create Change commits
+it on its own branch and pushes it, the rules tell every agent to commit and
+push its planning artifacts at once and its work after every task, and the
+`git` stage commits what the stages left before it pushes. A card whose
+change's branch is not on the server says so, and **Commit Change** commits
+what the change's worktree holds and pushes it (ADR 0043).
+
The **Change Graph** view nests each change under the ones it
follows, marks anything waiting on a change that has not landed, folds
away the branches where every change has landed, and lets a relation be
diff --git a/docs/adr/0043-a-change-is-made-in-a-working-directory-of-its-own.md b/docs/adr/0043-a-change-is-made-in-a-working-directory-of-its-own.md
index 1810dda7..6f4fb287 100644
--- a/docs/adr/0043-a-change-is-made-in-a-working-directory-of-its-own.md
+++ b/docs/adr/0043-a-change-is-made-in-a-working-directory-of-its-own.md
@@ -62,6 +62,33 @@ decision 3), so the order was always "in the checkout first".
page can open the change it just made. Another repository's directory
is still refused.
+### Amended on 2026-10-10 (a-change-is-committed-where-it-is-made)
+
+The rules said where a change's commits go and never when. An agent made
+the worktree, wrote the change's files and stopped: four changes of one
+repository sat uncommitted in their worktrees, their branches where `main`
+was, seen by no other directory, host or person, and their cards - drawn
+in the Pipeline's arrangement by step - offered nothing at all. So:
+
+6. **The rules say when.** As soon as the planning artifacts are written,
+ they are committed on the change's branch and the branch pushed with an
+ upstream; after every task ticked, its work with its `tasks.md`; nothing
+ is left uncommitted or unpushed at the end of a turn; nothing of a
+ change is committed to `main`; a refused push is said, not worked
+ around.
+7. **The product commits and pushes what it makes.** "Create Change"
+ commits the new change on its branch and pushes it at once; where the
+ push is refused, the change stays made and the host says why. The `git`
+ stage commits what the stages left before it pushes.
+8. **A card says where its change is not on the server, and commits it.**
+ The survey reads, from the refs it lists anyway, whether a change's own
+ branch was never pushed or is at another commit than the server's, and
+ the card says so. **Commit Change**, on the card and in Show
+ Actions..., commits everything the change's own worktree holds on its
+ branch and pushes it - never in the main checkout, never on the default
+ branch. A card of a change's own worktree offers its actions arranged by
+ step as on the board.
+
## Consequences
- An agent in any repository initialized by this product is told where a
diff --git a/docs/messages.md b/docs/messages.md
index 1b346be2..2b4e294d 100644
--- a/docs/messages.md
+++ b/docs/messages.md
@@ -281,6 +281,72 @@ Why: The CLI's input is not a terminal, so nobody can answer, and the request is
What to do: Run it in a terminal to be asked, or from the card.
+## GIT: git, branches, worktrees, push, pull requests
+
+### OSW-GIT-001
+
+> warning: {name} has no worktree of its own: {why}.
+
+Why: Commit Change commits only in a change's own worktree, on its own branch.
+
+What to do: Make the change's worktree, or work the change there.
+
+### OSW-GIT-002
+
+> warning: {name} is on {branch}, the default branch; nothing of a change is committed there.
+
+Why: A change reaches the default branch through its pull request, never by a commit on it (ADR 0043).
+
+What to do: Work the change on a branch of its own, named after it.
+
+### OSW-GIT-101
+
+> error: Nothing of {name} was committed: {why}
+
+Why: git refused the commit: often no author is configured, or a hook failed.
+
+What to do: Put right what git says, then choose Commit Change again.
+
+### OSW-GIT-102
+
+> error: The push of {branch} was refused{after}: {why}
+
+Why: The server, or the credentials git uses for it, refused the branch. A commit already made stays.
+
+What to do: Put right what the server says - access, credentials, a branch rule - then choose Commit Change again.
+
+### OSW-GIT-103
+
+> error: git stage failed at commit: {why}
+
+Why: The git stage commits what the stages left before it pushes, and git refused that commit.
+
+What to do: Put right what git says in the change's worktree, then run the chain again from git.
+
+### OSW-GIT-201
+
+> info: Committed {name} as {commit} and pushed it to {pushedTo}.
+
+Why: Commit Change committed what the change's worktree held, on its branch, and pushed the branch.
+
+What to do: Nothing: the change is on the server, where every directory, host and person can see it.
+
+### OSW-GIT-202
+
+> info: {name} had nothing to commit; its branch is pushed to {pushedTo}.
+
+Why: Everything in the change's worktree was committed already, and the push brought the server up to date.
+
+What to do: Nothing.
+
+### OSW-GIT-203
+
+> warning: {name} is made in {directory}, but not on the server: {why}
+
+Why: A new change is committed on its branch and pushed at once; that failed, so no other directory, host or person sees it yet.
+
+What to do: Put right what was refused, then choose Commit Change on the change's card.
+
## CLI: the CLI's arguments and subcommands
### OSW-CLI-001
diff --git a/openspec/changes/a-change-is-committed-where-it-is-made/.openspec.yaml b/openspec/changes/a-change-is-committed-where-it-is-made/.openspec.yaml
new file mode 100644
index 00000000..53f62d70
--- /dev/null
+++ b/openspec/changes/a-change-is-committed-where-it-is-made/.openspec.yaml
@@ -0,0 +1,2 @@
+schema: spec-driven
+created: 2026-10-10
diff --git a/openspec/changes/a-change-is-committed-where-it-is-made/design.md b/openspec/changes/a-change-is-committed-where-it-is-made/design.md
new file mode 100644
index 00000000..18b579db
--- /dev/null
+++ b/openspec/changes/a-change-is-committed-where-it-is-made/design.md
@@ -0,0 +1,62 @@
+## Context
+
+ADR 0043 made every change live in a worktree of its own, on a branch named
+after it. The survey pairs a directory with its change by the branch name
+(`ownChange`), and a card of that change may act on it. What was missing is
+the step between "made on this disk" and "on the server", and the actions
+of such a card when the Pipeline is arranged by step.
+
+## Goals / Non-Goals
+
+**Goals:** a change, once made, is committed on its branch and pushed, by
+whoever made it - an agent or the product; a card says where that has not
+happened and does it with one press; a change of one's own worktree is
+worked from its card in either arrangement.
+
+**Non-Goals:** opening the pull request at creation (the git stage, or the
+person, opens it when the change is ready for review: a pull request opened
+at once would put the change in review on the board); changes of another
+machine with no worktree here (a separate change, if wanted).
+
+## Decisions
+
+- **Commit everything in the change's worktree.** The worktree exists for
+ the change, on the change's branch; what it holds is the change's work.
+ `git add --all`, a commit where anything is staged, then a push that sets
+ the upstream where the branch has none. Refused on `main`/`master` and in
+ the main checkout.
+- **Create Change shares what it made.** After `openspec new change`, the
+ scaffold is committed (`: create the change`) and pushed. A refused
+ push does not undo the change; the result carries what was refused, and
+ the hosts say it (`OSW-GIT-203`).
+- **The git stage commits before it pushes** (`: commit what the stages
+ left`), recorded in the audit as a git action. Its failure fails the stage
+ before any push (`OSW-GIT-103`).
+- **"Not on the server" from the refs only.** The survey already lists
+ `refs/heads` and `refs/remotes/origin` once; a change's branch with no
+ remote ref is `never-pushed`, one at another commit is `differs`. Running
+ `git status` in every worktree on every survey would cost a process per
+ directory per read, which the survey avoids by design
+ (the-workspace-clears-what-it-left-behind). So uncommitted files alone
+ are not marked; Commit Change is offered on every own-worktree card
+ anyway, and the rules and the git stage keep files from staying
+ uncommitted.
+- **Commit Change is an action of core's list** (ADR 0044): verb Commit
+ (Set up), noun Change, its own icon `git-commit`; acts at once, so no
+ dots. Refused where the change is worked in the checkout, and while a run
+ of it is going (a run commits as it goes).
+- **Arranged by step, as on the board.** The directory sections beneath the
+ picture draw a change's own card with the same facts and actions the
+ board gives it.
+- **What is said is in the register** (ADR 0046): a new GIT group, so the
+ ratchet of messages said in place stays at 290.
+
+## Risks / Trade-offs
+
+- A push asks for credentials where git has none stored → the host waits
+ for git as any push does; a refusal is said with git's own words.
+- "Commit everything" could commit a stray file in the worktree → the
+ worktree is the change's own; the commit is on its branch, reviewed in
+ its pull request.
+- `differs` also reads a branch the server moved past → rare in this
+ workflow; the words say "not on the server as it is here", which holds.
diff --git a/openspec/changes/a-change-is-committed-where-it-is-made/proposal.md b/openspec/changes/a-change-is-committed-where-it-is-made/proposal.md
new file mode 100644
index 00000000..a0d64c3e
--- /dev/null
+++ b/openspec/changes/a-change-is-committed-where-it-is-made/proposal.md
@@ -0,0 +1,76 @@
+## Why
+
+The owner, on 2026-10-10, about the repository `HppMCP`: its four changes,
+each in a worktree of its own, all read as somebody else's, with no button
+on their cards. Two causes:
+
+- The rules agents are given (ADR 0043) say where a change's commits go -
+ "its planning artifacts, its code, its commits and pushes - in that
+ directory" - and never when. An agent made the worktree, wrote the files
+ and stopped. Every branch was where `main` was, and every change was
+ untracked files on one disk: no other directory, host or person saw it.
+ The product's own Create Change did the same, and the `git` stage pushes
+ but never commits.
+- In the Pipeline's arrangement by step, a card of another working
+ directory is drawn with no actions at all, even for the change that
+ directory was made for. Only the board, by stage, gave it its actions
+ (a-change-is-acted-on-from-its-card).
+
+The goal, in the owner's words: when a change is made, everything needed is
+done, so the change can be worked at once as one's own.
+
+## What Changes
+
+- The agent workflow rules say when: commit the planning artifacts on the
+ change's branch and push it with an upstream as soon as they are
+ written; commit and push after every task ticked; leave nothing
+ uncommitted or unpushed at the end of a turn; nothing of a change on
+ `main`; a refused push is said, not worked around.
+- Create Change, in both hosts, commits the new change on its branch and
+ pushes it at once. A refused push leaves the change made and says why
+ (`OSW-GIT-203`).
+- The `git` stage commits what the stages left before it pushes
+ (`OSW-GIT-103` where that commit fails).
+- The survey says where a change's own branch is not on the server: never
+ pushed, or at another commit. Read from the refs it lists anyway, so no
+ git runs in the directory.
+- A card of a change's own worktree says so ("not on the server"), and a
+ new action, **Commit Change**, commits everything the worktree holds on
+ the change's branch and pushes it. On the card and in Show Actions..., in
+ both hosts; refused in the main checkout and on the default branch.
+- A card of a change's own worktree offers its actions in the arrangement
+ by step as on the board, and **Run Change...**: the owner found on
+ 2026-10-10 that such a change could be acted on but never run - a card's
+ Start was refused for a change this checkout does not have. It runs in
+ the change's worktree, in both hosts.
+- Messages in the register's new GIT group (`OSW-GIT-001`..`203`). ADR 0043
+ amended.
+
+## Capabilities
+
+### New Capabilities
+
+None.
+
+### Modified Capabilities
+
+- `execution-core`: the rules say when to commit and push; a new change is
+ committed and pushed as it is made; the git stage commits what the
+ stages left; Commit Change.
+- `shared-ui`: a card of a change's own worktree acts arranged by step too,
+ and says where its branch is not on the server.
+
+## Impact
+
+- `packages/core`: `change-commit.ts` (new), `change-in-its-worktree.ts`,
+ `agent-workflow-rules.ts`, `harness-chain-runner.ts`, `worktree-survey.ts`
+ and its facts, `change-actions.ts`, `message-register.ts`; their tests.
+- `packages/webui`: `PipelineView.tsx`, `standalone-entry.tsx`.
+- `packages/extension`: the Commit Change command and its manifest entry,
+ Create Change's announcement.
+- `packages/server`: Commit Change in the change-action route; Create
+ Change's answer says whether the change is on the server.
+- `docs/messages.md` regenerated; ADR 0043 amended; README and the
+ extension's README. A changeset for core, webui, extension, server.
+- Existing repositories get the new rules when Write Agent Workflow Rules
+ is run again; changes already made are committed with Commit Change.
diff --git a/openspec/changes/a-change-is-committed-where-it-is-made/specs/execution-core/spec.md b/openspec/changes/a-change-is-committed-where-it-is-made/specs/execution-core/spec.md
new file mode 100644
index 00000000..a8b905ea
--- /dev/null
+++ b/openspec/changes/a-change-is-committed-where-it-is-made/specs/execution-core/spec.md
@@ -0,0 +1,104 @@
+## ADDED Requirements
+
+### Requirement: The git stage commits what the stages left
+
+Before it pushes, the `git` stage SHALL commit everything the change's
+working directory holds that is not committed, on the branch it pushes,
+and record that commit as a git action. It SHALL NOT commit on the default
+branch. Where the commit fails, the stage SHALL fail before any push and
+say why (`OSW-GIT-103`).
+
+#### Scenario: Work left uncommitted
+
+- **WHEN** the `git` stage starts on branch `add-cart` and the stages left
+ files uncommitted
+- **THEN** they are committed as "add-cart: commit what the stages left"
+ before the branch is pushed
+
+#### Scenario: The commit fails
+
+- **WHEN** git refuses that commit
+- **THEN** the stage fails with "git stage failed at commit" and git's
+ words, and nothing is pushed
+
+### Requirement: A change's own worktree is committed and pushed in one action
+
+Commit Change SHALL commit everything a change's own worktree holds on the
+change's branch and push the branch, setting its upstream where it has
+none. It SHALL be refused, committing nothing, where the change has no
+worktree of its own (`OSW-GIT-001`) and where that worktree is on the
+default branch (`OSW-GIT-002`). A refused push SHALL leave the commit made
+and say the server's words (`OSW-GIT-102`). Both hosts SHALL say the
+result through the same function in core.
+
+#### Scenario: A change made and never committed
+
+- **WHEN** Commit Change is chosen for `demo`, whose worktree holds its
+ files untracked on a branch never pushed
+- **THEN** they are committed on `demo`, the branch is on the server with
+ `origin/demo` as its upstream, and nothing of `demo` is on `main`
+
+#### Scenario: A change in the checkout
+
+- **WHEN** Commit Change is asked for a change with no worktree of its own
+- **THEN** nothing is committed, and the answer is `OSW-GIT-001`
+
+## MODIFIED Requirements
+
+### Requirement: Every agent is told how work is done in the repository
+
+The product SHALL write a section between its own markers into `CLAUDE.md`
+and `AGENTS.md` saying that each OpenSpec change is worked in a git
+worktree of its own at `//`, on a
+branch named after the change cut from `origin/main`, with the rule for
+the root and the commands that make it, and that the stage assignment is
+in `openspec/agent-harness.json` and a change's `harness.json`, a stage
+named for another agent being handed back. It SHALL also say when a
+change is committed and pushed: its planning artifacts on its branch, with
+the branch pushed and its upstream set, as soon as they are written; the
+work of every task ticked, with its `tasks.md`; nothing left uncommitted or
+unpushed at the end of a turn; nothing of a change on the default branch;
+and a refused push said, not worked around. It SHALL create a file that is
+missing, rewrite the section in place where it is, and add it to the end
+of a file without it that somebody else wrote only where the person said
+so; it SHALL NOT change anything else in a file.
+
+#### Scenario: A repository with OpenSpec's own AGENTS.md
+
+- **WHEN** the rules are written where `AGENTS.md` exists without the
+ section and `CLAUDE.md` does not exist, and the person declines
+- **THEN** `CLAUDE.md` is created with the section and `AGENTS.md` is left
+ as it was, and the result names it
+
+#### Scenario: Told when, not only where
+
+- **WHEN** an agent reads the rules
+- **THEN** they say to commit the planning artifacts and run `git push -u
+ origin ` as soon as they are written, and to commit and push
+ after every task ticked
+
+### Requirement: A new change is made in a working directory of its own
+
+A working directory SHALL be made for a change that exists nowhere yet;
+one SHALL still be refused for a change that exists only as uncommitted
+files in the main checkout. Making a new change through the product SHALL
+fetch, cut its own directory from `origin/main` on a branch named after
+it, run `openspec new change` there, commit the change on its branch and
+push the branch with its upstream set. A refused push SHALL leave the
+change made, and the host SHALL say why the server does not have it
+(`OSW-GIT-203`). In a repository with no `origin` the change SHALL be made
+in the workspace as before.
+
+#### Scenario: Create in a repository with a remote
+
+- **WHEN** a change `add-cart` is created in a repository `shop` with an
+ `origin`
+- **THEN** `../.worktrees/shop/add-cart` exists on branch `add-cart` cut
+ from `origin/main`, the change is in it, not in the checkout, and the
+ branch is on the server with the change committed
+
+#### Scenario: The push is refused
+
+- **WHEN** the push of the new change's branch is refused
+- **THEN** the change is made in its directory, and the host says it is
+ not on the server, and why
diff --git a/openspec/changes/a-change-is-committed-where-it-is-made/specs/shared-ui/spec.md b/openspec/changes/a-change-is-committed-where-it-is-made/specs/shared-ui/spec.md
new file mode 100644
index 00000000..830c94e5
--- /dev/null
+++ b/openspec/changes/a-change-is-committed-where-it-is-made/specs/shared-ui/spec.md
@@ -0,0 +1,47 @@
+## ADDED Requirements
+
+### Requirement: A change's own worktree is worked from its card in either arrangement
+
+A card of the change a working directory was made for SHALL offer that
+change's actions, from core's one list, whether the Pipeline is arranged by
+stage or by step. Among them, Commit Change SHALL commit what the worktree
+holds on the change's branch and push it; it SHALL be refused, saying why,
+for a change worked in the checkout and while a run of it is going.
+
+Such a card SHALL also offer Run Change..., as a card of this checkout
+does, and the run SHALL run in that worktree, with that worktree's harness
+settings and task list, in both hosts. While a run of the change reports
+from there, the card SHALL say it is running and offer no start.
+
+The survey SHALL say, from the refs it already lists and without running
+git in the directory, where that change's branch is not on the server:
+never pushed, or at another commit than the server's. The card SHALL say
+so in words: "not on the server: its branch was never pushed", or "not on
+the server as it is here".
+
+#### Scenario: Arranged by step
+
+- **WHEN** the Pipeline is arranged by step and a working directory below
+ it was made for `fresh`
+- **THEN** `fresh`'s card there offers its actions, Commit Change among
+ them, as it does on the board
+
+#### Scenario: Running a change of its own worktree
+
+- **WHEN** Run Change... is pressed on `fresh`'s card, `fresh` being only in
+ its own worktree
+- **THEN** the run dialog opens for `fresh` with that worktree's harness
+ and open tasks, and a run chosen in it runs in that worktree
+
+#### Scenario: A branch never pushed
+
+- **WHEN** `fresh`'s branch exists here and the server has no branch of
+ that name
+- **THEN** the card says "not on the server: its branch was never pushed",
+ and Commit Change can be pressed
+
+#### Scenario: A change worked in the checkout
+
+- **WHEN** a change is worked in this checkout
+- **THEN** its Commit Change says "This change is worked in this checkout,
+ not on a branch of its own." and does nothing
diff --git a/openspec/changes/a-change-is-committed-where-it-is-made/tasks.md b/openspec/changes/a-change-is-committed-where-it-is-made/tasks.md
new file mode 100644
index 00000000..3857b70b
--- /dev/null
+++ b/openspec/changes/a-change-is-committed-where-it-is-made/tasks.md
@@ -0,0 +1,85 @@
+From ADR 0043, amended on 2026-10-10: the owner's report that the changes of
+`HppMCP`, each in its own worktree, read as somebody else's and offered no
+button.
+
+## 1. Core
+
+- [x] 1.1 `change-commit.ts`: commit what a directory holds and push its
+ branch with an upstream; Commit Change for a change's own worktree;
+ refused in the checkout and on the default branch. What is said, from the
+ register.
+ `change-commit.test.ts`, real git against a bare remote, 2 tests
+ (2026-10-10).
+- [x] 1.2 Create Change commits the new change on its branch and pushes it;
+ a refused push leaves it made and says why.
+ `change-in-its-worktree.test.ts`, 3 tests (2026-10-10).
+- [x] 1.3 The rules say when to commit and push.
+ `agent-workflow-rules.ts`; its test asserts the new lines (2026-10-10).
+- [x] 1.4 The git stage commits what the stages left before it pushes.
+ `harness-chain-runner.ts`; 2 tests in its git stage section, 8 pass
+ (2026-10-10).
+- [x] 1.5 The survey says where a change's own branch is not on the server,
+ from the refs it lists anyway.
+ `worktree-survey.ts`, `notOnServer`; a test over never pushed, at
+ another commit and level (2026-10-10).
+- [x] 1.6 Commit Change in core's list of actions, with its refusals.
+ `change-actions.ts`; `change-actions.test.ts` (2026-10-10).
+- [x] 1.7 Messages `OSW-GIT-001`, `002`, `101`, `102`, `103`, `201`, `202`,
+ `203`; `docs/messages.md` regenerated; the ratchet stays at 290.
+
+## 2. Hosts
+
+- [x] 2.1 The Pipeline: a card of a change's own worktree offers its actions
+ arranged by step too, and says where its branch is not on the server.
+ `PipelineView.test.tsx`, 1 new test; 107 pass with the actions tests
+ (2026-10-10).
+- [x] 2.2 The extension: the Commit Change command and manifest entry; Create
+ Change says whether the change was pushed.
+ `commands.test.ts`, 1 new test; all 535 of the extension's unit tests pass
+ (2026-10-10).
+- [x] 2.3 The standalone server: Commit Change in the change-action route;
+ Create Change's answer says whether the change is on the server, and the
+ page says it.
+ `change-actions-rest.test.ts` (2026-10-10).
+
+- [x] 2.4 Run Change... on a card of a change's own worktree, in both
+ arrangements; the run runs in that worktree, in both hosts. The owner's
+ check on 2026-10-10 showed the card with every action but the run.
+ `PipelineView.test.tsx` (the button, its directory, running); the
+ extension's `runChange` resolves the change where it is worked and its
+ Start is accepted for a change of its own worktree (`commands.test.ts`,
+ `pipeline-panel.test.ts`); the standalone's run dialog, schedule and
+ chain take the worktree's root. Seen live on `HppMCP`: the dialog for
+ `mailbox-integrations-readonly` read its worktree's harness and "24 tasks
+ still open" (2026-10-10).
+
+## 3. Documents
+
+- [x] 3.1 ADR 0043 amended (decisions 6-8).
+- [x] 3.2 README and the extension's README.
+- [x] 3.3 A changeset: core, webui, extension, server - minor.
+
+## 4. Checks
+
+- [x] 4.1 `npm run typecheck && npm run lint`; the affected tests with two
+ workers.
+ 2026-10-10: typecheck clean; lint 0 errors (4 warnings, none in files
+ this change touched). Core: change-commit, change-in-its-worktree,
+ worktree-survey 33, change-actions, agent-workflow-rules, the message
+ register and the chain runner's git stage; webui PipelineView 107;
+ extension 535 + 1; server change-actions 5 - with two workers.
+- [x] 4.2 `openspec validate a-change-is-committed-where-it-is-made
+ --strict`, and the merge gate with the worktree's absolute path as
+ `--cwd`.
+ 2026-10-10: valid under `--strict`; the gate, run as `openspec-ui-cli
+ validate changes`, named only 4.1, 4.2 and 4.3 as open.
+- [x] 4.3 **Human-only**: in `HppMCP`, on the extension built from this
+ branch, a change of its own worktree shows "not on the server" and its
+ actions in the arrangement by step; Commit Change puts it on the server;
+ a change created afterwards is pushed at once.
+ 2026-10-10, the owner, on the extension 0.102.0 built from this branch:
+ "Confirmed". Arranged by step, the cards of the four changes worked in
+ their own worktrees showed their actions and, after the first check
+ found it missing, Run Change..., which opened each change's run dialog;
+ Commit Change committed and pushed. "not on the server" was gone by then,
+ every branch being on the server.
diff --git a/packages/core/src/agent-workflow-rules.test.ts b/packages/core/src/agent-workflow-rules.test.ts
index ee3132e9..9b464183 100644
--- a/packages/core/src/agent-workflow-rules.test.ts
+++ b/packages/core/src/agent-workflow-rules.test.ts
@@ -49,6 +49,11 @@ describe("the workflow rules", () => {
// And when it does not, the agent does it and goes on: told to stop,
// Copilot stopped, with the change unmade (2026-10-07).
expect(rules).toContain("commit the setup - nothing of any change - to `main`, push it, and go on");
+ // When, not only where (a-change-is-committed-where-it-is-made).
+ expect(rules).toContain("as soon as `openspec new change` has run and the planning artifacts are written, commit them on the change's branch and push it");
+ expect(rules).toContain("git push -u origin ");
+ expect(rules).toContain("after every task you tick, commit its work with its `tasks.md`, and push");
+ expect(rules).toContain("Nothing of a change is committed to `main`, here or anywhere.");
});
it("creates both files where there are none", async () => {
diff --git a/packages/core/src/agent-workflow-rules.ts b/packages/core/src/agent-workflow-rules.ts
index 17b57be2..6a6590e0 100644
--- a/packages/core/src/agent-workflow-rules.ts
+++ b/packages/core/src/agent-workflow-rules.ts
@@ -68,6 +68,26 @@ export function renderWorkflowRules(repositoryName: string, defaultBranch = "mai
" Then run `openspec new change ` there, and do everything else for the change - its planning artifacts, its code, its commits and pushes - in that directory.",
"- One change, one branch, one pull request, each named after the change.",
"",
+ "### Committed and pushed as it is made",
+ "",
+ // a-change-is-committed-where-it-is-made: told where to commit and never
+ // when, an agent wrote a change's files and stopped, and four changes of
+ // one repository sat uncommitted in their worktrees, seen nowhere else.
+ "- A change only on this disk is seen by no other working directory, host or person, and nothing can be done to it from its card. So, in the change's worktree:",
+ " - as soon as `openspec new change` has run and the planning artifacts are written, commit them on the change's branch and push it, setting its upstream;",
+ "",
+ " ```",
+ " git add openspec/changes/",
+ " git commit -m \": propose\"",
+ " git push -u origin ",
+ " ```",
+ "",
+ " - after every task you tick, commit its work with its `tasks.md`, and push;",
+ " - before you end your turn, leave nothing of the change uncommitted or unpushed.",
+ `- Nothing of a change is committed to \`${defaultBranch}\`, here or anywhere. It reaches \`${defaultBranch}\` through its pull request.`,
+ "- The pull request is opened when the change is ready for review: by the Agentic Harness's git stage, or by you where you are asked to.",
+ "- Where a push is refused, say so, and why; do not work around it.",
+ "",
"### Who does which stage",
"",
"- Which agent proposes, reviews, applies and verifies a change is set in `openspec/agent-harness.json` (`stepAgents`) and, for one change, in `openspec/changes//harness.json`. Read it before starting a stage.",
diff --git a/packages/core/src/change-actions.test.ts b/packages/core/src/change-actions.test.ts
index 7b246f76..da78e877 100644
--- a/packages/core/src/change-actions.test.ts
+++ b/packages/core/src/change-actions.test.ts
@@ -45,6 +45,13 @@ describe("the actions on a change", () => {
expect(stateOf(running, "sendMessage")).toMatchObject({ enabled: true });
});
+ it("commits a change only on its own branch, and not while a run of it commits as it goes", () => {
+ // a-change-is-committed-where-it-is-made.
+ expect(stateOf(idle, "commitChange")).toMatchObject({ enabled: false, reason: "This change is worked in this checkout, not on a branch of its own." });
+ expect(stateOf({ ...idle, where: "worktree", notOnServer: true }, "commitChange")).toMatchObject({ enabled: true });
+ expect(stateOf({ ...idle, where: "worktree", running: true }, "commitChange")).toMatchObject({ enabled: false, reason: "A run of this change is going: stop it first." });
+ });
+
it("archives a change whose open tasks were not read, and leaves the refusal to the archive", () => {
expect(stateOf({ where: "checkout", running: false }, "archiveChange")).toMatchObject({ enabled: true });
});
diff --git a/packages/core/src/change-actions.ts b/packages/core/src/change-actions.ts
index 45c28af5..62c3abe3 100644
--- a/packages/core/src/change-actions.ts
+++ b/packages/core/src/change-actions.ts
@@ -24,6 +24,7 @@ export type ChangeActionId =
| "recommendChangeHarness"
| "openWorktree"
| "openChangeCopy"
+ | "commitChange"
| "configureChangeHarness"
| "addRelation"
| "removeRelation"
@@ -75,6 +76,9 @@ export const CHANGE_ACTIONS: readonly ChangeAction[] = [
action("recommendChangeHarness", "Recommend Change Harness", "lightbulb", false),
action("openWorktree", "Open Worktree", "folder-opened", false),
action("openChangeCopy", "Open Change Copy...", "files", false),
+ // Everything its worktree holds, committed on its branch and pushed
+ // (a-change-is-committed-where-it-is-made).
+ action("commitChange", "Commit Change", "git-commit", true),
action("configureChangeHarness", "Configure Change Harness", "gear", true),
action("addRelation", "Add Relation...", "link", true),
action("removeRelation", "Remove Relation...", "debug-disconnect", true),
@@ -103,6 +107,9 @@ export interface ChangeActionFacts {
running: boolean;
/** Its tasks still open, where they were read. */
openTasks?: number;
+ /** Its branch is not on the server as it is here: never pushed, or at
+ * another commit (a-change-is-committed-where-it-is-made). */
+ notOnServer?: boolean;
}
export interface ChangeActionState {
@@ -128,6 +135,12 @@ function reasonAgainst(action: ChangeAction, facts: ChangeActionFacts): string |
case "openWorktree":
case "openChangeCopy":
return facts.where === "checkout" ? "This change is worked in this checkout." : undefined;
+ case "commitChange":
+ // Never in the checkout: what is there goes on the default branch
+ // (ADR 0043). A run commits as it goes, and two committers in one
+ // tree is one too many.
+ if (facts.where === "checkout") return "This change is worked in this checkout, not on a branch of its own.";
+ return facts.running ? RUNNING : undefined;
case "archiveChange":
if (facts.running) return RUNNING;
return facts.openTasks !== undefined && facts.openTasks > 0
diff --git a/packages/core/src/change-commit.test.ts b/packages/core/src/change-commit.test.ts
new file mode 100644
index 00000000..1f4c28d4
--- /dev/null
+++ b/packages/core/src/change-commit.test.ts
@@ -0,0 +1,88 @@
+import { execFile } from "node:child_process";
+import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
+import os from "node:os";
+import path from "node:path";
+import { promisify } from "node:util";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { commitChange } from "./change-commit.js";
+import { gitIsolationArgs } from "./test-support/git-isolation.js";
+
+// a-change-is-committed-where-it-is-made. Real git against a bare remote,
+// because what is asserted is what reaches the server: the change's branch
+// with the change on it.
+//
+// every-varying-check-has-a-budget: real git processes, pushes through a
+// local remote included, whose time varies with the machine and its load.
+vi.setConfig({ testTimeout: 90_000 });
+
+const run = promisify(execFile);
+const roots: string[] = [];
+
+afterEach(async () => {
+ await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }).catch(() => undefined)));
+});
+
+async function git(cwd: string, args: string[]): Promise {
+ const { stdout } = await run("git", [...(await gitIsolationArgs()), ...args], { cwd });
+ return stdout.trim();
+}
+
+async function write(file: string, text: string): Promise {
+ await mkdir(path.dirname(file), { recursive: true });
+ await writeFile(file, text, "utf8");
+}
+
+/** A bare remote, a main checkout pushed to it, and a change `demo` whose
+ * worktree holds its files uncommitted, on a branch never pushed: what an
+ * agent left behind when it was told where to commit and never when. */
+async function uncommittedChange(): Promise<{ work: string; worktree: string; remote: string }> {
+ const root = await mkdtemp(path.join(os.tmpdir(), "openspec-commit-change-"));
+ roots.push(root);
+ const remote = path.join(root, "remote.git");
+ const work = path.join(root, "work");
+ await git(root, ["init", "-q", "--bare", "-b", "main", remote]);
+ await mkdir(work);
+ await git(work, ["init", "-q", "-b", "main"]);
+ // The product commits through its own git, so the identity, and no
+ // signing, live in the repository.
+ await git(work, ["config", "user.name", "Fixture"]);
+ await git(work, ["config", "user.email", "fixture@example.com"]);
+ await git(work, ["config", "commit.gpgsign", "false"]);
+ await git(work, ["remote", "add", "origin", remote]);
+ await write(path.join(work, "openspec", "config.yaml"), "schema: spec-driven\n");
+ await git(work, ["add", "."]);
+ await git(work, ["commit", "-q", "-m", "first"]);
+ await git(work, ["push", "-q", "-u", "origin", "main"]);
+
+ const worktree = path.join(root, "wt-demo");
+ await git(work, ["worktree", "add", "-q", "-b", "demo", worktree]);
+ await write(path.join(worktree, "openspec", "changes", "demo", "proposal.md"), "## Why\n\nBecause.\n");
+ await write(path.join(worktree, "openspec", "changes", "demo", "tasks.md"), "## 1. Do\n\n- [ ] 1.1 It\n");
+ return { work, worktree, remote };
+}
+
+describe("commitChange", () => {
+ it("commits what the change's worktree holds on its branch, and pushes the branch with an upstream", async () => {
+ const { work, worktree, remote } = await uncommittedChange();
+
+ const result = await commitChange({ repositoryRoot: work, changeName: "demo" });
+
+ expect(result).toMatchObject({ ok: true, pushedTo: "origin/demo" });
+ expect(await git(worktree, ["status", "--porcelain"])).toBe("");
+ expect(await git(remote, ["ls-tree", "-r", "--name-only", "demo"])).toContain("openspec/changes/demo/proposal.md");
+ expect(await git(worktree, ["rev-parse", "--abbrev-ref", "demo@{upstream}"])).toBe("origin/demo");
+ expect(await git(worktree, ["log", "-1", "--format=%s"])).toBe("demo: commit what its worktree holds");
+ // Nothing of the change reached the default branch.
+ expect(await git(remote, ["ls-tree", "-r", "--name-only", "main"])).not.toContain("openspec/changes/demo/proposal.md");
+ });
+
+ it("refuses a change with no worktree of its own, committing nothing in the checkout", async () => {
+ const { work } = await uncommittedChange();
+ await write(path.join(work, "openspec", "changes", "here", "proposal.md"), "## Why\n");
+
+ const result = await commitChange({ repositoryRoot: work, changeName: "here" });
+
+ expect(result).toMatchObject({ ok: false, kind: "no-worktree" });
+ expect(await git(work, ["status", "--porcelain"])).not.toBe("");
+ });
+});
diff --git a/packages/core/src/change-commit.ts b/packages/core/src/change-commit.ts
new file mode 100644
index 00000000..d5da790e
--- /dev/null
+++ b/packages/core/src/change-commit.ts
@@ -0,0 +1,127 @@
+// A change committed and pushed where it is made
+// (a-change-is-committed-where-it-is-made, ADR 0043 amended 2026-10-10).
+//
+// A change is made in a worktree of its own, on a branch named after it.
+// Until that branch holds the change and the server has it, the change is
+// files in one directory of one machine: no other directory, host or person
+// sees it, and nothing can be done to it from its card. Agents were told
+// where to commit and never when, and four changes of one repository sat
+// uncommitted in their worktrees with their branches where main was.
+//
+// These functions commit what a change's worktree holds and push its
+// branch: once the change is made, from its card, and before the git
+// stage pushes. Never on the default branch, and never in the main
+// checkout. What they say is said from the register (ADR 0046).
+
+import { openGit } from "./git-client.js";
+import { changeOfWorktree } from "./change-worktrees.js";
+import { isValidChangeName } from "./change-name.js";
+import { createGitWrapper, type GitWrapper } from "./git.js";
+import { say, type SaidMessage } from "./message-register.js";
+
+const DEFAULT_BRANCHES = new Set(["main", "master"]);
+
+function gitMessage(error: unknown): string {
+ return (error instanceof Error ? error.message : String(error)).trim();
+}
+
+/** Commits everything a directory holds that is not committed, with this
+ * message. The commit, or `undefined` where there was nothing to commit. */
+export async function commitWhatIsLeft(directory: string, message: string): Promise {
+ const git = openGit(directory);
+ await git.raw(["add", "--all"]);
+ if ((await git.raw(["status", "--porcelain"])).trim().length === 0) return undefined;
+ await git.raw(["commit", "-m", message]);
+ return (await git.raw(["rev-parse", "HEAD"])).trim();
+}
+
+export interface SharedChange {
+ ok: true;
+ /** Where the branch was pushed: `/`. */
+ pushedTo: string;
+ /** The commit made, where there was something to commit. */
+ commit?: string;
+}
+
+export type ShareRefusalKind = "not-a-change" | "no-worktree" | "default-branch" | "commit-failed" | "push-rejected";
+
+export interface ShareRefusal {
+ ok: false;
+ kind: ShareRefusalKind;
+ /** What is said of it, from the register. */
+ said: SaidMessage;
+ /** The commit made before the push was refused, which stays. */
+ commit?: string;
+}
+
+function refused(kind: ShareRefusalKind, said: SaidMessage, commit?: string): ShareRefusal {
+ return { ok: false, kind, said, ...(commit !== undefined ? { commit } : {}) };
+}
+
+/** Commits what the directory holds and pushes its branch, setting the
+ * branch's upstream where it has none. Refuses the default branch. */
+export async function commitAndPushDirectory(options: {
+ changeName: string;
+ directory: string;
+ branch: string;
+ message: string;
+}): Promise {
+ const { changeName, directory, branch } = options;
+ if (DEFAULT_BRANCHES.has(branch)) return refused("default-branch", say("OSW-GIT-002", { name: changeName, branch }));
+ let commit: string | undefined;
+ try {
+ commit = await commitWhatIsLeft(directory, options.message);
+ } catch (error) {
+ return refused("commit-failed", say("OSW-GIT-101", { name: changeName, why: gitMessage(error) }));
+ }
+ const upstreams = await createGitWrapper({ cwd: directory }).branchUpstreams().catch(() => []);
+ const upstream = upstreams.find((entry) => entry.branch === branch)?.upstream;
+ const remote = upstream !== undefined && upstream.includes("/") ? upstream.slice(0, upstream.indexOf("/")) : "origin";
+ try {
+ await openGit(directory).raw(upstream !== undefined ? ["push", remote, branch] : ["push", "-u", remote, branch]);
+ } catch (error) {
+ const after = commit !== undefined ? ` after committing ${commit.slice(0, 8)}` : "";
+ return refused("push-rejected", say("OSW-GIT-102", { branch, after, why: gitMessage(error) }), commit);
+ }
+ return { ok: true, pushedTo: `${remote}/${branch}`, ...(commit !== undefined ? { commit } : {}) };
+}
+
+/** What a host says of Commit Change's result, in both hosts alike. */
+export function sayCommitChange(changeName: string, result: SharedChange | ShareRefusal): SaidMessage {
+ if (!result.ok) return result.said;
+ return result.commit !== undefined
+ ? say("OSW-GIT-201", { name: changeName, commit: result.commit.slice(0, 8), pushedTo: result.pushedTo })
+ : say("OSW-GIT-202", { name: changeName, pushedTo: result.pushedTo });
+}
+
+/** What a host says where a new change was made but its push refused, or
+ * `undefined` where the change is on the server. */
+export function sayChangeNotShared(changeName: string, directory: string, shared: SharedChange | ShareRefusal | undefined): SaidMessage | undefined {
+ return shared === undefined || shared.ok ? undefined : say("OSW-GIT-203", { name: changeName, directory, why: shared.said.text });
+}
+
+export interface CommitChangeOptions {
+ repositoryRoot: string;
+ changeName: string;
+ /** Test seam; production lists the repository's worktrees. */
+ git?: Pick;
+}
+
+/** Commits everything in a change's own worktree and pushes its branch:
+ * Commit Change, on its card and in Show Actions... */
+export async function commitChange(options: CommitChangeOptions): Promise {
+ const { changeName } = options;
+ if (!isValidChangeName(changeName)) return refused("not-a-change", say("OSW-CHG-001", { name: changeName }));
+ let worktrees;
+ try {
+ worktrees = await (options.git ?? createGitWrapper({ cwd: options.repositoryRoot })).worktreeList();
+ } catch {
+ return refused("no-worktree", say("OSW-GIT-001", { name: changeName, why: "this is not a git repository git can list" }));
+ }
+ const own = worktrees.find((worktree, index) => changeOfWorktree(worktree, index === 0) === changeName);
+ if (own === undefined || own.branch === undefined) {
+ return refused("no-worktree", say("OSW-GIT-001", { name: changeName, why: "a change worked in this checkout is committed on its own branch, never on the default one" }));
+ }
+ if (own.branch === worktrees[0]?.branch) return refused("default-branch", say("OSW-GIT-002", { name: changeName, branch: own.branch }));
+ return commitAndPushDirectory({ changeName, directory: own.path, branch: own.branch, message: `${changeName}: commit what its worktree holds` });
+}
diff --git a/packages/core/src/change-in-its-worktree.test.ts b/packages/core/src/change-in-its-worktree.test.ts
index 1b2a6a5b..ec4fac49 100644
--- a/packages/core/src/change-in-its-worktree.test.ts
+++ b/packages/core/src/change-in-its-worktree.test.ts
@@ -40,6 +40,7 @@ describe("createChangeInItsWorktree", () => {
const root = await repository();
const { git, calls, added } = fakeGit("https://example.test/shop.git");
const created: Array<{ name: string; cwd: string }> = [];
+ const shares: unknown[] = [];
const made = await createChangeInItsWorktree({
repositoryRoot: root,
@@ -47,12 +48,33 @@ describe("createChangeInItsWorktree", () => {
git,
rootSources: { env: {}, homeDirectory: path.join(path.dirname(root), "no-home") },
create: async (name, options) => { created.push({ name, cwd: options.cwd ?? "" }); return {}; },
+ share: async (shared) => { shares.push(shared); return { ok: true, pushedTo: "origin/add-cart", commit: "c0ffee" }; },
});
expect(calls).toEqual(["fetch origin"]);
expect(added).toEqual([{ path: path.join(path.dirname(root), ".worktrees", "shop", "add-cart"), branch: "add-cart", base: "origin/main" }]);
expect(created).toEqual([{ name: "add-cart", cwd: added[0]!.path }]);
- expect(made).toEqual({ directory: added[0]!.path, branch: "add-cart" });
+ // Committed on its branch and pushed at once
+ // (a-change-is-committed-where-it-is-made).
+ expect(shares).toEqual([{ changeName: "add-cart", directory: added[0]!.path, branch: "add-cart", message: "add-cart: create the change" }]);
+ expect(made).toEqual({ directory: added[0]!.path, branch: "add-cart", shared: { ok: true, pushedTo: "origin/add-cart", commit: "c0ffee" } });
+ });
+
+ it("keeps the change made where the push is refused, and says why", async () => {
+ const root = await repository();
+ const { git } = fakeGit("https://example.test/shop.git");
+
+ const made = await createChangeInItsWorktree({
+ repositoryRoot: root,
+ changeName: "add-cart",
+ git,
+ rootSources: { env: {}, homeDirectory: path.join(path.dirname(root), "no-home") },
+ create: async () => ({}),
+ share: async () => { throw new Error("could not read Username"); },
+ });
+
+ expect(made.branch).toBe("add-cart");
+ expect(made.shared).toMatchObject({ ok: false, kind: "commit-failed", said: { code: "OSW-GIT-101", text: "Nothing of add-cart was committed: could not read Username" } });
});
it("makes the change where it always was in a repository with no remote", async () => {
diff --git a/packages/core/src/change-in-its-worktree.ts b/packages/core/src/change-in-its-worktree.ts
index 612e5451..f80c09a0 100644
--- a/packages/core/src/change-in-its-worktree.ts
+++ b/packages/core/src/change-in-its-worktree.ts
@@ -8,9 +8,11 @@
// round, and the product keeps it too: the directory first, cut from the
// default branch as the server has it, and the change made inside it.
+import { commitAndPushDirectory, type ShareRefusal, type SharedChange } from "./change-commit.js";
import { createGitWrapper, type GitWrapper } from "./git.js";
import { planChangeWorktree } from "./change-worktrees.js";
import { DEFAULT_BRANCH, DEFAULT_REMOTE } from "./main-drift-facts.js";
+import { say } from "./message-register.js";
import { createChange, type CreateChangeOptions } from "./openspec.js";
import type { WorktreeRootSources } from "./worktree-root.js";
@@ -20,6 +22,10 @@ export interface ChangeMade {
directory: string;
/** The branch it is on, where it has a directory of its own. */
branch?: string;
+ /** Whether the change was committed on that branch and the branch pushed
+ * (a-change-is-committed-where-it-is-made). A push refused does not undo
+ * the change: it is made, and says why the server does not have it. */
+ shared?: SharedChange | ShareRefusal;
}
/** Makes `changeName` in a working directory of its own, on a branch of
@@ -38,6 +44,7 @@ export async function createChangeInItsWorktree(options: {
git?: GitWrapper;
rootSources?: WorktreeRootSources;
create?: typeof createChange;
+ share?: typeof commitAndPushDirectory;
}): Promise {
const git = options.git ?? createGitWrapper({ cwd: options.repositoryRoot });
const create = options.create ?? createChange;
@@ -58,5 +65,15 @@ export async function createChangeInItsWorktree(options: {
if (!plan.ok) throw new Error(`${plan.refusal.reason}; ${plan.refusal.remedy}`);
await git.worktreeAdd({ path: plan.path, branch: plan.branch, base: plan.base });
await create(options.changeName, { cwd: plan.path }, options.createOptions);
- return { directory: plan.path, branch: plan.branch };
+ // Committed and pushed at once: a change only on this disk is seen by no
+ // other directory, host or person, and its card can do nothing with it
+ // (a-change-is-committed-where-it-is-made).
+ const share = options.share ?? commitAndPushDirectory;
+ const shared = await share({ changeName: options.changeName, directory: plan.path, branch: plan.branch, message: `${options.changeName}: create the change` })
+ .catch((error: unknown): ShareRefusal => ({
+ ok: false,
+ kind: "commit-failed",
+ said: say("OSW-GIT-101", { name: options.changeName, why: error instanceof Error ? error.message : String(error) }),
+ }));
+ return { directory: plan.path, branch: plan.branch, shared };
}
diff --git a/packages/core/src/harness-chain-runner.test.ts b/packages/core/src/harness-chain-runner.test.ts
index 42a12a9c..1ba4c54e 100644
--- a/packages/core/src/harness-chain-runner.test.ts
+++ b/packages/core/src/harness-chain-runner.test.ts
@@ -223,11 +223,15 @@ function makeGitStageDeps(options: {
createPrError?: string;
checksState?: "pass" | "fail" | "none";
checksReason?: string;
+ /** What committing the stages' leftovers answers, or throws. */
+ leftCommit?: string;
+ commitError?: string;
} = {}) {
const calls = {
gitPush: 0,
prCreate: 0,
prMerge: 0,
+ commits: [] as Array<{ directory: string; message: string; beforePush: boolean }>,
};
const git: GitWrapper = {
status: vi.fn(),
@@ -286,6 +290,11 @@ function makeGitStageDeps(options: {
deps: {
createGitWrapper: () => git,
createPullRequestGateway: () => gateway,
+ commitWhatIsLeft: vi.fn(async (directory: string, message: string) => {
+ calls.commits.push({ directory, message, beforePush: calls.gitPush === 0 });
+ if (options.commitError !== undefined) throw new Error(options.commitError);
+ return options.leftCommit;
+ }),
},
gateway,
git,
@@ -597,6 +606,42 @@ describe("HarnessChainRunner — git stage gating", () => {
expect(gitStage.calls.prCreate).toBe(1);
expect(gitStage.calls.prMerge).toBe(1);
expect(auditLog.entries.filter((entry) => entry.agent === "git-stage" && entry.outcome === "completed")).toHaveLength(3);
+ // What the stages left is committed before the push
+ // (a-change-is-committed-where-it-is-made); here there was nothing.
+ expect(gitStage.calls.commits).toEqual([{ directory: root, message: "demo: commit what the stages left", beforePush: true }]);
+ });
+
+ it("commits what the stages left before it pushes, and fails without pushing where that commit fails", async () => {
+ for (const outcome of ["committed", "failed"] as const) {
+ const root = await temporaryRoot();
+ await writeGlobalHarnessConfig(root, { autonomyLevel: "semi-autonomous", stepAgents: { verify: "claude-cli" } });
+ await writeChangeHarnessConfig(root, "demo", {
+ reviewGate: { mode: "agent-sufficient" },
+ gitStageAllowlist: { remotes: ["origin"], branches: ["main", "feature/*"] },
+ });
+ mockStatus(true);
+ await writeTasks(root, 0, 3);
+ mockArchiveSucceeds();
+ const { runner } = makeCompletingRunner();
+ const gitStage = makeGitStageDeps(outcome === "committed" ? { leftCommit: "abc1234567" } : { commitError: "nothing to commit, author unknown" });
+ const auditLog = new InMemoryAuditLog();
+ const chain = new HarnessChainRunner({ resolveRunner: () => runner, auditLog, ...gitStage.deps });
+ const command = baseCommand(root);
+
+ const events: Event[] = [];
+ for await (const event of chain.run(command)) {
+ events.push(event);
+ if (event.kind === "checkpoint") chain.confirmCheckpoint(command.runId);
+ }
+
+ if (outcome === "committed") {
+ expect(events.at(-1)).toMatchObject({ kind: "completed" });
+ expect(auditLog.entries.some((entry) => entry.agent === "git-stage" && entry.outcome === "completed" && entry.summary === "committed abc12345")).toBe(true);
+ } else {
+ expect(events.at(-1)).toMatchObject({ kind: "failed", reason: "git stage failed at commit: nothing to commit, author unknown" });
+ expect(gitStage.calls.gitPush).toBe(0);
+ }
+ }
});
it("blocks a non-allowlisted git target before any push/pr/merge call", async () => {
diff --git a/packages/core/src/harness-chain-runner.ts b/packages/core/src/harness-chain-runner.ts
index 8328be65..ba047f10 100644
--- a/packages/core/src/harness-chain-runner.ts
+++ b/packages/core/src/harness-chain-runner.ts
@@ -30,6 +30,7 @@ import {
type PullRequestGateway,
} from "./gh-pr-gateway.js";
import { pullRequestGatewayFor } from "./forge.js";
+import { commitWhatIsLeft } from "./change-commit.js";
import { createGitWrapper, type GitWrapper } from "./git.js";
import {
runDeclaredChecks,
@@ -61,7 +62,7 @@ import { checkAllowlist, type AllowlistConfig, type AuditEntry, type AuditLog }
import type { AgentUsage } from "./agent-usage.js";
import { readTaskChecklist, TASK_CHECKBOX_LINE_RE, writeTaskCheckStates } from "./task-checklist.js";
import { buildUsageReport, unitKey, type UsageTotal } from "./usage-report.js";
-import { say, type SaidMessage } from "./message-register.js";
+import { say, type SaidMessage } from "./message-register.js";
/** The subsequence of `HarnessStage` a chain drives. Each entry's
* `AgentRunner` `CommandKind`, where one exists — `"archive"` and `"git"`
@@ -119,6 +120,9 @@ export interface HarnessChainDeps {
/** Override hooks for tests. Production callers use defaults. */
createGitWrapper?: (options: { cwd: string }) => GitWrapper;
createPullRequestGateway?: (options: { cwd: string }) => PullRequestGateway;
+ /** Commits what the stages left in the tree before the push
+ * (a-change-is-committed-where-it-is-made). Test seam. */
+ commitWhatIsLeft?: (directory: string, message: string) => Promise;
}
type CheckpointOutcome = "confirmed" | "cancelled" | "stopped";
@@ -2228,6 +2232,25 @@ export class HarnessChainRunner {
return "failed";
}
+ // What the stages left uncommitted goes with the push: a push of a
+ // branch that holds none of the change's work leaves the server without
+ // it (a-change-is-committed-where-it-is-made). Never on the default
+ // branch, where nothing of a change is committed (ADR 0043).
+ if (branch !== DEFAULT_PR_BASE_BRANCH && branch !== "master") {
+ const changeName = changeNameFromDir(command.context.changeDir);
+ const message = `${changeName}: commit what the stages left`;
+ const commitInvocation: AdapterInvocation = { kind: "process", executable: "git", args: ["commit", "-m", message] };
+ try {
+ const commit = await (this.deps.commitWhatIsLeft ?? commitWhatIsLeft)(command.cwd, message);
+ if (commit !== undefined) this.recordGitAction(command, commitInvocation, "completed", { summary: `committed ${commit.slice(0, 8)}` });
+ } catch (error) {
+ const reason = error instanceof Error ? error.message : String(error);
+ this.recordGitAction(command, commitInvocation, "failed", { reason });
+ yield failedEvent(command.runId, say("OSW-GIT-103", { why: reason }));
+ return "failed";
+ }
+ }
+
const pushInvocation: AdapterInvocation = {
kind: "process",
...buildGitPushInvocation(DEFAULT_GIT_REMOTE, branch),
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index f603df4a..24b05f15 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -117,6 +117,7 @@ export * from "./hints.js";
export * from "./failure-diagnosis.js";
export * from "./supervisor.js";
export * from "./own-worktree-tasks.js";
+export * from "./change-commit.js";
export * from "./change-layout.js";
export * from "./board-columns-facts.js";
export * from "./board-columns.js";
diff --git a/packages/core/src/message-register.test.ts b/packages/core/src/message-register.test.ts
index 806c2912..bdf38061 100644
--- a/packages/core/src/message-register.test.ts
+++ b/packages/core/src/message-register.test.ts
@@ -35,6 +35,7 @@ const GIVEN = [
"OSW-RUN-208", "OSW-RUN-209", "OSW-RUN-210", "OSW-RUN-211", "OSW-RUN-212",
"OSW-QST-001", "OSW-QST-002", "OSW-QST-003", "OSW-QST-101", "OSW-QST-102", "OSW-QST-201",
"OSW-PRM-101", "OSW-PRM-102",
+ "OSW-GIT-001", "OSW-GIT-002", "OSW-GIT-101", "OSW-GIT-102", "OSW-GIT-201", "OSW-GIT-202", "OSW-GIT-203", "OSW-GIT-103",
];
/** Messages said in place rather than from the register, counted by
diff --git a/packages/core/src/message-register.ts b/packages/core/src/message-register.ts
index ec2eb2f2..0cfb5bf2 100644
--- a/packages/core/src/message-register.ts
+++ b/packages/core/src/message-register.ts
@@ -68,6 +68,57 @@ export const MESSAGES = {
todo: "Do what the reason says, then choose the action again.",
},
+ // GIT: git, branches, worktrees, push, pull requests
+ // (a-change-is-committed-where-it-is-made).
+ "OSW-GIT-001": {
+ level: "warning",
+ text: "{name} has no worktree of its own: {why}.",
+ why: "Commit Change commits only in a change's own worktree, on its own branch.",
+ todo: "Make the change's worktree, or work the change there.",
+ },
+ "OSW-GIT-002": {
+ level: "warning",
+ text: "{name} is on {branch}, the default branch; nothing of a change is committed there.",
+ why: "A change reaches the default branch through its pull request, never by a commit on it (ADR 0043).",
+ todo: "Work the change on a branch of its own, named after it.",
+ },
+ "OSW-GIT-101": {
+ level: "error",
+ text: "Nothing of {name} was committed: {why}",
+ why: "git refused the commit: often no author is configured, or a hook failed.",
+ todo: "Put right what git says, then choose Commit Change again.",
+ },
+ "OSW-GIT-102": {
+ level: "error",
+ text: "The push of {branch} was refused{after}: {why}",
+ why: "The server, or the credentials git uses for it, refused the branch. A commit already made stays.",
+ todo: "Put right what the server says - access, credentials, a branch rule - then choose Commit Change again.",
+ },
+ "OSW-GIT-103": {
+ level: "error",
+ text: "git stage failed at commit: {why}",
+ why: "The git stage commits what the stages left before it pushes, and git refused that commit.",
+ todo: "Put right what git says in the change's worktree, then run the chain again from git.",
+ },
+ "OSW-GIT-201": {
+ level: "info",
+ text: "Committed {name} as {commit} and pushed it to {pushedTo}.",
+ why: "Commit Change committed what the change's worktree held, on its branch, and pushed the branch.",
+ todo: "Nothing: the change is on the server, where every directory, host and person can see it.",
+ },
+ "OSW-GIT-202": {
+ level: "info",
+ text: "{name} had nothing to commit; its branch is pushed to {pushedTo}.",
+ why: "Everything in the change's worktree was committed already, and the push brought the server up to date.",
+ todo: "Nothing.",
+ },
+ "OSW-GIT-203": {
+ level: "warning",
+ text: "{name} is made in {directory}, but not on the server: {why}",
+ why: "A new change is committed on its branch and pushed at once; that failed, so no other directory, host or person sees it yet.",
+ todo: "Put right what was refused, then choose Commit Change on the change's card.",
+ },
+
// CLI: the CLI's arguments and subcommands.
"OSW-CLI-001": {
level: "error",
diff --git a/packages/core/src/worktree-survey-facts.ts b/packages/core/src/worktree-survey-facts.ts
index 3fc3340b..0831e9ee 100644
--- a/packages/core/src/worktree-survey-facts.ts
+++ b/packages/core/src/worktree-survey-facts.ts
@@ -158,6 +158,12 @@ interface SurveyedDirectoryBase {
* work, so this is what a press is offered beside, not what a sweep
* acts on. */
finishedWith?: { reason: "merged" | "branch-gone"; branch: string };
+ /** Where this directory's own change is not on the server: its branch was
+ * never pushed, or the server's copy is at another commit - usually one it has not been pushed. Read from
+ * the refs the survey already lists, so no git runs in the directory; a
+ * file not committed is not seen here
+ * (a-change-is-committed-where-it-is-made). */
+ notOnServer?: "never-pushed" | "differs";
}
export type SurveyedDirectory =
diff --git a/packages/core/src/worktree-survey.test.ts b/packages/core/src/worktree-survey.test.ts
index 99c68d75..efca55dd 100644
--- a/packages/core/src/worktree-survey.test.ts
+++ b/packages/core/src/worktree-survey.test.ts
@@ -745,3 +745,38 @@ describe("surveyWorktrees - a directory with nothing left to do", () => {
expect(survey.directories[1]?.finishedWith).toBeUndefined();
});
});
+
+describe("surveyWorktrees - a change the server does not have", () => {
+ // a-change-is-committed-where-it-is-made: read from the refs the survey
+ // lists anyway, so no git runs in the directory.
+ function refsGit(worktrees: Array<{ path: string; branch?: string }>, refs: Record) {
+ return {
+ worktreeList: async () => worktrees,
+ configuredIdentity: async () => "someone@example.com",
+ listRefs: async () => Object.entries(refs).map(([name, commit]) => ({ name, commit })),
+ mergeBase: async () => "0000000",
+ };
+ }
+
+ it("says a change's branch was never pushed, or is at another commit than the server's, and says nothing where they agree", async () => {
+ const { main, worktreeRoot, rootSources } = await repository();
+ const never = path.join(worktreeRoot, "repo", "change-b");
+ const ahead = path.join(worktreeRoot, "repo", "change-c");
+ const level = path.join(worktreeRoot, "repo", "change-d");
+ await makeChange(main, "change-a");
+ for (const [directory, name] of [[never, "change-b"], [ahead, "change-c"], [level, "change-d"]] as const) await makeChange(directory, name);
+ const git = refsGit(
+ [{ path: main, branch: "main" }, { path: never, branch: "change-b" }, { path: ahead, branch: "change-c" }, { path: level, branch: "change-d" }],
+ {
+ "refs/heads/main": "m", "refs/remotes/origin/main": "m",
+ "refs/heads/change-b": "m",
+ "refs/heads/change-c": "c2", "refs/remotes/origin/change-c": "c1",
+ "refs/heads/change-d": "d1", "refs/remotes/origin/change-d": "d1",
+ },
+ );
+
+ const survey = await surveyWorktrees({ workspaceRoot: main, git, rootSources, isClean: async () => false });
+
+ expect(survey.directories.map((directory) => directory.notOnServer)).toEqual([undefined, "never-pushed", "differs", undefined]);
+ });
+});
diff --git a/packages/core/src/worktree-survey.ts b/packages/core/src/worktree-survey.ts
index 15dc4886..a0f48981 100644
--- a/packages/core/src/worktree-survey.ts
+++ b/packages/core/src/worktree-survey.ts
@@ -420,7 +420,9 @@ export async function surveyWorktrees(options: WorktreeSurveyOptions): Promise taskLists.get(taskListKey(directoryPath, changeName)),
);
- await markFinishedDirectories(attached.directories, git, options);
+ const refs = await readBranchRefs(attached.directories, git, options);
+ await markFinishedDirectories(attached.directories, git, options, refs);
+ markNotOnServer(attached.directories, refs, options.remote ?? "origin");
return {
directories: attached.directories,
runsElsewhere: attached.runsElsewhere,
@@ -438,23 +440,50 @@ export async function surveyWorktrees(options: WorktreeSurveyOptions): Promise>,
+ options: WorktreeSurveyOptions,
+): Promise
+ ) : null}
{counted ? : null}
{rows.length > 0 ? (
@@ -2469,7 +2517,8 @@ function DirectoryCard({ node, change, directory, details, testId, open, onToggl
}
/** A change of another working directory, drawn on this board. */
-function ElsewhereNode({ node, change, where, labels, archivedOnMain, stage, now, testId, open, onToggle, actions, onChooseAction }: {
+function ElsewhereNode({ node, change, where, labels, archivedOnMain, stage, now, testId, open, onToggle, actions, onChooseAction, onStart }: {
+ onStart?: (changeName: string, directory?: string) => void;
node: ChangeLayoutNode;
change: SurveyedChange;
where: Extract;
@@ -2484,13 +2533,16 @@ function ElsewhereNode({ node, change, where, labels, archivedOnMain, stage, now
onChooseAction?: (action: ChangeAction, changeName: string) => void;
}) {
const details = elsewhereDetails(change, where, labels, archivedOnMain, stage, now);
- return ;
+ return ;
}
/** A change of another working directory, drawn in that directory's own
* picture. */
-function ForeignNode({ node, change, directory, labels, testId, open, onToggle, archivedOnMain }: {
+function ForeignNode({ node, change, directory, labels, testId, open, onToggle, archivedOnMain, actions, onChooseAction, onStart }: {
+ onStart?: (changeName: string, directory?: string) => void;
node: ChangeLayoutNode;
+ actions?: readonly ChangeActionState[];
+ onChooseAction?: (action: ChangeAction, changeName: string) => void;
change: SurveyedChange | undefined;
directory: Extract;
labels: Map;
@@ -2499,6 +2551,6 @@ function ForeignNode({ node, change, directory, labels, testId, open, onToggle,
onToggle: () => void;
archivedOnMain: ReadonlySet;
}) {
- const details = foreignDetails(change, node.change.blockers, labels, archivedOnMain);
- return ;
+ const details = [...foreignDetails(change, node.change.blockers, labels, archivedOnMain), ...notOnServerDetails(change, directory)];
+ return ;
}
diff --git a/packages/webui/src/standalone-entry.tsx b/packages/webui/src/standalone-entry.tsx
index 54de8c17..bdc2a7f4 100644
--- a/packages/webui/src/standalone-entry.tsx
+++ b/packages/webui/src/standalone-entry.tsx
@@ -428,7 +428,11 @@ function StandaloneApp() {
// The command "Run one stage" opens the picker on; none until it is chosen.
const [runStageKind, setRunStageKind] = useState(undefined);
const [runOpenedFrom, setRunOpenedFrom] = useState<"editor" | "pipeline">("editor");
- const [pipelineChain, setPipelineChain] = useState<{ changeDir: string; budget: HarnessBudget | undefined } | null>(null);
+ const [pipelineChain, setPipelineChain] = useState<{ changeDir: string; budget: HarnessBudget | undefined; root: string } | null>(null);
+ // Where the run the dialog is for runs: this page's workspace, or the
+ // change's own worktree, for a card of one
+ // (a-change-is-committed-where-it-is-made).
+ const [runRoot, setRunRoot] = useState(null);
const pipelineRunLayer = useRef(null);
const pipelineRunShown = runOpenedFrom === "pipeline" && runDispatch !== null ? "dialog" : pipelineChain ? "chain" : "none";
// A person pressed Start on a card, and what it opened is not beside the
@@ -648,8 +652,8 @@ function StandaloneApp() {
const logsRead = useCallback((runId: string) => readChangeRunLog(apiFetch, cwd, runId), [cwd]);
// `handleRunWithHarness` is a hoisted declaration further down and reads
// `cwd` itself, as `loadChangeEditor` does for `openChangeInEditor` below.
- const pipelineStart = useCallback((changeName: string) => {
- void handleRunWithHarness(changeName, "pipeline");
+ const pipelineStart = useCallback((changeName: string, directory?: string) => {
+ void handleRunWithHarness(changeName, "pipeline", directory);
}, [cwd]);
// A card whose review asked for changes opens the run panel on `update`
// for that change (ADR 0041).
@@ -977,8 +981,9 @@ function StandaloneApp() {
* Command" tab, anything else revealed the chain panel. Both were
* correct and neither said so, which is why the button looked like it
* only changed tabs. */
- async function handleRunWithHarness(changeName: string = editorChangeName, from: "editor" | "pipeline" = "editor") {
+ async function handleRunWithHarness(changeName: string = editorChangeName, from: "editor" | "pipeline" = "editor", root: string = cwd) {
if (cwd.trim().length === 0 || changeName.trim().length === 0) return;
+ setRunRoot(root);
// Set first, so a failure is said where the person pressed the button.
setRunOpenedFrom(from);
setRunHarnessLoading(true);
@@ -990,7 +995,7 @@ function StandaloneApp() {
setRunAppliedNote(null);
setRunUseAgentNote(null);
try {
- const dispatch = await resolveRunWithHarnessDispatch(apiFetch, cwd, changeName);
+ const dispatch = await resolveRunWithHarnessDispatch(apiFetch, root, changeName);
// Where the change stands, with refs fetched now, so the dialog asks
// about fresh refs. A reading that fails leaves the dialog as it was.
const reading = await loadChangeStandings(apiFetch, cwd, "now").catch(() => undefined);
@@ -1000,7 +1005,7 @@ function StandaloneApp() {
setRunDispatch(dispatch);
// Absent rather than zeroed if it cannot be read. Zeroes would be a
// claim about this workspace; absence is the truth about the read.
- setRunStats(await loadWorkspaceRunStats(apiFetch, cwd).catch(() => undefined));
+ setRunStats(await loadWorkspaceRunStats(apiFetch, root).catch(() => undefined));
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
setRunHarnessMessage(`Run with Agentic Harness failed: ${message}`);
@@ -1020,8 +1025,8 @@ function StandaloneApp() {
if (!runDispatch) return;
setRunHarnessLoading(true);
try {
- const applied = await applyTemplateToChangeApi(apiFetch, cwd, runChangeName, template);
- setRunDispatch(await resolveRunWithHarnessDispatch(apiFetch, cwd, runChangeName));
+ const applied = await applyTemplateToChangeApi(apiFetch, runRoot ?? cwd, runChangeName, template);
+ setRunDispatch(await resolveRunWithHarnessDispatch(apiFetch, runRoot ?? cwd, runChangeName));
// Beside the Apply button, in the dialog. It used to be set above
// the dialog, where the person who pressed Apply could not see it
// (a-change-is-configured-from-the-change).
@@ -1045,8 +1050,8 @@ function StandaloneApp() {
if (!runDispatch) return;
setRunHarnessLoading(true);
try {
- await putAgentOnEveryStageApi(apiFetch, cwd, runChangeName, agentId);
- setRunDispatch(await resolveRunWithHarnessDispatch(apiFetch, cwd, runChangeName));
+ await putAgentOnEveryStageApi(apiFetch, runRoot ?? cwd, runChangeName, agentId);
+ setRunDispatch(await resolveRunWithHarnessDispatch(apiFetch, runRoot ?? cwd, runChangeName));
setRunUseAgentNote(
`Put ${agentId} on every stage in openspec/changes/${runChangeName}/harness.json. `
+ "The dialog now shows what the change resolves to.",
@@ -1215,7 +1220,7 @@ function StandaloneApp() {
setRunDispatch(null);
setRunNote(null);
try {
- await addScheduledRunApi(apiFetch, cwd, {
+ await addScheduledRunApi(apiFetch, runRoot ?? cwd, {
changeName: name,
path,
startAt,
@@ -1243,11 +1248,17 @@ function StandaloneApp() {
// reappeared over the next dialog a person opened themselves, which
// said a schedule had started something that nobody scheduled.
setRunNote(null);
+ const root = runRoot ?? cwd;
if (path === "chain" && fromPipeline) {
- // The chain runs in the Pipeline's layer, where Start was pressed.
- setPipelineChain({ changeDir: targetChangeDir, budget });
+ // The chain runs in the Pipeline's layer, where Start was pressed, and
+ // in the directory the change is worked in.
+ setPipelineChain({ changeDir: targetChangeDir, budget, root });
return;
}
+ // Run anywhere else, a change of its own worktree is run with this page
+ // working there, as a change it makes is
+ // (a-change-is-committed-where-it-is-made).
+ if (root !== cwd) handleCwdChange(root);
setRunOpenedFrom("editor");
if (path === "chain") {
setChainChangeDir(targetChangeDir);
@@ -1714,7 +1725,7 @@ function StandaloneApp() {
throw new Error(payload.error ?? `${response.status} ${response.statusText}`);
}
- const made = (await response.json().catch(() => ({}))) as { directory?: string; branch?: string };
+ const made = (await response.json().catch(() => ({}))) as { directory?: string; branch?: string; pushedTo?: string; notShared?: string };
setNewChangeName("");
setNewChangeDescription("");
// Made in a working directory of its own, where the repository has a
@@ -1724,9 +1735,12 @@ function StandaloneApp() {
if (root !== cwd) handleCwdChange(root);
await loadOverviewFor(root);
await loadChangeEditor(changeName, root);
+ // Committed on its branch and pushed as it was made, or why the server
+ // does not have it (a-change-is-committed-where-it-is-made).
+ const pushed = made.pushedTo !== undefined ? `, pushed to ${made.pushedTo}` : "";
setEditorMessage(root === cwd
? `Created ${changeName}.`
- : `Created ${changeName} in its own working directory, ${root}, on branch ${made.branch ?? changeName}; this page now works there.`);
+ : made.notShared ?? `Created ${changeName} in its own working directory, ${root}, on branch ${made.branch ?? changeName}${pushed}; this page now works there.`);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
setEditorMessage(`Create failed: ${message}`);
@@ -2916,7 +2930,7 @@ function StandaloneApp() {
tabIndex={-1}
>