From 1978ee943469a042f498917b0ca4b82c81f29633 Mon Sep 17 00:00:00 2001 From: Alexander Ivanov Date: Sat, 10 Oct 2026 14:37:02 +0300 Subject: [PATCH 1/4] a-change-is-committed-where-it-is-made: a change is on the server from the moment it is made The rules agents are given said where a change's commits go and never when; four changes of one repository sat untracked in their worktrees, their branches where main was, and their cards offered nothing arranged by step. - The rules say when: the planning artifacts committed on the change's branch and pushed with an upstream as soon as they are written, the work of every task, nothing left unpushed, nothing on main. - Create Change commits the new change on its branch and pushes it; a refused push leaves it made and says why. - The git stage commits what the stages left before it pushes. - The survey says where a change's own branch is not on the server, from the refs it lists anyway; the card says so. - Commit Change, in core's list of actions and both hosts, commits what a change's worktree holds and pushes it. - A card of a change's own worktree offers its actions arranged by step. - Messages OSW-GIT-001..203; ADR 0043 amended. Co-Authored-By: Claude Opus 5.5 --- .../a-change-is-committed-where-it-is-made.md | 8 ++ README.md | 7 + ...-made-in-a-working-directory-of-its-own.md | 27 ++++ docs/messages.md | 66 +++++++++ .../.openspec.yaml | 2 + .../design.md | 62 +++++++++ .../proposal.md | 73 ++++++++++ .../specs/execution-core/spec.md | 104 ++++++++++++++ .../specs/shared-ui/spec.md | 35 +++++ .../tasks.md | 68 ++++++++++ .../core/src/agent-workflow-rules.test.ts | 5 + packages/core/src/agent-workflow-rules.ts | 20 +++ packages/core/src/change-actions.test.ts | 7 + packages/core/src/change-actions.ts | 13 ++ packages/core/src/change-commit.test.ts | 88 ++++++++++++ packages/core/src/change-commit.ts | 127 ++++++++++++++++++ .../core/src/change-in-its-worktree.test.ts | 24 +++- packages/core/src/change-in-its-worktree.ts | 19 ++- .../core/src/harness-chain-runner.test.ts | 45 +++++++ packages/core/src/harness-chain-runner.ts | 25 +++- packages/core/src/index.ts | 1 + packages/core/src/message-register.test.ts | 1 + packages/core/src/message-register.ts | 51 +++++++ packages/core/src/worktree-survey-facts.ts | 6 + packages/core/src/worktree-survey.test.ts | 35 +++++ packages/core/src/worktree-survey.ts | 45 +++++-- packages/extension/README.md | 4 + packages/extension/package.json | 6 + packages/extension/src/commands.test.ts | 24 ++++ packages/extension/src/commands.ts | 34 ++++- .../server/src/change-actions-rest.test.ts | 5 + packages/server/src/change-actions-rest.ts | 11 ++ packages/server/src/rest.ts | 7 + .../src/components/PipelineView.test.tsx | 20 +++ .../webui/src/components/PipelineView.tsx | 53 ++++++-- packages/webui/src/standalone-entry.tsx | 7 +- 36 files changed, 1109 insertions(+), 26 deletions(-) create mode 100644 .changeset/a-change-is-committed-where-it-is-made.md create mode 100644 openspec/changes/a-change-is-committed-where-it-is-made/.openspec.yaml create mode 100644 openspec/changes/a-change-is-committed-where-it-is-made/design.md create mode 100644 openspec/changes/a-change-is-committed-where-it-is-made/proposal.md create mode 100644 openspec/changes/a-change-is-committed-where-it-is-made/specs/execution-core/spec.md create mode 100644 openspec/changes/a-change-is-committed-where-it-is-made/specs/shared-ui/spec.md create mode 100644 openspec/changes/a-change-is-committed-where-it-is-made/tasks.md create mode 100644 packages/core/src/change-commit.test.ts create mode 100644 packages/core/src/change-commit.ts 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..de44ca22 --- /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. 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..1ef45753 --- /dev/null +++ b/openspec/changes/a-change-is-committed-where-it-is-made/proposal.md @@ -0,0 +1,73 @@ +## 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. +- 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..2c1dbdb0 --- /dev/null +++ b/openspec/changes/a-change-is-committed-where-it-is-made/specs/shared-ui/spec.md @@ -0,0 +1,35 @@ +## 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. + +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: 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..03fc5c39 --- /dev/null +++ b/openspec/changes/a-change-is-committed-where-it-is-made/tasks.md @@ -0,0 +1,68 @@ +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). + +## 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. +- [ ] 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. 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 | undefined> { + if (git.listRefs === undefined || !directories.some((directory) => !directory.isMain && directory.branch !== undefined)) return undefined; + const remote = options.remote ?? "origin"; + try { + return new Map((await git.listRefs(["refs/heads", `refs/remotes/${remote}`])).map((ref) => [ref.name, ref.commit])); + } catch { + return undefined; + } +} + +/** Says of each directory with a change of its own whether the server has + * that change's branch as it is here (a-change-is-committed-where-it-is-made). */ +function markNotOnServer(directories: SurveyedDirectory[], refs: Map | undefined, remote: string): void { + if (refs === undefined) return; + for (const directory of directories) { + if (directory.isMain || directory.ownChange === undefined || directory.branch === undefined) continue; + const local = refs.get(`refs/heads/${directory.branch}`); + const onServer = refs.get(`refs/remotes/${remote}/${directory.branch}`); + if (local === undefined) continue; + if (onServer === undefined) directory.notOnServer = "never-pushed"; + else if (onServer !== local) directory.notOnServer = "differs"; + } +} + async function markFinishedDirectories( directories: SurveyedDirectory[], git: Pick & Partial>, options: WorktreeSurveyOptions, + branchRefs: Map | undefined, ): Promise { const candidates = directories.filter( (directory) => !directory.isMain && directory.branch !== undefined && directory.runs.length === 0, ); - if (candidates.length === 0 || git.listRefs === undefined || git.mergeBase === undefined) return; + if (candidates.length === 0 || branchRefs === undefined || git.mergeBase === undefined) return; const remote = options.remote ?? "origin"; - let refs: Map; - try { - refs = new Map((await git.listRefs(["refs/heads", `refs/remotes/${remote}`])).map((ref) => [ref.name, ref.commit])); - } catch { - return; - } + const refs = branchRefs; const mainCommit = refs.get(`refs/remotes/${remote}/main`) ?? refs.get("refs/heads/main"); const isClean = options.isClean ?? (async (directoryPath: string) => { diff --git a/packages/extension/README.md b/packages/extension/README.md index 5d5ed0b3..5e2a6050 100644 --- a/packages/extension/README.md +++ b/packages/extension/README.md @@ -150,6 +150,10 @@ it may be a change you have not written yet. their own worktrees - archived changes, canonical specs, and every proposal, design, task list, and delta spec. Choosing a change shows its card in the Pipeline. +- Have a new change on the server at once: Create Change commits it on its + own branch and pushes it. A card whose change's branch is not on the + server says so, and **Commit Change** commits everything the change's + worktree holds and pushes it - never on `main`, never in this checkout. - Create, validate, archive, restore, and delete changes - from a change's card or its **Show Actions...**, and from an archived change's menu - with confirmation for destructive operations. Expand a change, diff --git a/packages/extension/package.json b/packages/extension/package.json index 895d138e..cf4ebff4 100644 --- a/packages/extension/package.json +++ b/packages/extension/package.json @@ -90,6 +90,12 @@ "category": "OpenSpec Workbench", "icon": "$(key)" }, + { + "command": "openspec-ui.commitChange", + "title": "Commit Change", + "category": "OpenSpec Workbench", + "icon": "$(git-commit)" + }, { "command": "openspec-ui.configureChangeHarness", "title": "Configure Change Harness", diff --git a/packages/extension/src/commands.test.ts b/packages/extension/src/commands.test.ts index d6a9a43e..d9049350 100644 --- a/packages/extension/src/commands.test.ts +++ b/packages/extension/src/commands.test.ts @@ -28,6 +28,7 @@ const buildSprintReportMock = vi.fn(); const renderSprintReportPageMock = vi.fn(); const discoverOpenSpecWorkspaceMock = vi.fn(); const createChangeMock = vi.fn(); +const commitChangeMock = vi.fn(); const workflowRulesNeedConsentMock = vi.fn(async (..._args: unknown[]) => [] as string[]); const commitOpenSpecSetupMock = vi.fn(async (..._args: unknown[]) => ({ state: "pushed", paths: ["openspec/"], commit: "abc" })); const writeWorkflowRulesMock = vi.fn(async (..._args: unknown[]): Promise> => ({ "CLAUDE.md": "created", "AGENTS.md": "created" })); @@ -127,6 +128,11 @@ vi.mock("@openspec-ui/core", async () => ({ // A repository with no remote, as these tests' workspace is: the change // is made where it always was, through `openspec new change` // (agents-are-told-how-work-is-done-here). + // Committing is git in a worktree; what is said of it is core's own + // (a-change-is-committed-where-it-is-made). + commitChange: (...args: unknown[]) => commitChangeMock(...args), + sayCommitChange: (await vi.importActual("@openspec-ui/core")).sayCommitChange, + sayChangeNotShared: (await vi.importActual("@openspec-ui/core")).sayChangeNotShared, createChangeInItsWorktree: async (options: { repositoryRoot: string; changeName: string }) => { await createChangeMock(options.changeName, { cwd: options.repositoryRoot }); return { directory: options.repositoryRoot }; @@ -309,6 +315,24 @@ describe("registerCommands", () => { ); }); + it("commits a change's worktree from its row, and says what it did, or why not", async () => { + // a-change-is-committed-where-it-is-made. + const deps = makeDeps(); + registerCommands(makeContext() as unknown as import("vscode").ExtensionContext, deps); + const row = { changeName: "demo", archived: false, contextValue: "openspec-ui.activeChange.elsewhere" }; + vscodeMock.window.withProgress = vi.fn(async (_options: unknown, task: () => Promise) => task()); + + commitChangeMock.mockResolvedValueOnce({ ok: true, pushedTo: "origin/demo", commit: "abcdef1234567" }); + await vscodeMock._registeredCommands.get("openspec-ui.commitChange")?.(row); + expect(commitChangeMock).toHaveBeenCalledWith({ repositoryRoot: "/workspace/repo", changeName: "demo" }); + expect(vscodeMock.window.showInformationMessage).toHaveBeenCalledWith("OSW-GIT-201: Committed demo as abcdef12 and pushed it to origin/demo."); + expect(deps.refreshTrees).toHaveBeenCalled(); + + commitChangeMock.mockResolvedValueOnce({ ok: false, kind: "push-rejected", said: { level: "error", code: "OSW-GIT-102", text: "The push of demo was refused: denied" } }); + await vscodeMock._registeredCommands.get("openspec-ui.commitChange")?.(row); + expect(vscodeMock.window.showErrorMessage).toHaveBeenCalledWith("OSW-GIT-102: The push of demo was refused: denied"); + }); + it("creates a change and refreshes all trees", async () => { vscodeMock.window.showInputBox.mockResolvedValue("new-workbench-change"); createChangeMock.mockResolvedValue({ ok: true }); diff --git a/packages/extension/src/commands.ts b/packages/extension/src/commands.ts index a8cf51e3..4f20bf96 100644 --- a/packages/extension/src/commands.ts +++ b/packages/extension/src/commands.ts @@ -79,6 +79,9 @@ import { validateChange, writeAgentInstructions, createChangeInItsWorktree, + commitChange, + sayChangeNotShared, + sayCommitChange, workflowRulesNeedConsent, writeWorkflowRules, commitOpenSpecSetup, @@ -854,8 +857,18 @@ function announceChangeMade(changeName: string, made: ChangeMade | undefined, wo return; } const open = "Open its working directory"; + // Committed on its branch and pushed as it was made, or saying why the + // server does not have it (a-change-is-committed-where-it-is-made). + const notShared = sayChangeNotShared(changeName, made.directory, made.shared); + if (notShared !== undefined) { + void vscode.window.showWarningMessage(withMessageCode(notShared.text, notShared.code), open).then((choice) => { + if (choice === open) void vscode.commands.executeCommand("vscode.openFolder", vscode.Uri.file(made.directory), { forceNewWindow: true }); + }); + return; + } + const pushed = made.shared?.ok === true ? `, and pushed it to ${made.shared.pushedTo}` : ""; void vscode.window.showInformationMessage( - `OpenSpec Workbench: created ${changeName} in its own working directory, ${made.directory}, on branch ${made.branch ?? changeName}.`, + `OpenSpec Workbench: created ${changeName} in its own working directory, ${made.directory}, on branch ${made.branch ?? changeName}${pushed}.`, open, ).then((choice) => { if (choice === open) void vscode.commands.executeCommand("vscode.openFolder", vscode.Uri.file(made.directory), { forceNewWindow: true }); @@ -1703,6 +1716,25 @@ export function registerCommands(context: vscode.ExtensionContext, deps: Command } await vscode.commands.executeCommand(chosen.action.command, item); }), + // Everything the change's own worktree holds, committed on its branch + // and pushed, from its card or its Show Actions... + // (a-change-is-committed-where-it-is-made). + vscode.commands.registerCommand("openspec-ui.commitChange", async (invokedItem?: ChangeTreeItem) => { + const workspaceRoot = deps.getWorkspaceRoot(); + if (!workspaceRoot) { warnNoWorkspace(); return; } + const item = resolveTreeItem(invokedItem, deps.changesView, isChangeTreeItem); + if (!item || item.archived) { warnNoTreeSelection("change"); return; } + const result = await vscode.window.withProgress( + { location: vscode.ProgressLocation.Notification, title: `OpenSpec Workbench: committing and pushing ${item.changeName}` }, + () => commitChange({ repositoryRoot: workspaceRoot, changeName: item.changeName }), + ); + const said = sayCommitChange(item.changeName, result); + const show = said.level === "info" + ? vscode.window.showInformationMessage + : said.level === "warning" ? vscode.window.showWarningMessage : vscode.window.showErrorMessage; + void show(withMessageCode(said.text, said.code)); + deps.refreshTrees(); + }), vscode.commands.registerCommand("openspec-ui.configureChangeHarness", async (invokedItem?: ChangeTreeItem) => { const workspaceRoot = deps.getWorkspaceRoot(); if (!workspaceRoot) { warnNoWorkspace(); return; } diff --git a/packages/server/src/change-actions-rest.test.ts b/packages/server/src/change-actions-rest.test.ts index 5a88071e..78662c22 100644 --- a/packages/server/src/change-actions-rest.test.ts +++ b/packages/server/src/change-actions-rest.test.ts @@ -76,6 +76,11 @@ describe("a change's actions, for the standalone app's cards", () => { expect((await stop.json() as { error: string }).error).toContain("nothing is running on demo"); const open = await post({ cwd: workspace, changeName: "demo", action: "openWorktree" }); expect(open.status).toBe(409); + // Never committed in the checkout: only in a change's own worktree + // (a-change-is-committed-where-it-is-made). + const commit = await post({ cwd: workspace, changeName: "demo", action: "commitChange" }); + expect(commit.status).toBe(409); + expect((await commit.json() as { error: string }).error).toMatch(/^OSW-GIT-001: demo has no worktree of its own/u); }); }); diff --git a/packages/server/src/change-actions-rest.ts b/packages/server/src/change-actions-rest.ts index 6b659529..a9201c59 100644 --- a/packages/server/src/change-actions-rest.ts +++ b/packages/server/src/change-actions-rest.ts @@ -19,6 +19,7 @@ import { changeAction, changeActionRoot, changeAncestry, + commitChange, createGitWrapper, deleteChange, editChangeRelation, @@ -37,8 +38,10 @@ import { renderChangeCostReport, resolveAgentStatusDirectory, resolveHarnessConfig, + sayCommitChange, sayToLiveRun, validateChange, + withMessageCode, type ChangeActionAnswer, type ChangeActionId, type ChangeActionInput, @@ -186,6 +189,14 @@ async function perform(request: ChangeActionRequest, at: ChangeActionRoot, seams ...recommendation.grounds.map((line) => `- ${line}`), ].join("\n")); } + case "commitChange": { + // Everything its own worktree holds, committed on its branch and + // pushed (a-change-is-committed-where-it-is-made). + const result = await commitChange({ repositoryRoot: cwd, changeName: name }); + const said = sayCommitChange(name, result); + if (!result.ok) throw new Refused(withMessageCode(said.text, said.code)); + return done(withMessageCode(said.text, said.code)); + } case "openWorktree": { if (at.root === cwd) throw new Refused(`${name} is worked in this checkout.`); (seams.openFolder ?? openFolderInSystem)(at.root); diff --git a/packages/server/src/rest.ts b/packages/server/src/rest.ts index 6ecbe557..13dd4f82 100644 --- a/packages/server/src/rest.ts +++ b/packages/server/src/rest.ts @@ -16,6 +16,8 @@ import { UnknownProjectTemplateError, buildSprintReport, createChangeInItsWorktree, + sayChangeNotShared, + withMessageCode, customizeTemplate, deleteProjectTemplate, detectAvailableAgents, @@ -457,11 +459,16 @@ export async function handleChangeEditorCreateRequest(req: IncomingMessage, res: changeName: parsed.changeName, createOptions: { description: parsed.description }, }); + // 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 notShared = sayChangeNotShared(parsed.changeName, made.directory, made.shared); sendJson(res, 200, { ok: true, changeName: parsed.changeName, directory: made.directory, ...(made.branch !== undefined ? { branch: made.branch } : {}), + ...(made.shared?.ok === true ? { pushedTo: made.shared.pushedTo } : {}), + ...(notShared !== undefined ? { notShared: withMessageCode(notShared.text, notShared.code) } : {}), }); } catch (error) { const message = error instanceof Error ? error.message : String(error); diff --git a/packages/webui/src/components/PipelineView.test.tsx b/packages/webui/src/components/PipelineView.test.tsx index bc81cffb..bb684b57 100644 --- a/packages/webui/src/components/PipelineView.test.tsx +++ b/packages/webui/src/components/PipelineView.test.tsx @@ -1934,6 +1934,26 @@ describe("PipelineView — a card works its own tasks", () => { fireEvent.click(await screen.findByTestId("pipeline-directory-0-node-fresh-tasks-toggle")); } + it("offers the actions of a change in its own worktree arranged by step too, and says its branch is not on the server", async () => { + // a-change-is-committed-where-it-is-made: arranged by step, such a card + // had no buttons at all, and read as somebody else's. + const perform = vi.fn(); + render( + report(change("alpha"))} + survey={async () => survey(directory(), own({ notOnServer: "never-pushed" }))} + changeActions={{ offered: new Set(["commitChange", "validateChange"]), perform }} + />, + ); + + const commit = await screen.findByTestId("pipeline-action-commitChange-fresh"); + expect(commit).not.toHaveAttribute("aria-disabled", "true"); + expect(screen.getByTestId("pipeline-directory-0-node-fresh")).toHaveTextContent("not on the server: its branch was never pushed"); + fireEvent.click(commit); + expect(perform).toHaveBeenCalledWith("commitChange", "fresh", { confirmed: false }); + }); + it("holds a task's whole text in the hint of its row", async () => { await openOwnCard(); const row = screen.getByTestId("pipeline-directory-0-node-fresh-tasks").querySelector("[data-word='only a person can close it']"); diff --git a/packages/webui/src/components/PipelineView.tsx b/packages/webui/src/components/PipelineView.tsx index 66d02718..8ba7f55b 100644 --- a/packages/webui/src/components/PipelineView.tsx +++ b/packages/webui/src/components/PipelineView.tsx @@ -1047,7 +1047,7 @@ export function PipelineView({ suggestion computed here — `buildHints` derived them in core before the payload was sent. */} {report !== undefined ? : null} - {others.value ? : null} + {others.value ? : null} {others.error !== undefined ?

The other working directories could not be read: {others.error}

: null} @@ -1234,9 +1234,20 @@ function elsewhereActionFacts(change: SurveyedChange, directory: Extract run.signature === "does-not-check-out") ? "unverified" : "worktree", running: runs.length > 0, ...(!change.tasksUnreadable ? { openTasks: change.tasksTotal - change.tasksDone } : {}), + ...(directory.notOnServer !== undefined ? { notOnServer: true } : {}), }; } +/** What a card of a change's own worktree says where the server does not + * have the change's branch as it is there (a-change-is-committed-where-it-is-made). */ +function notOnServerDetails(change: SurveyedChange | undefined, directory: Extract): CardDetail[] { + if (change === undefined || directory.ownChange !== change.changeName || directory.notOnServer === undefined) return []; + return [{ + kind: "where", + text: directory.notOnServer === "never-pushed" ? "not on the server: its branch was never pushed" : "not on the server as it is here", + }]; +} + function hasProgress(card: ChangeCard): boolean { return card.progress !== undefined && card.progress.total > 0; } @@ -2193,8 +2204,11 @@ function describeChange(change: ChangeReadiness): CardDetail[] { /** Every working directory other than this one, each in its own * recessed section with its own picture. */ -function OtherDirectories({ survey, labels, now, onCards, openCards, archivedOnMain, drawsChanges, order }: { +function OtherDirectories({ survey, labels, now, onCards, openCards, archivedOnMain, drawsChanges, order, controls }: { survey: WorktreeSurvey; + /** A change's actions, for the card of its own worktree drawn here, as + * on the board (a-change-is-committed-where-it-is-made). */ + controls?: Pick; order: ChangeOrder; labels: Map; now: Date; @@ -2220,7 +2234,7 @@ function OtherDirectories({ survey, labels, now, onCards, openCards, archivedOnM whose tasks its card works.

{others.map((directory, index) => ( - + ))} {survey.runsElsewhere.length > 0 ? (
@@ -2237,8 +2251,9 @@ function OtherDirectories({ survey, labels, now, onCards, openCards, archivedOnM ); } -function OtherDirectory({ directory, index, labels, now, onCards, openCards, archivedOnMain, drawsChanges, order }: { +function OtherDirectory({ directory, index, labels, now, onCards, openCards, archivedOnMain, drawsChanges, order, controls }: { directory: SurveyedDirectory; + controls?: Pick; order: ChangeOrder; index: number; labels: Map; @@ -2279,7 +2294,7 @@ function OtherDirectory({ directory, index, labels, now, onCards, openCards, arc
    {describeDirectoryRuns(directory, now, onCards).map((line, lineIndex) =>
  • {line}
  • )}
- {directory.readable && drawsChanges ? : null} + {directory.readable && drawsChanges ? : null} ); } @@ -2322,8 +2337,9 @@ function foreignDetails( ]; } -function ForeignChanges({ directory, testId, labels, openCards, archivedOnMain, order }: { +function ForeignChanges({ directory, testId, labels, openCards, archivedOnMain, order, controls }: { directory: Extract; + controls?: Pick; order: ChangeOrder; testId: string; labels: Map; @@ -2342,14 +2358,22 @@ function ForeignChanges({ directory, testId, labels, openCards, archivedOnMain, } const byName = new Map(changes.map((change) => [change.changeName, change])); const heights = new Map(); + // The change this directory is the worktree of offers its actions here as + // on the board: arranged by step, it was drawn with none, and every + // change made in its own worktree read as somebody else's + // (a-change-is-committed-where-it-is-made). + const actionsOf = new Map(); for (const change of changes) { + const facts = elsewhereActionFacts(change, directory); + if (facts !== undefined) actionsOf.set(change.changeName, cardActionStates(facts, controls?.actions)); const openRows = openParts(change.tasks ?? [], openCards.isOpen(directory.path, change.changeName), taskCards.hidesDone(directory.path, change.changeName)); heights.set(change.changeName, pipelineCardHeight({ hasState: false, hasProgress: !change.tasksUnreadable && change.tasksTotal > 0, hasCallout: false, - detailLines: foreignDetails(change, change.blockers, labels, archivedOnMain).length, + detailLines: foreignDetails(change, change.blockers, labels, archivedOnMain).length + notOnServerDetails(change, directory).length, hasControls: false, + actionRows: cardActionRows(actionsOf.get(change.changeName) ?? []).length, ...(openRows !== undefined ? { open: openRows } : {}), })); } @@ -2373,6 +2397,8 @@ function ForeignChanges({ directory, testId, labels, openCards, archivedOnMain, testId={`${testId}-node-${node.change.changeName}`} open={openCards.isOpen(directory.path, node.change.changeName)} onToggle={() => openCards.toggle(directory.path, node.change.changeName)} + actions={actionsOf.get(node.change.changeName) ?? []} + {...(controls?.chooseAction !== undefined ? { onChooseAction: controls.chooseAction } : {})} /> )} /> @@ -2395,6 +2421,7 @@ function elsewhereDetails( { kind: "where", text: `worked in ${where.label}` }, ...(stage !== undefined ? [{ kind: "where" as const, text: describeStageLine(stage, now) }] : []), ...foreignDetails(change, change.blockers, labels, archivedOnMain), + ...notOnServerDetails(change, where), ]; } @@ -2412,8 +2439,8 @@ function DirectoryCard({ node, change, directory, details, testId, open, onToggl testId: string; open: boolean; onToggle: () => void; - /** Its change's actions, on a card of the change's own worktree drawn on - * this board; none elsewhere. */ + /** Its change's actions, on a card of the change's own worktree, on the + * board and arranged by step alike; none elsewhere. */ actions?: readonly ChangeActionState[]; onChooseAction?: (action: ChangeAction, changeName: string) => void; }) { @@ -2489,8 +2516,10 @@ function ElsewhereNode({ node, change, where, labels, archivedOnMain, stage, now /** 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 }: { node: ChangeLayoutNode; + actions?: readonly ChangeActionState[]; + onChooseAction?: (action: ChangeAction, changeName: string) => void; change: SurveyedChange | undefined; directory: Extract; labels: Map; @@ -2499,6 +2528,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..d93fefee 100644 --- a/packages/webui/src/standalone-entry.tsx +++ b/packages/webui/src/standalone-entry.tsx @@ -1714,7 +1714,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 +1724,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}`); From 01da9ba6e731135bc69ad9bda83f058fb5032f3c Mon Sep 17 00:00:00 2001 From: Alexander Ivanov Date: Sat, 10 Oct 2026 14:44:11 +0300 Subject: [PATCH 2/4] a-change-is-committed-where-it-is-made: the card draws Commit Change's icon The web UI draws a change action's codicon from its own outline table, which had no git-commit. Taken from the codicon font VS Code ships, as the others were; the existing outlines are unchanged. Co-Authored-By: Claude Opus 5.5 --- packages/webui/scripts/extract-codicons.py | 2 +- packages/webui/src/codicons.generated.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/webui/scripts/extract-codicons.py b/packages/webui/scripts/extract-codicons.py index 7fee714e..7c8869a4 100644 --- a/packages/webui/scripts/extract-codicons.py +++ b/packages/webui/scripts/extract-codicons.py @@ -22,7 +22,7 @@ NAMES = [ "play", "comment", "debug-stop", "checklist", "diff", "history", "type-hierarchy-super", - "credit-card", "graph", "info", "lightbulb", "folder-opened", "files", "gear", "link", + "credit-card", "graph", "info", "lightbulb", "folder-opened", "files", "git-commit", "gear", "link", "debug-disconnect", "archive", "debug-reverse-continue", "trash", ] diff --git a/packages/webui/src/codicons.generated.ts b/packages/webui/src/codicons.generated.ts index 4ba1b503..80c0036c 100644 --- a/packages/webui/src/codicons.generated.ts +++ b/packages/webui/src/codicons.generated.ts @@ -20,6 +20,7 @@ export const CODICON_PATHS: Readonly> = { "lightbulb": "M150 19Q125 19 102.5 31Q80 43 66 64Q52 88 52 117Q52 141 61 160Q67 171 79 185Q86 193 87 195L104 260Q107 270 114.5 275.5Q122 281 132 281H168Q178 281 185.5 275.5Q193 270 196 260L213 195Q214 193 221 185Q233 171 239 160Q248 141 248 117Q248 90 235 67.5Q222 45 199.5 32Q177 19 150 19ZM178 256Q177 259 174.5 261Q172 263 168 263H132Q128 262 125.5 260.5Q123 259 123 256L114 225H186ZM207 172Q207 172 207 173L200 181Q196 186 195 190L191 206H109L105 190Q104 186 100 181L93 172Q82 160 78 151Q70 136 70 117Q70 93 81.5 75Q93 57 111.5 47Q130 37 151 37Q172 37 190 48Q208 59 219 77.5Q230 96 230 117Q230 136 222 151Q218 160 207 172Z", "folder-opened": "M38 84V171L55 141Q62 128 75 120.5Q88 113 104 113L223 112Q220 104 213 99Q206 94 197 94H131Q127 94 125 91L93 59Q90 56 86 56H66Q54 56 46 64.5Q38 73 38 84ZM133 262Q132 262 131 263H66Q46 262 32.5 248.5Q19 235 19 216V84Q19 65 32.5 51.5Q46 38 66 38H86Q98 37 106 46L135 75H197Q214 75 227 86Q240 97 243 113Q258 116 268.5 127Q279 138 281 153.5Q283 169 275 183L245 234Q238 247 225 254.5Q212 262 196 262ZM104 131Q93 131 84.5 136Q76 141 71 150L41 201Q36 211 38 220.5Q40 230 47.5 237Q55 244 66 244H196Q207 244 215.5 239Q224 234 229 225L259 173Q264 164 262 154Q260 144 252.5 137.5Q245 131 234 131Z", "files": "M94 281H220Q215 290 206.5 295Q198 300 188 300H94Q73 300 56 290Q39 280 29 262.5Q19 245 19 225V75Q19 65 24 56.5Q29 48 38 43V225Q38 248 54.5 264.5Q71 281 94 281ZM263 102V225Q263 241 252 252Q241 263 225 263H94Q78 262 67 251.5Q56 241 56 225V37Q56 22 67 11Q78 0 94 0H161Q173 0 181 8L254 82Q263 90 262 102ZM169 84Q169 88 171.5 91Q174 94 178 94H240L169 23ZM244 225V112H178Q166 112 158 104Q150 96 150 84V19H94Q86 19 80.5 24.5Q75 30 75 38V225Q75 233 80.5 238.5Q86 244 94 244H225Q233 244 238.5 238.5Q244 233 244 225Z", + "git-commit": "M216 150Q216 125 199.5 106.5Q183 88 159 85V28Q159 24 156.5 21.5Q154 19 150 19Q146 19 143.5 21.5Q141 24 141 28V85Q117 88 100.5 106.5Q84 125 84 150Q84 175 100.5 193.5Q117 212 141 215V272Q141 276 143.5 278.5Q146 281 150 281Q154 281 156.5 278.5Q159 276 159 272V215Q183 212 199.5 193.5Q216 175 216 150ZM150 197Q131 197 117 183Q103 169 103 150Q103 131 117 117Q131 103 150 103Q169 103 183 117Q197 131 197 150Q197 169 183 183Q169 197 150 197Z", "gear": "M150 112Q134 112 123 123Q112 134 112 150Q112 166 123 176.5Q134 187 150 187Q166 187 176.5 176.5Q187 166 187 150Q187 134 176.5 123Q166 112 150 112ZM150 169Q142 169 136.5 163.5Q131 158 131 150Q131 142 136.5 136.5Q142 131 150 131Q158 131 163.5 136.5Q169 142 169 150Q169 158 163.5 163.5Q158 169 150 169ZM273 182 249 162 247 160Q243 155 243.5 148.5Q244 142 249 138L273 118Q277 115 275 110Q267 83 248 62Q246 59 242 59L239 60L207 71H205Q199 71 194.5 67.5Q190 64 189 59L184 28Q183 23 178 22Q164 19 150 19Q136 19 122 22Q117 23 116 28L110 61Q108 66 104 68.5Q100 71 96 71Q93 71 90 71L61 60L58 59Q54 59 52 62Q33 83 25 110Q23 115 27 118L53 140Q57 145 56.5 151.5Q56 158 51 162L27 182Q23 186 25 190Q33 217 52 238Q54 241 58 241L61 240L93 229H96Q101 229 105.5 232.5Q110 236 111 241L116 272Q117 277 122 278Q136 281 150 281Q164 281 178 278Q183 277 184 272L190 239Q192 234 196 231.5Q200 229 205 229Q207 229 210 229L239 240L242 241Q246 241 248 238Q267 217 275 190Q277 186 273 182ZM239 220 216 212Q210 210 205 210Q194 210 185 216Q176 222 172 232L171 238L167 261Q158 262 150 262Q142 262 133 261L129 238Q127 226 117.5 218Q108 210 96 210H89L84 212L61 220Q51 207 45 192L63 176Q74 167 75 153Q76 139 67 128L63 124L45 108Q51 93 61 80L84 88Q90 90 96 90Q106 90 115 84Q124 78 128 68L129 62L133 39Q142 37 150 37Q158 37 167 39L171 62Q173 74 182.5 82Q192 90 204 90H211L216 88L239 80Q249 93 255 108L237 124Q226 133 225 147Q224 161 233 172L237 176L255 191Q249 207 239 220Z", "link": "M178 75H197Q215 75 230 84Q245 93 254 108Q263 123 262 141Q263 167 244.5 186Q226 205 201 206H178Q174 206 171.5 203.5Q169 201 169 197.5Q169 194 171 191Q173 188 176 188L197 187Q216 188 230 174Q244 160 244 141Q244 122 231 108.5Q218 95 200 94H178Q174 94 171.5 91Q169 88 169 84.5Q169 81 171 78.5Q173 76 176 75H197ZM103 75H122Q126 75 128.5 77.5Q131 80 131 84Q131 88 129 90.5Q127 93 124 94H103Q84 94 70 107.5Q56 121 56 140Q56 159 69 172.5Q82 186 100 187H122Q126 187 128.5 190Q131 193 131 196.5Q131 200 129 202.5Q127 205 124 206H103Q85 206 70 197.5Q55 189 46.5 173.5Q38 158 38 141Q38 114 56 95.5Q74 77 100 75H122ZM103 131H197Q201 131 203.5 134Q206 137 206 140.5Q206 144 204 146.5Q202 149 199 150H103Q99 150 96.5 147.5Q94 145 94 141Q94 137 96 134.5Q98 132 101 131Z", "debug-disconnect": "M288 12Q285 9 281 9Q277 9 275 12L236 51Q216 37 191.5 39Q167 41 149 59L146 61Q140 68 140 78Q140 88 146 95L206 154Q213 161 222.5 161Q232 161 239 154L243 150Q263 130 263 103Q262 81 249 64L288 25Q291 23 291 19Q291 15 288 12ZM230 137 226 141Q225 142 223 142Q221 142 219 141L160 81Q158 80 158 78Q158 76 160 75L162 72Q177 57 198 57Q216 57 229.5 70Q243 83 243.5 103Q244 123 230 137ZM153 171 136 188 112 164 128 147Q131 145 131 141Q131 137 128.5 134Q126 131 122 131Q118 131 115 134L99 150L94 146Q87 139 77.5 139Q68 139 61 146L57 150Q37 170 38 197Q38 219 51 236L12 275Q9 277 9 281Q9 285 12 288Q15 291 19 291Q23 291 25 288L64 249Q81 261 102 261Q116 261 128.5 256Q141 251 151 241L154 239Q161 232 161 222Q161 212 154 205L150 201L166 185Q169 182 169 178Q169 174 166 171.5Q163 169 159.5 169Q156 169 153 171ZM140 225 138 228Q124 242 104.5 242.5Q85 243 71 230Q57 217 56.5 197Q56 177 70 163L74 159Q75 158 77 158Q79 158 81 159L140 219Q142 220 142 222Q142 224 140 225Z", From c40be89c14cdfd3681263e2264c318345bb3d173 Mon Sep 17 00:00:00 2001 From: Alexander Ivanov Date: Sat, 10 Oct 2026 16:11:15 +0300 Subject: [PATCH 3/4] a-change-is-committed-where-it-is-made: a change of its own worktree runs from its card, there The owner's check on HppMCP showed the card of a change worked in its own worktree with every action but the run: its Start was refused for a change this checkout does not have. - The card of a change's own worktree offers Run Change..., in both arrangements; "running" while a run of it reports from there. - The editor accepts the card's Start for a change of its own worktree, and Run Change resolves the change where it is worked and runs it there, with that worktree's harness. - The standalone's run dialog, schedule and chain take the worktree's root. Co-Authored-By: Claude Opus 5.5 --- .../a-change-is-committed-where-it-is-made.md | 2 +- .../proposal.md | 5 +- .../specs/shared-ui/spec.md | 12 +++++ .../tasks.md | 11 +++++ packages/extension/src/commands.test.ts | 34 +++++++++++++ packages/extension/src/commands.ts | 22 +++++---- .../src/webview/pipeline-panel.test.ts | 7 +-- .../extension/src/webview/pipeline-panel.ts | 6 ++- .../src/components/PipelineView.test.tsx | 20 ++++++++ .../webui/src/components/PipelineView.tsx | 49 ++++++++++++++----- packages/webui/src/standalone-entry.tsx | 39 +++++++++------ 11 files changed, 164 insertions(+), 43 deletions(-) diff --git a/.changeset/a-change-is-committed-where-it-is-made.md b/.changeset/a-change-is-committed-where-it-is-made.md index de44ca22..121370bf 100644 --- a/.changeset/a-change-is-committed-where-it-is-made.md +++ b/.changeset/a-change-is-committed-where-it-is-made.md @@ -5,4 +5,4 @@ "@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. New messages `OSW-GIT-001`..`OSW-GIT-203`. +**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/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 index 1ef45753..a0d64c3e 100644 --- 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 @@ -39,7 +39,10 @@ done, so the change can be worked at once as one's own. 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. + 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. 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 index 2c1dbdb0..830c94e5 100644 --- 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 @@ -8,6 +8,11 @@ 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 @@ -21,6 +26,13 @@ the server as it is here". - **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 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 index 03fc5c39..5c567f5a 100644 --- 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 @@ -42,6 +42,17 @@ button. 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). diff --git a/packages/extension/src/commands.test.ts b/packages/extension/src/commands.test.ts index d9049350..202f82a8 100644 --- a/packages/extension/src/commands.test.ts +++ b/packages/extension/src/commands.test.ts @@ -29,6 +29,7 @@ const renderSprintReportPageMock = vi.fn(); const discoverOpenSpecWorkspaceMock = vi.fn(); const createChangeMock = vi.fn(); const commitChangeMock = vi.fn(); +const surveyWorktreesMock = vi.fn(async (..._args: unknown[]): Promise => ({ directories: [], runsElsewhere: [] })); const workflowRulesNeedConsentMock = vi.fn(async (..._args: unknown[]) => [] as string[]); const commitOpenSpecSetupMock = vi.fn(async (..._args: unknown[]) => ({ state: "pushed", paths: ["openspec/"], commit: "abc" })); const writeWorkflowRulesMock = vi.fn(async (..._args: unknown[]): Promise> => ({ "CLAUDE.md": "created", "AGENTS.md": "created" })); @@ -148,6 +149,9 @@ vi.mock("@openspec-ui/core", async () => ({ deleteTaskLine: (...args: unknown[]) => deleteTaskLineMock(...args), detectAvailableAgentsDetailed: (...args: unknown[]) => detectAvailableAgentsDetailedMock(...args), discoverOpenSpecWorkspace: (...args: unknown[]) => discoverOpenSpecWorkspaceMock(...args), + // No other working directory: a card's Start names a change of this + // workspace (a-change-is-committed-where-it-is-made). + surveyWorktrees: (...args: unknown[]) => surveyWorktreesMock(...args), getChangeTimeline: (...args: unknown[]) => getChangeTimelineMock(...args), getChangeTimelines: (...args: unknown[]) => getChangeTimelinesMock(...args), readChangeSpans: (...args: unknown[]) => readChangeSpansMock(...args), @@ -2116,6 +2120,36 @@ describe("registerCommands", () => { })); }); + it("runs a change worked only in its own worktree, there", async () => { + // a-change-is-committed-where-it-is-made: its card's Start named a + // change this checkout does not have, and was refused. + discoverOpenSpecWorkspaceMock.mockResolvedValue({ changes: [], archivedChanges: [] }); + const worktree = "/wt/repo/fresh"; + surveyWorktreesMock.mockResolvedValueOnce({ + directories: [ + { path: "/workspace/repo", label: "repo", labelDeclared: false, isMain: true, isThis: true, branch: "main", runs: [], readable: true, changes: [], authorDiffers: false }, + { + path: worktree, label: "fresh", labelDeclared: false, isMain: false, isThis: false, branch: "fresh", ownChange: "fresh", runs: [], + readable: true, changes: [{ changeName: "fresh", tasksDone: 0, tasksTotal: 1, blockers: [], alsoIn: [] }], authorDiffers: false, + }, + ], + runsElsewhere: [], + }); + resolveHarnessConfigMock.mockResolvedValue({ stepAgents: {}, autonomyLevel: "semi-autonomous", reviewGate: { mode: "human-required" } }); + buildRunPlanMock.mockReturnValue(planFor("chain")); + const deps = makeDeps(); + registerCommands(makeContext() as unknown as import("vscode").ExtensionContext, deps); + + await vscodeMock._registeredCommands.get("openspec-ui.runChange")?.("fresh"); + + expect(resolveHarnessConfigMock).toHaveBeenCalledWith(worktree, "fresh"); + expect(deps.revealAiPanel).toHaveBeenCalledWith(expect.objectContaining({ + cwd: worktree, + changeDir: path.join(worktree, "openspec", "changes", "fresh"), + changeName: "fresh", + })); + }); + it("refuses a name that is not an active change of the workspace, and says so", async () => { discoverOpenSpecWorkspaceMock.mockResolvedValue({ changes: [], archivedChanges: [] }); const deps = makeDeps(); diff --git a/packages/extension/src/commands.ts b/packages/extension/src/commands.ts index 4f20bf96..ec0f077f 100644 --- a/packages/extension/src/commands.ts +++ b/packages/extension/src/commands.ts @@ -123,6 +123,7 @@ import { ancestryOf, findGraphRows, type ChangeGraphTreeItem, type GraphTreeNode import { describeEvent } from "./describe-event.js"; import { readConfig } from "./config.js"; import { openDiffAgainstHead } from "./native/diff.js"; +import { changeItemNamed } from "./change-action-target.js"; import { ChangeTreeItem, LeftoverTreeItem } from "./tree/changes-tree.js"; import { withoutRelationMark } from "./relations-context.js"; import { pickChangeToRelate, pickRelationKind, pickRelationToRemove } from "./relation-edit.js"; @@ -1793,18 +1794,18 @@ export function registerCommands(context: vscode.ExtensionContext, deps: Command let item: ChangeTreeItem | undefined; if (typeof invokedItem === "string") { // A Pipeline card's Start names its change rather than a tree row - // (a-change-is-run-from-its-card). Only an active change of this - // workspace runs; anything else is refused, and said. - // A Drafted card is a change too, and its Start begins at propose - // (a-change-before-its-proposal). - const change = (await discoverOpenSpecWorkspace(workspaceRoot, { changes: "active", drafts: true })).changes.find((candidate) => candidate.name === invokedItem); - if (!change) { + // (a-change-is-run-from-its-card): an active change of this + // workspace, or one worked in its own worktree, where it runs + // (a-change-is-committed-where-it-is-made). Anything else is + // refused, and said. A Drafted card is a change too, and its Start + // begins at propose (a-change-before-its-proposal). + item = await changeItemNamed(workspaceRoot, invokedItem); + if (!item) { void vscode.window.showWarningMessage( `OpenSpec Workbench: ${invokedItem} is not an active change of this workspace, so it cannot be run.`, ); return; } - item = new ChangeTreeItem(change.name, change.path, change.state, change.artifacts, false); } else { item = resolveTreeItem(invokedItem, deps.changesView, isChangeTreeItem); } @@ -1817,7 +1818,10 @@ export function registerCommands(context: vscode.ExtensionContext, deps: Command // resolves fresh, never caches the autonomy level": the user may // have just edited this change's harness.json via "Configure // Harness for this Change" immediately before running. - const config = await resolveHarnessConfig(workspaceRoot, item.changeName); + // Where the change is worked: its own worktree, or this checkout. + // Its harness is read there, and the run runs there. + const at = workedIn(item, workspaceRoot); + const config = await resolveHarnessConfig(at.root, item.changeName); const plan = buildRunPlan(config, { hasVsCodeAgent: true, ...(await readRecommendationInput(deps, workspaceRoot, item)), @@ -1847,7 +1851,7 @@ export function registerCommands(context: vscode.ExtensionContext, deps: Command runStanding = undefined; } deps.revealAiPanel({ - ...dashboardContext(workspaceRoot, item.changeDir), + ...dashboardContext(at.root, at.changeDir), runPlan: plan, changeName: item.changeName, ...(runStanding !== undefined ? { runStanding } : {}), diff --git a/packages/extension/src/webview/pipeline-panel.test.ts b/packages/extension/src/webview/pipeline-panel.test.ts index c4fbf2c2..445ce27c 100644 --- a/packages/extension/src/webview/pipeline-panel.test.ts +++ b/packages/extension/src/webview/pipeline-panel.test.ts @@ -460,17 +460,18 @@ describe("PipelinePanel — answering the view", () => { }); // a-change-is-run-from-its-card 5.2 - it("opens the run dialog for a card's Start, only for an active change of its workspace", async () => { + it("opens the run dialog for a card's Start, for an active change of its workspace or one worked in its own worktree", async () => { const runChange = vi.fn(async () => undefined); const { pipeline } = createPipelinePanel({ runChange }); pipeline.show(); await pipeline.deliverMessageForTesting({ type: "openspec-ui/run-change", changeName: "alpha" }); + // `beta` is only in its own worktree (a-change-is-committed-where-it-is-made). + await pipeline.deliverMessageForTesting({ type: "openspec-ui/run-change", changeName: "beta" }); await pipeline.deliverMessageForTesting({ type: "openspec-ui/run-change", changeName: "gone" }); await pipeline.deliverMessageForTesting({ type: "openspec-ui/run-change", changeName: "../etc" }); - expect(runChange).toHaveBeenCalledTimes(1); - expect(runChange).toHaveBeenCalledWith("alpha"); + expect(runChange.mock.calls).toEqual([["alpha"], ["beta"]]); }); // a-change-is-acted-on-from-its-card (ADR 0044): a card's action runs the diff --git a/packages/extension/src/webview/pipeline-panel.ts b/packages/extension/src/webview/pipeline-panel.ts index 80a8b249..99a371dd 100644 --- a/packages/extension/src/webview/pipeline-panel.ts +++ b/packages/extension/src/webview/pipeline-panel.ts @@ -701,7 +701,8 @@ export class PipelinePanel { * workspace can have and is one of its active changes. A message never * says what gets opened: the path comes from this host's own lookup. */ /** A card's Start: checked as opening a change is, then the change's run - * dialog (a-change-is-run-from-its-card). */ + * dialog (a-change-is-run-from-its-card). A change worked in its own + * worktree runs too, there (a-change-is-committed-where-it-is-made). */ private async runChange(changeName: unknown): Promise { const workspaceRoot = this.deps.getWorkspaceRoot(); if (!workspaceRoot || this.deps.runChange === undefined) return; @@ -710,7 +711,8 @@ export class PipelinePanel { return; } const change = await this.readers.findActiveChange(workspaceRoot, changeName); - if (!change) { + const own = change ? undefined : await this.readers.ownWorktree(workspaceRoot, changeName).catch(() => undefined); + if (!change && own?.ok !== true) { void vscode.window.showInformationMessage( `OpenSpec Workbench: ${changeName} is not an active change of this workspace — it may have been archived or deleted since the Pipeline was read.`, ); diff --git a/packages/webui/src/components/PipelineView.test.tsx b/packages/webui/src/components/PipelineView.test.tsx index bb684b57..b9f0a9d9 100644 --- a/packages/webui/src/components/PipelineView.test.tsx +++ b/packages/webui/src/components/PipelineView.test.tsx @@ -1954,6 +1954,26 @@ describe("PipelineView — a card works its own tasks", () => { expect(perform).toHaveBeenCalledWith("commitChange", "fresh", { confirmed: false }); }); + it("runs a change from the card of its own worktree, there, and not while a run of it reports from there", async () => { + // a-change-is-committed-where-it-is-made: its Start was the one action + // such a card lacked. + const onStart = vi.fn(); + const { unmount } = render( + report(change("alpha"))} survey={async () => survey(directory(), own())} onStart={onStart} />, + ); + const start = await screen.findByTestId("pipeline-directory-0-node-fresh-start"); + expect(start).toHaveAccessibleName("Run Change fresh"); + expect(start).toHaveTextContent("Run Change..."); + fireEvent.click(start); + expect(onStart).toHaveBeenCalledWith("fresh", "/wt/repo/fresh"); + unmount(); + + const running = { instanceId: "r1", changeName: "fresh", workingDirectory: "/wt/repo/fresh", gone: false } as never; + render( report(change("alpha"))} survey={async () => survey(directory(), own({ runs: [running] }))} onStart={onStart} />); + expect(await screen.findByTestId("pipeline-directory-0-node-fresh-controls")).toHaveTextContent("running"); + expect(screen.queryByTestId("pipeline-directory-0-node-fresh-start")).toBeNull(); + }); + it("holds a task's whole text in the hint of its row", async () => { await openOwnCard(); const row = screen.getByTestId("pipeline-directory-0-node-fresh-tasks").querySelector("[data-word='only a person can close it']"); diff --git a/packages/webui/src/components/PipelineView.tsx b/packages/webui/src/components/PipelineView.tsx index 8ba7f55b..f8d465b3 100644 --- a/packages/webui/src/components/PipelineView.tsx +++ b/packages/webui/src/components/PipelineView.tsx @@ -196,8 +196,10 @@ export interface PipelineViewProps { * is: the host writes it to the change's decisions.md in the run's working * directory (the-agent-asks-the-operator, ADR 0042). Absent, no Answer. */ onAnswerQuestion?: (answer: QuestionAnswer) => void; - /** Starts a change: the host opens its run dialog. Absent, no Start. */ - onStart?: (changeName: string) => void; + /** Starts a change: the host opens its run dialog. Absent, no Start. + * `directory` is the change's own worktree, for a card of one: the run + * runs there (a-change-is-committed-where-it-is-made). */ + onStart?: (changeName: string, directory?: string) => void; /** Updates a change's plan its last review asked to change: the host opens * the AI panel on `update` for it (ADR 0041). Absent, no Update the plan. */ onUpdatePlan?: (changeName: string) => void; @@ -1238,6 +1240,11 @@ function elsewhereActionFacts(change: SurveyedChange, directory: Extract, changeName: string): boolean { + return directory.runs.some((run) => run.changeName === changeName && !run.gone); +} + /** What a card of a change's own worktree says where the server does not * have the change's branch as it is there (a-change-is-committed-where-it-is-made). */ function notOnServerDetails(change: SurveyedChange | undefined, directory: Extract): CardDetail[] { @@ -1305,7 +1312,7 @@ function LocalPicture({ report, cards, now, onOpenChange, alsoIn, controls, dire hasProgress: !change.tasksUnreadable && change.tasksTotal > 0, hasCallout: false, detailLines: elsewhereDetails(change, where, labels, archivedOnMain, stages.get(name), now).length, - hasControls: false, + hasControls: facts !== undefined && controls.onStart !== undefined, actionRows: cardActionRows(actions).length, ...(openRows !== undefined ? { open: openRows } : {}), })); @@ -1399,6 +1406,7 @@ function LocalPicture({ report, cards, now, onOpenChange, alsoIn, controls, dire onToggle={() => openCards.toggle(foreign.directory.path, name)} actions={elsewhereActions.get(name) ?? []} {...(controls.chooseAction !== undefined ? { onChooseAction: controls.chooseAction } : {})} + {...(controls.onStart !== undefined ? { onStart: controls.onStart } : {})} /> ); } @@ -1825,7 +1833,7 @@ function Node({ node, model, onOpenChange, directory, open, onToggle, onChooseAc interface CardControlHandlers { heldRuns: Map; onRunControl?: (control: RunControl) => void; - onStart?: (changeName: string) => void; + onStart?: (changeName: string, directory?: string) => void; onUpdatePlan?: (changeName: string) => void; onViewLogs?: (changeName: string) => void; copyText?: (text: string) => Promise; @@ -2208,7 +2216,7 @@ function OtherDirectories({ survey, labels, now, onCards, openCards, archivedOnM survey: WorktreeSurvey; /** A change's actions, for the card of its own worktree drawn here, as * on the board (a-change-is-committed-where-it-is-made). */ - controls?: Pick; + controls?: Pick; order: ChangeOrder; labels: Map; now: Date; @@ -2253,7 +2261,7 @@ function OtherDirectories({ survey, labels, now, onCards, openCards, archivedOnM function OtherDirectory({ directory, index, labels, now, onCards, openCards, archivedOnMain, drawsChanges, order, controls }: { directory: SurveyedDirectory; - controls?: Pick; + controls?: Pick; order: ChangeOrder; index: number; labels: Map; @@ -2339,7 +2347,7 @@ function foreignDetails( function ForeignChanges({ directory, testId, labels, openCards, archivedOnMain, order, controls }: { directory: Extract; - controls?: Pick; + controls?: Pick; order: ChangeOrder; testId: string; labels: Map; @@ -2372,7 +2380,7 @@ function ForeignChanges({ directory, testId, labels, openCards, archivedOnMain, hasProgress: !change.tasksUnreadable && change.tasksTotal > 0, hasCallout: false, detailLines: foreignDetails(change, change.blockers, labels, archivedOnMain).length + notOnServerDetails(change, directory).length, - hasControls: false, + hasControls: facts !== undefined && controls?.onStart !== undefined, actionRows: cardActionRows(actionsOf.get(change.changeName) ?? []).length, ...(openRows !== undefined ? { open: openRows } : {}), })); @@ -2399,6 +2407,7 @@ function ForeignChanges({ directory, testId, labels, openCards, archivedOnMain, onToggle={() => openCards.toggle(directory.path, node.change.changeName)} actions={actionsOf.get(node.change.changeName) ?? []} {...(controls?.chooseAction !== undefined ? { onChooseAction: controls.chooseAction } : {})} + {...(controls?.onStart !== undefined ? { onStart: controls.onStart } : {})} /> )} /> @@ -2431,7 +2440,7 @@ function elsewhereDetails( * that directory is the change's own worktree, which its card works * (ADR 0026 amended 2026-10-05, a-card-works-its-own-tasks). Then its name * opens the change there, and its rows and list carry their actions. */ -function DirectoryCard({ node, change, directory, details, testId, open, onToggle, actions, onChooseAction }: { +function DirectoryCard({ node, change, directory, details, testId, open, onToggle, actions, onChooseAction, onStart }: { node: ChangeLayoutNode; change: SurveyedChange | undefined; directory: Extract; @@ -2443,6 +2452,9 @@ function DirectoryCard({ node, change, directory, details, testId, open, onToggl * board and arranged by step alike; none elsewhere. */ actions?: readonly ChangeActionState[]; onChooseAction?: (action: ChangeAction, changeName: string) => void; + /** Starts the change, on a card of its own worktree: the run runs there + * (a-change-is-committed-where-it-is-made). */ + onStart?: (changeName: string, directory?: string) => void; }) { const taskCards = useContext(TaskCardContext); const name = node.change.changeName; @@ -2478,6 +2490,15 @@ function DirectoryCard({ node, change, directory, details, testId, open, onToggl {onChooseAction !== undefined && actions !== undefined ? onChooseAction(action, name)} testId={testId} /> : null} + {own && onStart !== undefined ? ( + // Its own worktree's card starts it as this checkout's card does, + // and the run runs there (a-change-is-committed-where-it-is-made). +
+ {runningHere(directory, name) + ? running + : } +
+ ) : null} {counted ? : null} {rows.length > 0 ? ( @@ -2496,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; @@ -2511,12 +2533,13 @@ 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, actions, onChooseAction }: { +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; @@ -2529,5 +2552,5 @@ function ForeignNode({ node, change, directory, labels, testId, open, onToggle, archivedOnMain: ReadonlySet; }) { const details = [...foreignDetails(change, node.change.blockers, labels, archivedOnMain), ...notOnServerDetails(change, directory)]; - return ; + return ; } diff --git a/packages/webui/src/standalone-entry.tsx b/packages/webui/src/standalone-entry.tsx index d93fefee..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); @@ -2919,7 +2930,7 @@ function StandaloneApp() { tabIndex={-1} >

{`Run ${runChangeName}`}

- + ) : null} From 60e9c0fffba4c5483c8f8cf1369bb46ae96d64bd Mon Sep 17 00:00:00 2001 From: Alexander Ivanov Date: Sat, 10 Oct 2026 16:15:47 +0300 Subject: [PATCH 4/4] a-change-is-committed-where-it-is-made: the owner's check (4.3) Co-Authored-By: Claude Opus 5.5 --- .../a-change-is-committed-where-it-is-made/tasks.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) 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 index 5c567f5a..3857b70b 100644 --- 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 @@ -73,7 +73,13 @@ button. `--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. -- [ ] 4.3 **Human-only**: in `HppMCP`, on the extension built from this +- [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.