From d32b61a0aefc2a2ece2c19daa5b06557b657c131 Mon Sep 17 00:00:00 2001 From: Alexander Ivanov Date: Mon, 5 Oct 2026 10:12:51 +0300 Subject: [PATCH 1/4] feat: a Pipeline card works its change's tasks in its own worktree (a-card-works-its-own-tasks) ADR 0026 amended 2026-10-05: a change's own worktree (a working directory on the branch named after the change) is worked from its card, and only there. The host resolves the directory from the change's name. - A task's hint holds all of it, and a task opens whole in a panel over the board. - In the own worktree a card closes or reopens a task with a note under it (required for Human-only and delegated tasks, as the merge gate needs), commits that tasks.md alone and pushes the branch, and runs a delegated task with runners built for that worktree. Refused while a run works there. - The change's name opens its task list where it is worked: tasks.md from the worktree in VS Code (it looked only in this checkout, so a change not yet merged opened nothing), a page of its own in a new browser tab in the standalone app. - Hide done, kept with the view; the task in hand scrolled into view. - openspec-ui-cli task done|reopen|commit. Co-Authored-By: Claude Opus 5.5 --- .changeset/a-card-works-its-own-tasks.md | 21 + HARNESS.md | 1 + README.md | 7 +- ...-directories-are-observed-never-touched.md | 39 ++ docs/how-to/close-a-task-from-its-card.md | 76 +++ .../a-card-works-its-own-tasks/.openspec.yaml | 4 + .../a-card-works-its-own-tasks/design.md | 142 ++++++ .../a-card-works-its-own-tasks/proposal.md | 75 +++ .../specs/ci-cli/spec.md | 20 + .../specs/shared-ui/spec.md | 97 ++++ .../specs/standalone-app/spec.md | 15 + .../specs/vscode-extension/spec.md | 21 + .../a-card-works-its-own-tasks/tasks.md | 118 +++++ packages/cli/src/main.ts | 40 +- packages/cli/src/task-command.test.ts | 66 +++ packages/cli/src/task-command.ts | 84 ++++ packages/core/src/index.ts | 1 + .../src/own-worktree-tasks.commit.test.ts | 123 +++++ packages/core/src/own-worktree-tasks.test.ts | 231 ++++++++++ packages/core/src/own-worktree-tasks.ts | 431 ++++++++++++++++++ packages/core/src/task-checklist.test.ts | 18 + packages/core/src/task-checklist.ts | 48 ++ packages/core/src/worktree-survey-facts.ts | 12 + packages/core/src/worktree-survey.test.ts | 31 +- packages/core/src/worktree-survey.ts | 3 + packages/core/vitest.workspace.ts | 3 + packages/extension/src/extension.ts | 25 +- packages/extension/src/optional-server.ts | 8 + .../extension/src/test-utils/vscode-mock.ts | 11 + .../src/webview/pipeline-panel.test.ts | 67 ++- .../extension/src/webview/pipeline-panel.ts | 141 +++++- packages/server/src/cli.ts | 7 + packages/server/src/own-worktree-rest.test.ts | 80 ++++ packages/server/src/own-worktree-rest.ts | 131 ++++++ packages/server/src/rest.ts | 8 +- packages/server/src/server.ts | 27 ++ packages/webui/src/bridge-request.ts | 6 +- .../src/components/PipelineView.test.tsx | 127 ++++++ .../webui/src/components/PipelineView.tsx | 372 +++++++++++---- packages/webui/src/components/TaskPanel.tsx | 255 +++++++++++ .../webui/src/components/TasksPage.test.tsx | 59 +++ packages/webui/src/components/TasksPage.tsx | 126 +++++ packages/webui/src/pipeline-entry.tsx | 23 +- packages/webui/src/shell-ui.ts | 62 +++ packages/webui/src/standalone-entry.tsx | 91 +++- packages/webui/src/task-actions.test.ts | 35 ++ packages/webui/src/task-actions.ts | 63 +++ 47 files changed, 3332 insertions(+), 119 deletions(-) create mode 100644 .changeset/a-card-works-its-own-tasks.md create mode 100644 docs/how-to/close-a-task-from-its-card.md create mode 100644 openspec/changes/a-card-works-its-own-tasks/.openspec.yaml create mode 100644 openspec/changes/a-card-works-its-own-tasks/design.md create mode 100644 openspec/changes/a-card-works-its-own-tasks/proposal.md create mode 100644 openspec/changes/a-card-works-its-own-tasks/specs/ci-cli/spec.md create mode 100644 openspec/changes/a-card-works-its-own-tasks/specs/shared-ui/spec.md create mode 100644 openspec/changes/a-card-works-its-own-tasks/specs/standalone-app/spec.md create mode 100644 openspec/changes/a-card-works-its-own-tasks/specs/vscode-extension/spec.md create mode 100644 openspec/changes/a-card-works-its-own-tasks/tasks.md create mode 100644 packages/cli/src/task-command.test.ts create mode 100644 packages/cli/src/task-command.ts create mode 100644 packages/core/src/own-worktree-tasks.commit.test.ts create mode 100644 packages/core/src/own-worktree-tasks.test.ts create mode 100644 packages/core/src/own-worktree-tasks.ts create mode 100644 packages/server/src/own-worktree-rest.test.ts create mode 100644 packages/server/src/own-worktree-rest.ts create mode 100644 packages/webui/src/components/TaskPanel.tsx create mode 100644 packages/webui/src/components/TasksPage.test.tsx create mode 100644 packages/webui/src/components/TasksPage.tsx create mode 100644 packages/webui/src/task-actions.test.ts create mode 100644 packages/webui/src/task-actions.ts diff --git a/.changeset/a-card-works-its-own-tasks.md b/.changeset/a-card-works-its-own-tasks.md new file mode 100644 index 00000000..27dce7d3 --- /dev/null +++ b/.changeset/a-card-works-its-own-tasks.md @@ -0,0 +1,21 @@ +--- +"@openspec-ui/core": minor +"@openspec-ui/webui": minor +"@openspec-ui/server": minor +"@openspec-ui/cli": minor +"openspec-ui-vscode": minor +--- + +A Pipeline card works its change's tasks in the worktree made for it. A +task's hint now holds all of it, and selecting a task opens it whole beside +the board. For a change in its own worktree, a card closes or reopens a +task with a note written under it (required for a Human-only or delegated +task), commits that `tasks.md` alone and pushes the branch, and runs a +delegated task on its agent. Refused while a run works there. The change's +name opens its task list where it is worked: `tasks.md` from the worktree +in the editor, which looked only in this checkout and so opened nothing for +a change not yet merged; a page of its own in a new browser tab in the +standalone app. An open card can hide its done tasks. From a terminal: +`openspec-ui-cli task done|reopen [--note]` and +`openspec-ui-cli task commit `. Cards read from anywhere else stay +read-only (ADR 0026, amended). diff --git a/HARNESS.md b/HARNESS.md index 63ffffb3..8200197e 100644 --- a/HARNESS.md +++ b/HARNESS.md @@ -27,6 +27,7 @@ it — start there and come back here for the detail. | Run two changes at the same time | [How to](docs/how-to/run-changes-side-by-side.md) | [CI CLI](README.md#ci-cli-merge-gate) | | Find out what would stop a run here | — | `openspec-ui-cli doctor`, and `doctor --change ` for one change | | Learn why a run failed, or whether one has gone quiet | — | [`supervisor`](#supervisor), and `openspec-ui-cli advise` | +| Close a task, or open a change's tasks, from its card | [How to](docs/how-to/close-a-task-from-its-card.md) | `openspec-ui-cli task` | The harness sequences CLI-agent runs (or a mechanical action) across the stages of one OpenSpec change: `propose → review → apply → verify → diff --git a/README.md b/README.md index e66963f9..cc8ec3fa 100644 --- a/README.md +++ b/README.md @@ -81,7 +81,12 @@ Both hosts also carry, beyond the views above: with a reason. **By stage** arranges the same cards as a board, a column per stage from Proposed to Archived, and each card says where its change is, for how long, and who owns and implements it (ADR 0037). The - arrangement is kept for the next visit. + arrangement is kept for the next visit. A task opens whole beside the + board. For a change in the worktree made for it, its card also closes or + reopens a task with a note, commits and pushes that `tasks.md`, runs a + delegated task, and its name opens the change's task list there: in the + editor as `tasks.md`, in a browser as a page of its own + ([how to](docs/how-to/close-a-task-from-its-card.md)). - **The Timeline** - one change as a line of moments read from git, and a comparison of every change on a grid of days. - **What waits on somebody** - every task marked human-only or delegated diff --git a/docs/adr/0026-other-working-directories-are-observed-never-touched.md b/docs/adr/0026-other-working-directories-are-observed-never-touched.md index 88cddd84..ce7a8495 100644 --- a/docs/adr/0026-other-working-directories-are-observed-never-touched.md +++ b/docs/adr/0026-other-working-directories-are-observed-never-touched.md @@ -256,6 +256,45 @@ is reported, not forbidden, as the rules above already hold. The proposal that implements this measures that cost on this repository before it is accepted. +## Amendment, 2026-10-05: a change's own worktree is worked from its card + +The owner works each change in its own worktree (ADR 0022, the runbook's +"Where an agent works"), and the card already reads that worktree. So +every action this ADR withholds from a foreign directory was also +withheld from the one place the work is: the card could not open the +change's files, and a person closing a **Human-only** item had to find +`tasks.md` under `.worktrees` by hand. The owner asked on 2026-10-05 for +the card to open the change's task list, show each task whole, and tick +or untick a task, "only for your own worktree". + +**A change's own worktree is not foreign to its card.** A worktree is the +change's own when it is a working directory of this repository whose name +is the change's name (the survey's `belongsTo`), so it was made for that +change. From that change's card, and from nowhere else, a person may: + +- open the change's files there, in an editor or a browser tab; +- tick or untick one task in its `tasks.md`, with a note written under it; +- commit that `tasks.md` alone and push the worktree's branch; +- start the agent a delegated task in it names. + +**The directory is resolved by the host, never named by the request.** A +request carries the change's name and the task's line. The host finds the +change's own worktree with the `git worktree list` it already runs, and +refuses where there is none. A path from a request is never written to, +for the reason this ADR gives for the pair of a directory and a name. + +**Every other directory stays observed, never touched.** This checkout is +not offered either: it stays on the default branch, and the work is not +done there. A worktree made for another change, or any directory not +listed by this repository, carries no action, as above. + +**Nothing is written while a run works there.** A run that holds the +worktree's lease, or whose live record names the change in that +directory, may be editing the same file; a tick then refuses and says +which run. Git is run in the change's own worktree only for the commit +and push a person asks for, which this amendment allows there and nowhere +else. + ## Alternatives considered **A central registry or daemon agents report to.** Rejected: the diff --git a/docs/how-to/close-a-task-from-its-card.md b/docs/how-to/close-a-task-from-its-card.md new file mode 100644 index 00000000..10af03f7 --- /dev/null +++ b/docs/how-to/close-a-task-from-its-card.md @@ -0,0 +1,76 @@ +# Close a task from its card + +A change worked in the worktree made for it (`.worktrees//`, +on the branch named after the change) is worked from its Pipeline card. You +do not need to find its `tasks.md` under `.worktrees`, edit it, commit it and +push it by hand. + +Nothing below is offered for a change read from this checkout or from +another change's worktree: those cards stay read-only (ADR 0026, amended +2026-10-05). + +## Read a task whole + +Hover a task on an open card to see all of it, with what is written under +it. Select the task to open it whole beside the board. + +## Close or reopen it + +**1.** Select the task on its card. + +**2.** Write what was checked, and press **Close the task**. A note is +required for a **Human-only** or **Delegated** task, because the merge gate +refuses one closed with nothing written under it. It is optional otherwise. + +The checkbox is ticked and nothing else on its line changes. The note goes +under the task as `Closed by on : `. +**Reopen the task** works the same way, and its note is optional. + +The card refuses while a run is working in that worktree, and names the +run: the run may be editing the same file. It also refuses when the line +changed since the card was read. Read it again and try once more. + +## Send it + +Press **…** on the card, or open any of its tasks, and press **Commit and +push tasks.md**. The commit holds that file alone, whatever else is +changed or staged in the worktree, and the worktree's branch is pushed. It +is never made on the default branch. A refused push keeps the commit and +says what git said. + +## Open the change where it is worked + +Select the change's name on its card: +- **In the editor**, the worktree's `tasks.md` opens in a tab of its own. + The card's panel also opens the proposal and the design, shows the specs + folder, opens the worktree in a new window, and copies its path. + **Go to line** opens `tasks.md` at the task. +- **In a browser**, a new tab shows the change's tasks, each whole, with the + same controls. **Go to line** scrolls there. + +A change with no worktree of its own opens this checkout's copy, read-only. + +## Run a delegated task + +A task marked **Delegated to ``** offers **Run on ``**. The +agent runs in the change's worktree, as `openspec-ui-cli run --cwd` would +run it there. + +## Hide what is done + +**Hide done** on an open card lists only its open tasks. The choice is kept +like the open cards. + +## From a terminal + +```bash +openspec-ui-cli task done the-change 6.4 --note "seen in the Pipeline" +``` + +```bash +openspec-ui-cli task commit the-change +``` + +`task reopen` unticks. Each exits `1` with the reason when refused. + +Every command and its exit codes: [README](../../README.md#ci-cli-merge-gate). diff --git a/openspec/changes/a-card-works-its-own-tasks/.openspec.yaml b/openspec/changes/a-card-works-its-own-tasks/.openspec.yaml new file mode 100644 index 00000000..e05b7e31 --- /dev/null +++ b/openspec/changes/a-card-works-its-own-tasks/.openspec.yaml @@ -0,0 +1,4 @@ +schema: spec-driven +created: 2026-10-05 +follows: + - a-card-opens-to-its-tasks diff --git a/openspec/changes/a-card-works-its-own-tasks/design.md b/openspec/changes/a-card-works-its-own-tasks/design.md new file mode 100644 index 00000000..5c6e8758 --- /dev/null +++ b/openspec/changes/a-card-works-its-own-tasks/design.md @@ -0,0 +1,142 @@ +## Context + +See `proposal.md` and ADR 0026's amendment of 2026-10-05. What exists: + +- `changeOfWorktree` (`change-worktrees.ts`) pairs a working directory with + a change: not the main one, on a branch that is a valid change name. The + survey sets `belongsTo` only where that change is also active in the main + checkout, so a change that so far exists only in its own worktree (every + change before its pull request merges) is drawn in that directory's own + picture, as foreign, with no action. +- `parseChecklist` (`task-checklist.ts`) reads each item's line, its + `lineNumber`, and `continued` (the wrapped rest of the sentence). The + survey's `SurveyedTask` keeps the first line only, which is what the row's + `title` shows. +- The extension's `openChange` looks the change up in the workspace root + (`findActiveChange`) and opens `proposal.md` there. The standalone app's + name button opens the Change Editor tab for the workspace root. +- `writeTaskCheckStates` writes a checkbox after re-verifying its line's + text; `runDelegatedItem` runs a delegated item's agent on one line. +- The merge gate (`describeTaskDebts`) refuses a closed **Human-only** or + **Delegated** item with nothing written under it. + +## Goals / Non-Goals + +**Goals:** + +- A task is readable whole from its card. +- A person ticks, unticks, commits and pushes a task list from its card, + in the change's own worktree, and the merge gate accepts what is written. +- The change's files open from its card wherever the change is worked. + +**Non-Goals:** + +- Any action in this checkout or in another change's worktree. +- Editing a task's text from the card. `tasks.md` stays the place for that. +- Committing anything but that change's `tasks.md`. +- Resolving a push that is rejected: it is reported, as `git` says it. + +## Decisions + +1. **"Own worktree" is resolved by the host from the change's name.** + `resolveOwnWorktree({ git, repositoryRoot, changeName })` lists the + worktrees and returns the one `changeOfWorktree` pairs with that name, + where `openspec/changes//tasks.md` exists in it, or a refusal + naming why ("no worktree of its own", "its worktree has no tasks.md"). + It does not require the change to be active in the main checkout. Every + action takes the change's name and a line, never a path. + - Rejected: trusting the path the card shows. A request is data from a + page, and ADR 0026 names a change by the pair of directory and name. + +2. **A task row carries its body and its line.** `SurveyedTask` gains + `lineNumber` and `body`: the indented lines under the item up to the next + item or heading, blank lines inside kept, dedented, line breaks kept, + capped at 4 000 characters with the cap said. `text` stays the checkbox + line. The row's `title` becomes the whole of it. Selecting the row opens + it whole in a panel drawn over the board, as a run's logs are, rendered + as Markdown by the renderer the Change Editor already uses. + - Rejected: opening the row inside the card. A card's height is worked + out in core from what it lists, and the cards of a column stack by + those heights; a row grown in place would run into the card below. + The panel also has room for the note a tick needs. + +3. **A card for a change in its own worktree acts, wherever it is drawn.** + A directory the survey pairs with a change by `changeOfWorktree` carries + `ownChange` even where the change is not active in the main checkout; a + card read from such a directory is the change's own, and gets the + actions. A card read from this checkout or from any other directory + gets none, and the name opens this checkout's copy read-only where there + is one. + +4. **Tick and untick: `setTaskDone`.** + `setTaskDone({ repositoryRoot, changeName, lineNumber, expectedText, + done, note?, by?, now })`: + - finds the own worktree (decision 1); + - refuses while a run works there: the worktree's lease is held and + fresh, or a live status record names the change in that directory. + The refusal names the run; + - re-reads the line and refuses where its text is not `expectedText`; + - refuses a tick of a **Human-only** or **Delegated** item without a + note. A note is optional otherwise, and for an untick; + - writes `[x]` or `[ ]` and nothing else on that line, then, where there + is a note, one indented line directly under the item's existing body: + `Closed by on : ` or `Reopened by ...`. `` + is the worktree's configured git author, a claim, as everywhere here; + - writes through a temporary file and a rename. + +5. **Commit and push: `commitTaskList`.** + `commitTaskList({ repositoryRoot, changeName })`, in the own worktree + only: + - stages that `tasks.md` alone (`git add -- `), and says so where + it has nothing to commit; + - refuses on a detached head and on the repository's default branch; + - commits with the message `tasks(): ticked 6.4; reopened 2.1`, + built from the staged diff's checkbox lines, with hooks as configured; + - pushes the branch to its upstream, or to `origin` with `-u` where it + has none. A rejected push is reported with git's words; the commit + stays. + Other modified files in the worktree are left as they are. + +6. **Opening.** + - VS Code: `openChangeTasks(changeName, line?)` opens `tasks.md` from the + own worktree, or this checkout's copy, with `preview: false`, at the + line where one is given. The card's menu adds proposal, design, the + specs folder revealed in the Explorer, the worktree in a new window + (`vscode.openFolder`, `forceNewWindow`), and copying its path. + - Standalone: the name opens a new browser tab, + `?view=tasks&change=` with the session's token in the fragment. + The page reads the change's rows through `POST /api/change-tasks`, + shows every task whole, and offers the same controls through the same + panel; "Go to line" scrolls to the row and marks it. Framed in the + editor's Pipeline, the shell posts the request to the editor instead, + as it already does for a change's name. + +7. **A delegated task runs from its row.** The row of a **Delegated** item + in an own worktree offers "Run on ": `runOwnDelegatedItem` runs + the inbox's `runDelegatedItem` with the own worktree as its workspace + and runners the host builds for that root (`runnersFor`), as + `openspec-ui-cli run --cwd` does there. The run passes that directory's + allowlist, sandbox and audit, and its request and reply are recorded in + that directory's log. + +8. **Hide done, and the task in hand in view.** An open card offers "Hide + done" (remembered per browser, like the open cards) and scrolls its list + to the row marked in hand or probably next when it opens. + +9. **Terminal.** `openspec-ui-cli task done [--note + ]`, `task reopen [--note ]` and `task + commit ` call the same core functions, resolving the number to + its line in the own worktree. Exit 0 on success, 1 on a refusal with its + reason, 2 where it could not be attempted. + +## Risks / Trade-offs + +- **A person can tick an agent's task.** That is the point of a control; + the note says who did it, and the verify stage still rechecks what it + checks. +- **The push goes to the server.** It is one explicit control per push, it + carries one file, and it never runs on the default branch. +- **The survey grows by the task bodies.** Bounded per task, and read from + files the survey already reads. +- **A run can start between the check and the write.** The window is the + write itself; the line check refuses a line that a run changed first. diff --git a/openspec/changes/a-card-works-its-own-tasks/proposal.md b/openspec/changes/a-card-works-its-own-tasks/proposal.md new file mode 100644 index 00000000..9d4c0ff3 --- /dev/null +++ b/openspec/changes/a-card-works-its-own-tasks/proposal.md @@ -0,0 +1,75 @@ +## Why + +The owner, 2026-10-05: the Pipeline's cards are hardly interactive. Each +change is worked in its own worktree under `.worktrees`, and finding its +`tasks.md` there by hand takes a while. On a card: + +- a task's hint shows the first line only, so it is unclear which task it + is; +- nothing ticks or unticks a task, so closing a **Human-only** item means + finding and editing the file, then committing and pushing it by hand; +- the change's name opens nothing in the editor. The extension looks the + change up in this checkout, where a change worked in its own worktree + usually is not, and gives up. + +ADR 0026 withheld every action from a directory other than this one, +including the change's own worktree. Its amendment of 2026-10-05 allows a +card to act on its change's own worktree, and only there. + +## What Changes + +- **A task is shown whole.** Each task row carries its whole text and what + is written under it, and its line in `tasks.md`. Hovering shows all of it; + selecting the row opens it in place, keyboard included. +- **A task is ticked or unticked from its card**, in the change's own + worktree only. A note is written under the task, with who and when; for a + **Human-only** or **Delegated** task the note is required, as the merge + gate requires a record under one. Refused while a run works in that + worktree, and when the line has changed since it was read. +- **"Commit and push tasks.md"** on the card commits that file alone in the + change's own worktree and pushes its branch. +- **A delegated task can be started on its agent** from its row, in the + change's own worktree. +- **The change's name opens its task list**: + - in VS Code, `tasks.md` from the change's own worktree (or this + checkout's copy where there is none) in a new tab, and a menu beside it + for the proposal, the design, the specs folder, the worktree in a new + window, and copying its path; + - in the standalone app, a new browser tab showing the change's tasks, + whole, with the same controls. +- "Go to line" on a row opens `tasks.md` at that task (VS Code) or scrolls + the tasks tab to it. +- An open card can hide its done tasks, and scrolls to the task in hand. +- `openspec-ui-cli task done|reopen [--note]` and + `openspec-ui-cli task commit `, for a terminal and for agents. + +## Capabilities + +### New Capabilities + +(none) + +### Modified Capabilities + +- `shared-ui`: task rows shown whole; tick, untick, commit, run a delegated + task and go to a line from a card; hide done tasks; the task in hand in + view. +- `vscode-extension`: the change's name opens its task list from its own + worktree; the card's menu. +- `standalone-app`: the change's name opens its tasks in a new browser + tab; the routes the card's actions use. +- `ci-cli`: the `task` command. + +## Impact + +- `docs/adr/0026-...`: the amendment of 2026-10-05. +- `packages/core`: task rows gain their body and line; a module that finds + a change's own worktree, ticks or unticks one task with a note, and + commits and pushes that `tasks.md`. +- `packages/webui`: the card's task rows, controls and filter; a tasks tab + for the standalone app. +- `packages/extension`: opening a change's files from its own worktree; the + card's messages for tick, commit, run and go to line. +- `packages/server`: routes for the tasks tab and the card's actions. +- `packages/cli`: the `task` command. +- `HARNESS.md` or `README.md` where the Pipeline's card is described. diff --git a/openspec/changes/a-card-works-its-own-tasks/specs/ci-cli/spec.md b/openspec/changes/a-card-works-its-own-tasks/specs/ci-cli/spec.md new file mode 100644 index 00000000..c088ed90 --- /dev/null +++ b/openspec/changes/a-card-works-its-own-tasks/specs/ci-cli/spec.md @@ -0,0 +1,20 @@ +## ADDED Requirements + +### Requirement: A task is closed, reopened and committed from a terminal + +`openspec-ui-cli task done [--note ]`, `task +reopen [--note ]` and `task commit ` SHALL +do what a card's controls do, under the same rules, in the change's own +worktree. They SHALL exit 0 on success, 1 on a refusal with its reason, and +2 where the request could not be attempted. + +#### Scenario: Closing a Human-only task from a terminal + +- **WHEN** `task done the-change 6.4 --note "seen"` is run +- **THEN** 6.4 is ticked with the note under it, and the command exits 0 + +#### Scenario: Without the note it needs + +- **WHEN** `task done the-change 6.4` is run and 6.4 is Human-only +- **THEN** nothing is written, the reason is printed, and the command + exits 1 diff --git a/openspec/changes/a-card-works-its-own-tasks/specs/shared-ui/spec.md b/openspec/changes/a-card-works-its-own-tasks/specs/shared-ui/spec.md new file mode 100644 index 00000000..b6ed2ba6 --- /dev/null +++ b/openspec/changes/a-card-works-its-own-tasks/specs/shared-ui/spec.md @@ -0,0 +1,97 @@ +## ADDED Requirements + +### Requirement: A card's task is shown whole + +A card's task row SHALL carry the task's whole text and what is written +under it in `tasks.md`. Hovering the row SHALL show all of it, and +selecting the row, by pointer or keyboard, SHALL show it whole in a panel +beside the board, on a card read from any directory. A card's height is +derived from what it lists, so a task is not opened inside the card. + +#### Scenario: A wrapped task with a record + +- **WHEN** a task's sentence wraps onto two more lines and a record is + written under it +- **THEN** the row's hint holds all of it, and selecting the row shows it + whole beside the board + +### Requirement: A card acts on its change's own worktree only + +A card SHALL offer to tick or untick a task, to commit and push its +`tasks.md`, and to run a delegated task only where the card was read from +the change's own worktree: a working directory of this repository paired +with the change by its branch, holding the change's `tasks.md`. The host +SHALL resolve that directory from the change's name and SHALL NOT write to +a path a request names. A card read from this checkout or from any other +directory SHALL offer none of these. + +#### Scenario: A change worked in its own worktree + +- **WHEN** a change exists only in its own worktree +- **THEN** its card offers the task controls + +#### Scenario: A change in this checkout + +- **WHEN** a card was read from this checkout +- **THEN** it offers no task control + +### Requirement: A task is ticked or unticked with a note + +Ticking or unticking a task SHALL change that task's checkbox and nothing +else on its line, and SHALL write the note, where given, as one line under +the task naming who and when. A tick of a **Human-only** or **Delegated** +task SHALL require a note. The change SHALL be refused while a run works in +that worktree, naming the run, and where the task's line no longer reads as +it did when shown. + +#### Scenario: Closing a Human-only task + +- **WHEN** a person ticks a Human-only task with the note "seen in the + Pipeline" +- **THEN** the task reads `[x]`, a line under it reads "Closed by + on : seen in the Pipeline", and the merge gate counts it as recorded + +#### Scenario: A Human-only task without a note + +- **WHEN** a person ticks a Human-only task with no note +- **THEN** nothing is written and the card says a note is required + +#### Scenario: A run is working there + +- **WHEN** a run holds the worktree +- **THEN** the tick is refused and the card names the run + +### Requirement: A task list is committed and pushed from its card + +The card SHALL offer to commit the change's `tasks.md` alone in its own +worktree and push that worktree's branch. It SHALL NOT stage another file, +SHALL refuse on a detached head or the default branch, and SHALL report a +rejected push in git's words. + +#### Scenario: After closing a task + +- **WHEN** a person commits and pushes after ticking a task +- **THEN** one commit holding only `tasks.md` is pushed to the change's + branch, and the card says which commit + +### Requirement: A delegated task runs from its row + +The row of a **Delegated** task in an own worktree SHALL offer to run the +agent it names, through the same route as the inbox, so the run passes the +same allowlist, sandbox and audit. + +#### Scenario: Running a delegated task + +- **WHEN** a person runs a delegated task from its row +- **THEN** that agent runs on that line in the change's own worktree + +### Requirement: An open card can hide done tasks and shows the task in hand + +An open card SHALL offer to hide its done tasks, remembered by the host +like its open cards, and SHALL bring the task in hand, or the probable +next one, into view when it opens. + +#### Scenario: Hiding done tasks + +- **WHEN** a person hides done tasks on an open card +- **THEN** only open tasks are listed, and the choice holds after a reload diff --git a/openspec/changes/a-card-works-its-own-tasks/specs/standalone-app/spec.md b/openspec/changes/a-card-works-its-own-tasks/specs/standalone-app/spec.md new file mode 100644 index 00000000..d916fe6b --- /dev/null +++ b/openspec/changes/a-card-works-its-own-tasks/specs/standalone-app/spec.md @@ -0,0 +1,15 @@ +## ADDED Requirements + +### Requirement: A card's name opens the change's tasks in a new browser tab + +Selecting a change's name on a Pipeline card in the standalone app SHALL +open a new browser tab that shows that change's tasks, each whole, with the +controls its card offers, read from the change's own worktree where it has +one and from this checkout otherwise. "Go to line" SHALL scroll that tab to +the task and mark it. + +#### Scenario: Opening a change's tasks + +- **WHEN** a person selects a change's name on its card +- **THEN** a new browser tab shows the change's tasks, and the session is + the same session diff --git a/openspec/changes/a-card-works-its-own-tasks/specs/vscode-extension/spec.md b/openspec/changes/a-card-works-its-own-tasks/specs/vscode-extension/spec.md new file mode 100644 index 00000000..d75b99b5 --- /dev/null +++ b/openspec/changes/a-card-works-its-own-tasks/specs/vscode-extension/spec.md @@ -0,0 +1,21 @@ +## ADDED Requirements + +### Requirement: A card's name opens the change's task list where it is worked + +Selecting a change's name on a Pipeline card SHALL open that change's +`tasks.md` in a new editor tab: from the change's own worktree where it has +one, otherwise from this checkout. A card's menu SHALL also open the +proposal and the design, reveal the specs folder, open the worktree in a +new window, and copy the worktree's path. "Go to line" SHALL open +`tasks.md` at the task's line. + +#### Scenario: A change that exists only in its worktree + +- **WHEN** a person selects the name of a change that exists only in its + own worktree +- **THEN** that worktree's `tasks.md` opens in a new tab + +#### Scenario: Going to a task + +- **WHEN** a person chooses "Go to line" on task 6.4 +- **THEN** `tasks.md` opens with the cursor on 6.4's line diff --git a/openspec/changes/a-card-works-its-own-tasks/tasks.md b/openspec/changes/a-card-works-its-own-tasks/tasks.md new file mode 100644 index 00000000..faa262c1 --- /dev/null +++ b/openspec/changes/a-card-works-its-own-tasks/tasks.md @@ -0,0 +1,118 @@ +Requested by the owner on 2026-10-05: a card opens its change's task +list, shows each task whole, and ticks, unticks, commits and pushes in +the change's own worktree, and only there. + +## 1. The decision + +- [x] 1.1 ADR 0026's amendment of 2026-10-05 written: a change's own + worktree is worked from its card; the directory is resolved by the host; + every other directory stays observed; nothing is written while a run + works there. The owner set the rule ("only for your own worktree") and + agreed the proposals on 2026-10-05. + +## 2. Core + +- [x] 2.1 `resolveOwnWorktree` (design.md decision 1), with its refusals. + Test in `own-worktree-tasks.test.ts`: a worktree on the change's branch + with a `tasks.md`; none; one on another branch; the main checkout on that + branch; a worktree without `tasks.md`; a name that is not a change name. + A directory git cannot list is no worktree, not an error. +- [x] 2.2 `SurveyedTask.lineNumber` and `body` (decision 2), from + `parseChecklist`'s new `body`; the survey's `ownChange` marks a directory + paired with a change by `changeOfWorktree` even where the change is not + active in the main checkout (decision 3). Tests: `task-checklist.test.ts` + (a wrapped task, a record after a blank line, a nested list, a heading + ending the body, a tab indent, the cap) and `worktree-survey.test.ts` + (the lines, a body, `ownChange` without `belongsTo`). +- [x] 2.3 `setTaskDone` (decision 4). Test in `own-worktree-tasks.test.ts`: + tick and untick, the note line and where it goes (under the continued + lines, so `describeTaskDebts` counts it recorded), the required note, a + changed line refused, a held lease refused and named, a live record + refused and named, nothing else on the line changed, CRLF kept. + `readChangeTaskRows` and `runOwnDelegatedItem` beside it: rows whole from + the own worktree or, read-only, this checkout; a delegated run refused + before any runner is asked for. 14 passed. +- [x] 2.4 `commitTaskList` (decision 5), in + `own-worktree-tasks.commit.test.ts`, added to the `core-git-subprocess` + project: only `tasks.md` committed with another file staged and left + staged, the message, nothing to commit, no worktree of its own, a branch + without upstream pushed with `-u`, a refused push keeping its commit. 5 + passed. + +## 3. Hosts + +- [x] 3.1 `packages/server`: `POST /api/change-tasks`, + `/api/change-tasks/set`, `/commit` and `/run` (`own-worktree-rest.ts`), + each taking the change's name and resolving the worktree; `runnersFor` + builds runners for the own worktree, in `cli.ts` and the extension's + optional server. Test in `own-worktree-rest.test.ts`: the rows read, each + action refused where there is no worktree of its own, bad bodies, a cwd + outside. 4 passed. +- [x] 3.2 `packages/extension`: the name opens `tasks.md` from the own + worktree or this checkout (decision 6), in a tab of its own, at a line + where one is named; proposal, design, specs (revealed in the system's + file manager, since a worktree is outside the window's folder), a new + window and the path; requests `pipeline/task-set`, `task-commit`, + `task-run`. Tests in `pipeline-panel.test.ts`. This is also why the name + opened nothing before: the extension looked the change up in this + checkout, where a change not yet merged is not. +- [x] 3.3 `packages/webui`: rows hold their whole text in the hint and open + whole in `TaskPanel`, a panel over the board (design.md decision 2 says + why not in place); the controls of decisions 4, 5 and 7 only on cards + read from an own worktree; "Hide done" kept in the view's memory, with + the card's height following; the task in hand scrolled into view; the + standalone app's `?view=tasks&change=` page (`TasksPage`) opened in a new + browser tab; `task-actions.ts` turning both hosts' answers into the + panel's words. Tests: `PipelineView.test.tsx` (5 new), `TasksPage.test.tsx` + (4), `task-actions.test.ts` (3). +- [x] 3.4 `packages/cli`: `task done|reopen|commit` (decision 9) in + `task-command.ts`, with `--note`. Test in `task-command.test.ts`: a close + with its note, a refusal exiting 1, a commit, the cases exiting 2, and + the command line reaching it. 4 passed. + +## 4. Documents + +- [x] 4.1 `docs/how-to/close-a-task-from-its-card.md`; the Pipeline's line + in `README.md`; a row in `HARNESS.md`'s "Find what you need"; the `task` + command in the CLI's own usage text. +- [x] 4.2 A changeset: core, webui, server, cli, extension, minor. + +## 5. Checks + +- [x] 5.1 `npm run typecheck && npm run lint`, and every test project, after + `git add`, exit code 0, each project run on its own where the root run + would exceed a background limit. 2026-10-05: typecheck 0; lint 0 (three + warnings, all in files this change does not touch). The seven script + tests 0; `server` 5/122, `cli` 22/196, `webui` 78/699, `extension` 36/502; + `core` 2027 tests, of which one timed out while another package's tests + ran beside it (`successor-check`, which reads the repository's whole + archive) and passes alone, 10/10; `core-git-subprocess` file by file, 8 + files, 67 tests. +- [x] 5.2 `openspec validate a-card-works-its-own-tasks --strict`, and the + merge gate with the worktree's absolute path as `--cwd`. 2026-10-05: + "Change 'a-card-works-its-own-tasks' is valid"; the gate owes only 5.4, + the Human-only item. +- [x] 5.3 One live close end to end: a scratch repository with a change in + its own worktree and a bare remote; `task done` with a note on a + Human-only item, then `task commit`; record the line written, the commit + and the pushed ref, and the gate counting the item recorded. + Record, 2026-10-05, scratch change `close-a-task` in + `wt/close-a-task` on its own branch, pushed, with an unrelated + `notes.txt` left in the worktree: + - `task done close-a-task 1.2` without a note: "1.2 is Human-only: say + what was checked, so the note can be written under it.", exit 1, + nothing written; + - with `--note "seen in the Pipeline"`: `- [x] 1.2 **Human-only**: look + at it in the Pipeline.` and under it `Closed by live@example.invalid + on 2026-10-05: seen in the Pipeline`, exit 0; + - `task commit close-a-task`: "Committed 3359286f (tasks(close-a-task): + ticked 1.2) and pushed to origin/close-a-task.", exit 0. The server's + `close-a-task` holds that commit, naming + `openspec/changes/close-a-task/tasks.md` alone; `notes.txt` is still + untracked in the worktree; + - the merge gate on that worktree reports no `openItems` and no + `unrecordedItems` for the change. Its one failed item is openspec's own + structural validation of a scratch change with no spec delta. +- [ ] 5.4 **Human-only**: in the Pipeline of either host, open a change + worked in its own worktree from its name, read a task whole, close a task + with a note, and commit and push from the card. diff --git a/packages/cli/src/main.ts b/packages/cli/src/main.ts index 4cd470f4..e87cbaa7 100644 --- a/packages/cli/src/main.ts +++ b/packages/cli/src/main.ts @@ -24,6 +24,7 @@ import { readyCommand } from "./ready-command.js"; import { statusCommand } from "./status-command.js"; import { claimCommand, presentCommand, rootOf, untilInterrupted } from "./coordination-commands.js"; import { stopCommand } from "./stop-command.js"; +import { taskCommand } from "./task-command.js"; import { worktreeCommand } from "./worktree-command.js"; import { runValidateAll, type ValidateAllResult } from "./openspec-validate.js"; import { @@ -62,6 +63,9 @@ Usage: openspec-ui-cli send-back --stage --reason [--reopen :]... [--agent ] [--cwd ] + openspec-ui-cli task done|reopen [--note ] [--cwd ] + [--format text|json] + openspec-ui-cli task commit [--cwd ] [--format text|json] openspec-ui-cli worktree add [--cwd ] [--path ] [--base ] openspec-ui-cli worktree list [--cwd ] [--format text|json] @@ -103,6 +107,8 @@ Options: --agent The agent acting for you. Without it, the environment says (OPENSPEC_UI_AGENT, AI_AGENT) --reason Why a run is asked to stop; the run records it + --note What 'task done' or 'task reopen' writes under the task; + required to close a Human-only or delegated task --after Let the run finish this task first, as tasks.md numbers it (for example 4.6), then stop where the work is sound --repository owner/name for the manifest's links @@ -150,6 +156,13 @@ look identical to. It exits 0 whether or not anything is running. Each run says whose it is only as far as its signature shows: signed by an enrolled person, not verified, or a signature that does not check out. +'task' does what a Pipeline card does for a change in the worktree made +for it, and only there: 'done' and 'reopen' tick or untick one task in that +worktree's tasks.md, with the note written under it; 'commit' commits that +tasks.md alone and pushes the worktree's branch. Each exits 1 when it is +refused, saying why: no worktree of the change's own, a run working in it, +or a Human-only or delegated task closed without a note. + 'stop' asks a live run to stop where its work is sound, through a request signed with this machine's key. The run reads it at its next renewal and acts on it only if the request is verified and fresh. It prints the @@ -252,6 +265,10 @@ export interface MainOptions { * (an-agent-says-where-it-is-working). */ activity?: string; wait?: string; + /** `task done|reopen`'s third positional, the task's number, and the note + * written under it (a-card-works-its-own-tasks). */ + taskNumber?: string; + note?: string; } export interface MainDeps { @@ -271,6 +288,7 @@ export interface MainDeps { leaseCommand?: typeof leaseCommand; statusCommand?: typeof statusCommand; stopCommand?: typeof stopCommand; + taskCommand?: typeof taskCommand; enrolCommand?: typeof enrolCommand; joinCommand?: typeof joinCommand; peopleCommand?: typeof peopleCommand; @@ -329,11 +347,12 @@ function parseArgs(argv: string[]): { command: string | undefined; options: Main arg === "--email" || arg === "--to" || arg === "--stage" || - arg === "--agent" + arg === "--agent" || + arg === "--note" ) { const value = argv[i + 1]; if (!value) return { command: undefined, options, error: `${arg} requires a value` }; - const key = arg.slice(2) as "repository" | "ref" | "commit" | "releases" | "from" | "path" | "base" | "change" | "label" | "reason" | "after" | "activity" | "wait" | "handle" | "name" | "email" | "to" | "stage" | "agent"; + const key = arg.slice(2) as "repository" | "ref" | "commit" | "releases" | "from" | "path" | "base" | "change" | "label" | "reason" | "after" | "activity" | "wait" | "handle" | "name" | "email" | "to" | "stage" | "agent" | "note"; options[key] = value; i += 1; } else if (arg === "--reopen") { @@ -361,6 +380,7 @@ function parseArgs(argv: string[]): { command: string | undefined; options: Main if (positional[1] !== undefined) options.changeName = positional[1]; if (positional[2] !== undefined) options.worktreeChange = positional[2]; + if (positional[3] !== undefined) options.taskNumber = positional[3]; return { command: positional[0], options }; } @@ -642,6 +662,22 @@ export async function runMain(argv: string[], deps: MainDeps = {}): Promise []`: the action in the first + // positional, as `worktree` has it. + return await (deps.taskCommand ?? taskCommand)( + { + repositoryRoot: options.cwd ?? process.cwd(), + action: options.changeName, + changeName: options.worktreeChange, + number: options.taskNumber, + ...(options.note !== undefined ? { note: options.note } : {}), + format: options.format === "json" ? "json" : "text", + }, + { stdout, stderr }, + ); + } + if (command === "worktree") { const action = options.changeName; if (action !== "add" && action !== "list" && action !== "move" && action !== "remove") { diff --git a/packages/cli/src/task-command.test.ts b/packages/cli/src/task-command.test.ts new file mode 100644 index 00000000..5e6e212a --- /dev/null +++ b/packages/cli/src/task-command.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it, vi } from "vitest"; +import { taskCommand } from "./task-command.js"; +import { runMain } from "./main.js"; + +// a-card-works-its-own-tasks 3.4: a card's controls from a terminal, under +// core's rules, with the 0/1/2 contract. +function collect() { + const out: string[] = []; + const err: string[] = []; + return { out, err, deps: { stdout: (line: string) => out.push(line), stderr: (line: string) => err.push(line) } }; +} + +const item = { lineNumber: 7, text: "6.4 **Human-only**: look", done: false, humanOnly: true as const }; + +describe("taskCommand", () => { + it("closes a task with its note, and says it is not committed", async () => { + const { out, deps } = collect(); + const set = vi.fn(async () => ({ ok: true as const, tasksPath: "/t", line: "- [x] 6.4 **Human-only**: look", noteLine: " Closed by ada on 2026-10-05: seen" })); + const code = await taskCommand( + { repositoryRoot: "/repo", action: "done", changeName: "demo", number: "6.4", note: "seen", format: "text" }, + { ...deps, find: async () => ({ ok: true as const, item }), set }, + ); + expect(code).toBe(0); + expect(set).toHaveBeenCalledWith(expect.objectContaining({ changeName: "demo", lineNumber: 7, expectedText: item.text, done: true, note: "seen" })); + expect(out.join("\n")).toContain("Not committed"); + }); + + it("exits 1 with core's reason on a refusal", async () => { + const { err, deps } = collect(); + const code = await taskCommand( + { repositoryRoot: "/repo", action: "done", changeName: "demo", number: "6.4", format: "text" }, + { + ...deps, + find: async () => ({ ok: true as const, item }), + set: async () => ({ ok: false as const, kind: "note-required" as const, reason: "6.4 is Human-only: say what was checked." }), + }, + ); + expect(code).toBe(1); + expect(err.join("\n")).toContain("say what was checked"); + }); + + it("commits and pushes, and exits 2 for what it cannot attempt", async () => { + const { out, deps } = collect(); + const commit = vi.fn(async () => ({ ok: true as const, commit: "abcdef1234", branch: "demo", pushedTo: "origin/demo", message: "tasks(demo): ticked 6.4" })); + expect(await taskCommand({ repositoryRoot: "/repo", action: "commit", changeName: "demo", number: undefined, format: "text" }, { ...deps, commit })).toBe(0); + expect(out.join("\n")).toContain("pushed to origin/demo"); + + expect(await taskCommand({ repositoryRoot: "/repo", action: "tick", changeName: "demo", number: "1", format: "text" }, collect().deps)).toBe(2); + expect(await taskCommand({ repositoryRoot: "/repo", action: "done", changeName: undefined, number: "1", format: "text" }, collect().deps)).toBe(2); + expect(await taskCommand({ repositoryRoot: "/repo", action: "done", changeName: "demo", number: undefined, format: "text" }, collect().deps)).toBe(2); + }); + + it("is reached from the command line with its action, change, number and note", async () => { + const taskCommandSeam = vi.fn(async () => 0); + const code = await runMain(["task", "done", "demo", "6.4", "--note", "seen", "--cwd", "/repo"], { + stdout: () => undefined, + stderr: () => undefined, + taskCommand: taskCommandSeam, + }); + expect(code).toBe(0); + expect(taskCommandSeam).toHaveBeenCalledWith( + expect.objectContaining({ repositoryRoot: "/repo", action: "done", changeName: "demo", number: "6.4", note: "seen" }), + expect.anything(), + ); + }); +}); diff --git a/packages/cli/src/task-command.ts b/packages/cli/src/task-command.ts new file mode 100644 index 00000000..4123a03e --- /dev/null +++ b/packages/cli/src/task-command.ts @@ -0,0 +1,84 @@ +// `openspec-ui-cli task done|reopen [--note ]` and +// `openspec-ui-cli task commit ` — what a Pipeline card does in its +// change's own worktree, from a terminal (a-card-works-its-own-tasks). +// +// Presentation only. Which directory is the change's own worktree, whether a +// task may be ticked without a note, and what is committed are core's +// (`findOwnTask`, `setTaskDone`, `commitTaskList`), under the same rules a +// card's controls are. + +import { commitTaskList, findOwnTask, setTaskDone } from "@openspec-ui/core"; + +export type TaskAction = "done" | "reopen" | "commit"; + +export interface TaskOptions { + repositoryRoot: string; + action: string | undefined; + changeName: string | undefined; + /** The task, as `tasks.md` numbers it. */ + number: string | undefined; + note?: string; + format: "text" | "json"; +} + +export interface TaskDeps { + stdout: (line: string) => void; + stderr: (line: string) => void; + /** Test seams. */ + find?: typeof findOwnTask; + set?: typeof setTaskDone; + commit?: typeof commitTaskList; +} + +/** `0` on success, `1` on a refusal with its reason, `2` where the request + * could not be attempted: an unknown action, a missing change or number, or + * a failure reading or writing. */ +export async function taskCommand(options: TaskOptions, deps: TaskDeps): Promise { + const { action, changeName } = options; + if (action !== "done" && action !== "reopen" && action !== "commit") { + deps.stderr("openspec-ui-cli: task takes done, reopen or commit"); + return 2; + } + if (!changeName) { + deps.stderr(`openspec-ui-cli: task ${action} needs a change name`); + return 2; + } + + const say = (result: { ok: boolean } & Record, words: string): number => { + if (options.format === "json") deps.stdout(JSON.stringify(result, null, 2)); + else if (result.ok) deps.stdout(words); + else deps.stderr(`openspec-ui-cli: ${String(result.reason)}`); + return result.ok ? 0 : 1; + }; + + try { + if (action === "commit") { + const result = await (deps.commit ?? commitTaskList)({ repositoryRoot: options.repositoryRoot, changeName }); + return say(result as never, result.ok ? `Committed ${result.commit.slice(0, 8)} (${result.message}) and pushed to ${result.pushedTo}.` : ""); + } + + if (!options.number) { + deps.stderr(`openspec-ui-cli: task ${action} needs the task's number, as tasks.md numbers it (for example 6.4)`); + return 2; + } + const found = await (deps.find ?? findOwnTask)({ repositoryRoot: options.repositoryRoot, changeName, number: options.number }); + if (!found.ok) return say(found as never, ""); + const result = await (deps.set ?? setTaskDone)({ + repositoryRoot: options.repositoryRoot, + changeName, + lineNumber: found.item.lineNumber, + expectedText: found.item.text, + done: action === "done", + ...(options.note !== undefined ? { note: options.note } : {}), + }); + return say( + result as never, + result.ok + ? [result.line.trim(), ...(result.noteLine !== undefined ? [result.noteLine] : []), "Not committed: 'openspec-ui-cli task commit' sends it."].join("\n") + : "", + ); + } catch (error) { + deps.stderr(`openspec-ui-cli: task ${action} could not complete: ${error instanceof Error ? error.message : String(error)}`); + return 2; + } +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index b77d3a90..3c446995 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -113,6 +113,7 @@ export * from "./pipeline-card.js"; export * from "./hints.js"; export * from "./failure-diagnosis.js"; export * from "./supervisor.js"; +export * from "./own-worktree-tasks.js"; export * from "./change-layout.js"; export * from "./board-columns-facts.js"; export * from "./board-columns.js"; diff --git a/packages/core/src/own-worktree-tasks.commit.test.ts b/packages/core/src/own-worktree-tasks.commit.test.ts new file mode 100644 index 00000000..001cb9a8 --- /dev/null +++ b/packages/core/src/own-worktree-tasks.commit.test.ts @@ -0,0 +1,123 @@ +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 { commitTaskList } from "./own-worktree-tasks.js"; +import { gitIsolationArgs } from "./test-support/git-isolation.js"; + +// a-card-works-its-own-tasks 2.4. Real git against a bare remote, because +// what is asserted is what reaches the server: one commit holding the task +// list alone, on the change's branch. +// +// 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 env = { + ...process.env, + GIT_AUTHOR_NAME: "Fixture", + GIT_AUTHOR_EMAIL: "fixture@example.com", + GIT_COMMITTER_NAME: "Fixture", + GIT_COMMITTER_EMAIL: "fixture@example.com", + }; + const { stdout } = await run("git", [...(await gitIsolationArgs()), ...args], { cwd, env }); + 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, and the change `demo` in its own + * worktree with a `tasks.md`. `pushed` decides whether the change's branch + * is already on the server. */ +async function changeInItsWorktree(options: { pushed: boolean }): Promise<{ work: string; worktree: string; remote: string }> { + const root = await mkdtemp(path.join(os.tmpdir(), "openspec-own-commit-")); + 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's own git makes the commit, so the identity lives in the + // repository: a runner has no global one to fall back on. + await git(work, ["config", "user.name", "Fixture"]); + await git(work, ["config", "user.email", "fixture@example.com"]); + await git(work, ["remote", "add", "origin", remote]); + await write(path.join(work, "README.md"), "repo\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", "tasks.md"), "- [ ] 1.1 **Human-only**: look.\n"); + await write(path.join(worktree, "src.txt"), "code\n"); + await git(worktree, ["add", "."]); + await git(worktree, ["commit", "-q", "-m", "the change"]); + if (options.pushed) await git(worktree, ["push", "-q", "-u", "origin", "demo"]); + return { work, worktree, remote }; +} + +describe("commitTaskList", () => { + it("commits tasks.md alone, with other files dirty, and pushes the change's branch", async () => { + const { work, worktree, remote } = await changeInItsWorktree({ pushed: true }); + await write(path.join(worktree, "openspec", "changes", "demo", "tasks.md"), "- [x] 1.1 **Human-only**: look.\n Closed by Fixture on 2026-10-05: seen.\n"); + await write(path.join(worktree, "src.txt"), "code, still being written\n"); + await git(worktree, ["add", "src.txt"]); + + const result = await commitTaskList({ repositoryRoot: work, changeName: "demo" }); + + expect(result).toMatchObject({ ok: true, branch: "demo", pushedTo: "origin/demo", message: "tasks(demo): ticked 1.1" }); + const files = await git(remote, ["show", "--name-only", "--format=", "demo"]); + expect(files.split(/\r?\n/)).toEqual(["openspec/changes/demo/tasks.md"]); + // What else was staged stays staged, and stays uncommitted. + expect(await git(worktree, ["diff", "--cached", "--name-only"])).toBe("src.txt"); + }); + + it("pushes a branch that has no upstream with one", async () => { + const { work, worktree, remote } = await changeInItsWorktree({ pushed: false }); + await write(path.join(worktree, "openspec", "changes", "demo", "tasks.md"), "- [x] 1.1 **Human-only**: look.\n Closed by Fixture on 2026-10-05: seen.\n"); + + const result = await commitTaskList({ repositoryRoot: work, changeName: "demo" }); + + expect(result).toMatchObject({ ok: true, pushedTo: "origin/demo" }); + expect(await git(remote, ["rev-parse", "demo"])).toBe(await git(worktree, ["rev-parse", "HEAD"])); + expect(await git(worktree, ["rev-parse", "--abbrev-ref", "demo@{upstream}"])).toBe("origin/demo"); + }); + + it("says there is nothing to commit, and commits nothing", async () => { + const { work, worktree } = await changeInItsWorktree({ pushed: true }); + const before = await git(worktree, ["rev-parse", "HEAD"]); + + expect(await commitTaskList({ repositoryRoot: work, changeName: "demo" })).toMatchObject({ ok: false, kind: "nothing-to-commit" }); + expect(await git(worktree, ["rev-parse", "HEAD"])).toBe(before); + }); + + it("refuses a change with no worktree of its own", async () => { + const { work } = await changeInItsWorktree({ pushed: true }); + expect(await commitTaskList({ repositoryRoot: work, changeName: "other" })).toMatchObject({ ok: false, kind: "no-worktree" }); + }); + + it("keeps the commit and reports git's words where the push is refused", async () => { + const { work, worktree } = await changeInItsWorktree({ pushed: true }); + await git(worktree, ["remote", "set-url", "origin", path.join(path.dirname(work), "no-such-remote.git")]); + await write(path.join(worktree, "openspec", "changes", "demo", "tasks.md"), "- [x] 1.1 **Human-only**: look.\n Closed by Fixture on 2026-10-05: seen.\n"); + + const result = await commitTaskList({ repositoryRoot: work, changeName: "demo" }); + + expect(result).toMatchObject({ ok: false, kind: "push-rejected" }); + expect(result.ok ? undefined : (result as { commit?: string }).commit).toBe(await git(worktree, ["rev-parse", "HEAD"])); + }); +}); diff --git a/packages/core/src/own-worktree-tasks.test.ts b/packages/core/src/own-worktree-tasks.test.ts new file mode 100644 index 00000000..0196ffb5 --- /dev/null +++ b/packages/core/src/own-worktree-tasks.test.ts @@ -0,0 +1,231 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { AgentStatusReport } from "./agent-status.js"; +import type { GitWorktree } from "./git.js"; +import { describeTaskDebts, parseTaskChecklist } from "./task-checklist.js"; +import { readChangeTaskRows, resolveOwnWorktree, runOwnDelegatedItem, setTaskDone, taskListCommitMessage } from "./own-worktree-tasks.js"; + +// a-card-works-its-own-tasks 2.1 and 2.3. The worktree list is a stand-in, +// so no git process is started; the files are real. +// every-varying-check-has-a-budget: temporary files only, no process. +vi.setConfig({ testTimeout: 15_000 }); + +const roots: string[] = []; +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); +}); + +const TASKS = [ + "## 1. Work", + "", + "- [ ] 1.1 Write the module.", + "- [ ] 1.2 **Human-only**: see it in the Pipeline, with", + " the card open.", + "", + " An earlier remark after a blank line.", + "- [x] 1.3 Already done.", + "", +].join("\n"); + +async function fixture(options: { tasks?: string | null; branch?: string } = {}): Promise<{ root: string; worktree: string; worktrees: GitWorktree[] }> { + const root = await mkdtemp(path.join(os.tmpdir(), "own-worktree-")); + roots.push(root); + const main = path.join(root, "main"); + const worktree = path.join(root, "wt", "demo"); + await mkdir(main, { recursive: true }); + if (options.tasks !== null) { + const dir = path.join(worktree, "openspec", "changes", "demo"); + await mkdir(dir, { recursive: true }); + await writeFile(path.join(dir, "tasks.md"), options.tasks ?? TASKS, "utf8"); + } else { + await mkdir(worktree, { recursive: true }); + } + return { + root, + worktree, + worktrees: [{ path: main, branch: "main" }, { path: worktree, branch: options.branch ?? "demo" }], + }; +} + +const quiet = { readLease: async () => undefined, readStatuses: async () => [] as AgentStatusReport[] }; + +describe("resolveOwnWorktree", () => { + it("finds the worktree on the change's branch that holds its tasks.md", async () => { + const { worktree, worktrees } = await fixture(); + const own = await resolveOwnWorktree({ repositoryRoot: "/repo", changeName: "demo", git: { worktreeList: async () => worktrees } }); + expect(own).toMatchObject({ ok: true, path: worktree, branch: "demo", mainBranch: "main" }); + }); + + it("refuses a change with no worktree, one on another branch, and the main checkout on that branch", async () => { + const { worktrees } = await fixture({ branch: "other" }); + const none = await resolveOwnWorktree({ repositoryRoot: "/repo", changeName: "demo", git: { worktreeList: async () => worktrees } }); + expect(none).toMatchObject({ ok: false, kind: "no-worktree" }); + + const mainOnIt = await resolveOwnWorktree({ + repositoryRoot: "/repo", + changeName: "demo", + git: { worktreeList: async () => [{ path: "/main", branch: "demo" }] }, + }); + expect(mainOnIt).toMatchObject({ ok: false, kind: "no-worktree" }); + }); + + it("refuses a worktree without the change's tasks.md, and a name that is not a change name", async () => { + const { worktrees } = await fixture({ tasks: null }); + expect(await resolveOwnWorktree({ repositoryRoot: "/repo", changeName: "demo", git: { worktreeList: async () => worktrees } })) + .toMatchObject({ ok: false, kind: "no-task-list" }); + expect(await resolveOwnWorktree({ repositoryRoot: "/repo", changeName: "../escape", git: { worktreeList: async () => worktrees } })) + .toMatchObject({ ok: false, kind: "no-worktree" }); + }); +}); + +describe("setTaskDone", () => { + const now = () => new Date(2026, 9, 5, 9, 30); + + it("ticks a task and changes nothing else on its line", async () => { + const { worktree, worktrees } = await fixture(); + const result = await setTaskDone({ + repositoryRoot: "/repo", changeName: "demo", git: { worktreeList: async () => worktrees }, ...quiet, + lineNumber: 2, expectedText: "Write the module.", done: true, now, + }); + expect(result).toMatchObject({ ok: true, line: "- [x] 1.1 Write the module." }); + const text = await readFile(path.join(worktree, "openspec", "changes", "demo", "tasks.md"), "utf8"); + expect(text).toBe(TASKS.replace("- [ ] 1.1", "- [x] 1.1")); + }); + + it("writes a Human-only task's note under the lines that carry it on, so the gate counts it", async () => { + const { worktree, worktrees } = await fixture(); + const result = await setTaskDone({ + repositoryRoot: "/repo", changeName: "demo", git: { worktreeList: async () => worktrees }, ...quiet, + lineNumber: 3, expectedText: "1.2 **Human-only**: see it in the Pipeline, with", done: true, + note: "seen in the Pipeline", by: "ada@example.com", now, + }); + expect(result).toMatchObject({ ok: true, noteLine: " Closed by ada@example.com on 2026-10-05: seen in the Pipeline" }); + const text = await readFile(path.join(worktree, "openspec", "changes", "demo", "tasks.md"), "utf8"); + expect(text.split("\n").slice(3, 6)).toEqual([ + "- [x] 1.2 **Human-only**: see it in the Pipeline, with", + " the card open.", + " Closed by ada@example.com on 2026-10-05: seen in the Pipeline", + ]); + expect(describeTaskDebts(parseTaskChecklist(text)).unrecorded).toEqual([]); + }); + + it("refuses to tick a Human-only task without a note, and writes nothing", async () => { + const { worktree, worktrees } = await fixture(); + const result = await setTaskDone({ + repositoryRoot: "/repo", changeName: "demo", git: { worktreeList: async () => worktrees }, ...quiet, + lineNumber: 3, expectedText: "1.2 **Human-only**: see it in the Pipeline, with", done: true, now, + }); + expect(result).toMatchObject({ ok: false, kind: "note-required" }); + expect(await readFile(path.join(worktree, "openspec", "changes", "demo", "tasks.md"), "utf8")).toBe(TASKS); + }); + + it("reopens a task with a note saying so", async () => { + const { worktree, worktrees } = await fixture(); + await setTaskDone({ + repositoryRoot: "/repo", changeName: "demo", git: { worktreeList: async () => worktrees }, ...quiet, + lineNumber: 7, expectedText: "1.3 Already done.", done: false, note: "the test was wrong", by: "ada", now, + }); + const lines = (await readFile(path.join(worktree, "openspec", "changes", "demo", "tasks.md"), "utf8")).split("\n"); + expect(lines.slice(7, 9)).toEqual(["- [ ] 1.3 Already done.", " Reopened by ada on 2026-10-05: the test was wrong"]); + }); + + it("refuses a line that changed since it was shown, and a line that is no task", async () => { + const { worktrees } = await fixture(); + const base = { repositoryRoot: "/repo", changeName: "demo", git: { worktreeList: async () => worktrees }, ...quiet, done: true, now }; + expect(await setTaskDone({ ...base, lineNumber: 2, expectedText: "Write the old module." })).toMatchObject({ ok: false, kind: "line-changed" }); + expect(await setTaskDone({ ...base, lineNumber: 0, expectedText: "## 1. Work" })).toMatchObject({ ok: false, kind: "not-a-task" }); + }); + + it("refuses while a run holds the worktree, or a live record names the change there, naming it", async () => { + const { worktree, worktrees } = await fixture(); + const base = { + repositoryRoot: "/repo", changeName: "demo", git: { worktreeList: async () => worktrees }, + lineNumber: 2, expectedText: "Write the module.", done: true, now, + }; + const leased = await setTaskDone({ + ...base, + readLease: async () => ({ hostKind: "vscode-extension" as const, hostname: "box", pid: 42, heartbeatAgeMs: 1000 }), + readStatuses: async () => [], + }); + expect(leased).toMatchObject({ ok: false, kind: "run-working" }); + expect(leased.ok ? "" : leased.reason).toContain("pid 42"); + + const recorded = await setTaskDone({ + ...base, + readLease: async () => undefined, + readStatuses: async () => [{ instanceId: "i9", changeName: "demo", workingDirectory: worktree, gone: false, activity: "running npm test" } as AgentStatusReport], + }); + expect(recorded).toMatchObject({ ok: false, kind: "run-working" }); + expect(recorded.ok ? "" : recorded.reason).toContain("i9"); + }); + + it("keeps a file's CRLF line endings", async () => { + const { worktree, worktrees } = await fixture({ tasks: TASKS.replace(/\n/g, "\r\n") }); + await setTaskDone({ + repositoryRoot: "/repo", changeName: "demo", git: { worktreeList: async () => worktrees }, ...quiet, + lineNumber: 3, expectedText: "1.2 **Human-only**: see it in the Pipeline, with", done: true, note: "seen", by: "ada", now, + }); + const text = await readFile(path.join(worktree, "openspec", "changes", "demo", "tasks.md"), "utf8"); + expect(text.includes("\r\n")).toBe(true); + expect(/[^\r]\n/.test(text)).toBe(false); + }); +}); + +describe("readChangeTaskRows", () => { + it("reads the own worktree's rows whole, with their lines", async () => { + const { worktree, worktrees } = await fixture(); + const rows = await readChangeTaskRows({ repositoryRoot: "/repo", changeName: "demo", git: { worktreeList: async () => worktrees } }); + expect(rows).toMatchObject({ source: "own-worktree", path: worktree }); + expect(rows.ok === false ? [] : (rows as { rows: unknown[] }).rows).toEqual([ + { number: "1.1", text: "Write the module.", section: "Work", lineNumber: 2, done: false, closedBy: "agent" }, + { + number: "1.2", + text: "**Human-only**: see it in the Pipeline, with", + section: "Work", + lineNumber: 3, + body: "the card open.\n\nAn earlier remark after a blank line.", + done: false, + closedBy: "person", + }, + { number: "1.3", text: "Already done.", section: "Work", lineNumber: 7, done: true, closedBy: "agent" }, + ]); + }); + + it("falls back to this checkout's copy, read-only, where there is no worktree of its own", async () => { + const { root } = await fixture({ branch: "other" }); + const here = path.join(root, "main"); + await mkdir(path.join(here, "openspec", "changes", "demo"), { recursive: true }); + await writeFile(path.join(here, "openspec", "changes", "demo", "tasks.md"), "- [ ] 1.1 Here.\n", "utf8"); + const rows = await readChangeTaskRows({ + repositoryRoot: here, + changeName: "demo", + git: { worktreeList: async () => [{ path: here, branch: "main" }] }, + }); + expect(rows).toMatchObject({ source: "this-checkout", path: path.resolve(here) }); + }); +}); + +describe("runOwnDelegatedItem", () => { + it("refuses a change with no worktree of its own, and one a run is working in, before any runner is asked for", async () => { + const { worktree, worktrees } = await fixture(); + const runnersFor = vi.fn(() => () => undefined); + expect(await runOwnDelegatedItem({ repositoryRoot: "/repo", changeName: "nope", git: { worktreeList: async () => worktrees }, lineNumber: 3, runnersFor })) + .toMatchObject({ ok: false, kind: "no-worktree" }); + expect(await runOwnDelegatedItem({ + repositoryRoot: "/repo", changeName: "demo", git: { worktreeList: async () => worktrees }, lineNumber: 3, runnersFor, + readLease: async () => undefined, + readStatuses: async () => [{ instanceId: "i1", changeName: "demo", workingDirectory: worktree, gone: false, activity: "x" } as AgentStatusReport], + })).toMatchObject({ ok: false, kind: "run-working" }); + expect(runnersFor).not.toHaveBeenCalled(); + }); +}); + +describe("taskListCommitMessage", () => { + it("names the tasks ticked and reopened", () => { + const diff = ["-- [ ] 6.4 Human-only", "+- [x] 6.4 Human-only", "-- [x] 2.1 Something", "+- [ ] 2.1 Something", "+ Closed by ada"].join("\n"); + expect(taskListCommitMessage("demo", diff)).toBe("tasks(demo): ticked 6.4; reopened 2.1"); + expect(taskListCommitMessage("demo", "+ a note only")).toBe("tasks(demo): update the task list"); + }); +}); diff --git a/packages/core/src/own-worktree-tasks.ts b/packages/core/src/own-worktree-tasks.ts new file mode 100644 index 00000000..b779b094 --- /dev/null +++ b/packages/core/src/own-worktree-tasks.ts @@ -0,0 +1,431 @@ +// What a card may do in its change's own worktree, and nowhere else — +// a-card-works-its-own-tasks, ADR 0026 amended 2026-10-05. +// +// A change is worked in the worktree made for it, and until its pull +// request merges it usually exists only there. A person closing a +// Human-only item had to find that worktree's `tasks.md` by hand, edit it, +// commit it and push it. These three functions do that for one change: +// find its own worktree, tick or untick one task with a note under it, and +// commit that `tasks.md` alone and push the branch. +// +// Every one of them takes the change's name and resolves the directory +// itself from `git worktree list`. A path from a request is never written +// to: a request is data from a page, and a change is the pair of a +// directory and a name (ADR 0026). + +import { randomUUID } from "node:crypto"; +import { access, readFile, rename, rm, writeFile } from "node:fs/promises"; +import path from "node:path"; +import simpleGit from "simple-git"; + +import type { AgentRunner } from "./agent-runner.js"; +import { readAgentStatuses, resolveAgentStatusDirectory, type AgentStatusReport } from "./agent-status.js"; +import { runDelegatedItem, type DelegatedItemRunResult } from "./delegated-item-run.js"; +import type { Event } from "./protocol.js"; +import { FileAuditLog, auditLogPath, type AuditLog } from "./security.js"; +import { changeOfWorktree } from "./change-worktrees.js"; +import { createGitWrapper, type GitWrapper } from "./git.js"; +import { isValidChangeName } from "./change-name.js"; +import { parseTaskChecklist, taskNumberOf, TASK_CHECKBOX_LINE_RE, type TaskChecklistItem } from "./task-checklist.js"; +import { readWorkspaceLeaseHolder, type WorkspaceLeaseConflict } from "./workspace-lease.js"; + +/** Why an action on a change's own worktree was not taken. A field rather + * than a phrase, so a surface can say more where it knows more; `reason` is + * always the sentence to show. */ +export type OwnWorktreeRefusalKind = + | "no-worktree" + | "no-task-list" + | "run-working" + | "line-changed" + | "not-a-task" + | "note-required" + | "nothing-to-commit" + | "default-branch" + | "detached-head" + | "push-rejected"; + +export interface OwnWorktreeRefusal { + ok: false; + kind: OwnWorktreeRefusalKind; + reason: string; +} + +export interface OwnWorktree { + ok: true; + /** The worktree's directory, as git lists it. */ + path: string; + branch: string; + /** The change's `tasks.md` in that worktree. */ + tasksPath: string; + /** The main checkout's branch: the one a commit here is never made on. */ + mainBranch?: string; +} + +export interface OwnWorktreeOptions { + repositoryRoot: string; + changeName: string; + /** Test seam; production lists the repository's worktrees. */ + git?: Pick; +} + +function refuse(kind: OwnWorktreeRefusalKind, reason: string): OwnWorktreeRefusal { + return { ok: false, kind, reason }; +} + +async function exists(filePath: string): Promise { + try { + await access(filePath); + return true; + } catch { + return false; + } +} + +/** The change's own worktree: a working directory of this repository, not + * the main one, on the branch named after the change, holding the change's + * `tasks.md`. It need not be active in the main checkout — a change usually + * is not until its pull request merges. */ +export async function resolveOwnWorktree(options: OwnWorktreeOptions): Promise { + const { changeName } = options; + if (!isValidChangeName(changeName)) return refuse("no-worktree", `"${changeName}" is not a change name.`); + let worktrees; + try { + // Made inside the guard: `simple-git` refuses a directory that does not + // exist as it is constructed, not when it is asked. + const git = options.git ?? createGitWrapper({ cwd: options.repositoryRoot }); + worktrees = await git.worktreeList(); + } catch { + // Not a git repository, or no git: there is then no worktree of the + // change's own, which is a true statement rather than a failure. + return refuse("no-worktree", `${changeName} has no worktree of its own: 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 refuse("no-worktree", `${changeName} has no worktree of its own, so nothing here acts on it.`); + } + const tasksPath = path.join(own.path, "openspec", "changes", changeName, "tasks.md"); + if (!(await exists(tasksPath))) { + return refuse("no-task-list", `${changeName}'s worktree, ${own.path}, has no openspec/changes/${changeName}/tasks.md.`); + } + const mainBranch = worktrees[0]?.branch; + return { ok: true, path: own.path, branch: own.branch, tasksPath, ...(mainBranch !== undefined ? { mainBranch } : {}) }; +} + +function pathKey(value: string): string { + const resolved = path.resolve(value); + return process.platform === "win32" ? resolved.toLowerCase() : resolved; +} + +function describeHolder(holder: WorkspaceLeaseConflict): string { + return `a ${holder.hostKind} run on ${holder.hostname} (pid ${holder.pid})${holder.author !== undefined ? `, ${holder.author}'s` : ""}`; +} + +export interface RunWorkingOptions { + /** Test seams: the lease holder and the runs' records, in place of + * reading them. */ + readLease?: (worktreePath: string) => Promise; + readStatuses?: () => Promise; +} + +/** Who is working in the worktree, or `undefined`. A run holding its lease, + * or a live record naming the change in that directory, may be editing the + * very file a tick would write. */ +async function runWorkingIn( + worktree: OwnWorktree, + options: OwnWorktreeOptions & RunWorkingOptions, +): Promise { + const holder = await (options.readLease ?? ((root: string) => readWorkspaceLeaseHolder(root)))(worktree.path); + if (holder !== undefined) return describeHolder(holder); + const statuses = await (options.readStatuses ?? (async () => { + try { + const directory = await resolveAgentStatusDirectory(createGitWrapper({ cwd: options.repositoryRoot }), options.repositoryRoot); + return (await readAgentStatuses(directory)).reports; + } catch { + return []; + } + }))(); + const record = statuses.find((report) => !report.gone + && report.changeName === options.changeName + && pathKey(report.workingDirectory) === pathKey(worktree.path)); + return record === undefined ? undefined : `run ${record.instanceId}, which says "${record.activity}"`; +} + +export interface SetTaskDoneOptions extends OwnWorktreeOptions, RunWorkingOptions { + /** The task's line in `tasks.md`, counted from 0. */ + lineNumber: number; + /** The task's text as it was shown. A line that no longer reads so is + * not written: the list changed under the person. */ + expectedText: string; + done: boolean; + note?: string; + /** Who is closing or reopening it, as the note says. Read from the + * worktree's git identity where not given: a claim, never an identity. */ + by?: string; + now?: () => Date; +} + +export interface SetTaskDoneResult { + ok: true; + tasksPath: string; + /** The line as written. */ + line: string; + /** The note line written under the task, where there was a note. */ + noteLine?: string; +} + +function localDate(date: Date): string { + const pad = (value: number): string => String(value).padStart(2, "0"); + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`; +} + +/** Ticks or unticks one task in the change's own worktree, with a note + * under it. Changes the checkbox and nothing else on its line. */ +export async function setTaskDone(options: SetTaskDoneOptions): Promise { + const worktree = await resolveOwnWorktree(options); + if (!worktree.ok) return worktree; + + const working = await runWorkingIn(worktree, options); + if (working !== undefined) { + return refuse("run-working", `${options.changeName}'s worktree is being worked by ${working}; its task list is left alone until that run ends.`); + } + + const raw = await readFile(worktree.tasksPath, "utf8"); + const eol = raw.includes("\r\n") ? "\r\n" : "\n"; + const lines = raw.split(/\r?\n/); + const line = lines[options.lineNumber]; + const match = line === undefined ? null : TASK_CHECKBOX_LINE_RE.exec(line); + if (line === undefined || match === null) { + return refuse("not-a-task", `Line ${options.lineNumber + 1} of ${options.changeName}'s tasks.md is not a task.`); + } + // Compared without the leading number: a card shows a task's number and + // its text apart, and either is enough to say the line is the one shown. + const withoutNumber = (text: string): string => text.trim().replace(/^\d+(?:\.\d+)*\.?\s*/u, ""); + if (withoutNumber(match[2] ?? "") !== withoutNumber(options.expectedText)) { + return refuse("line-changed", `${options.changeName}'s tasks.md has changed since it was shown; read it again and try again.`); + } + + const item = parseTaskChecklist(raw).find((candidate) => candidate.lineNumber === options.lineNumber) as TaskChecklistItem; + const note = options.note?.trim() ?? ""; + if (options.done && note.length === 0 && (item.humanOnly === true || item.delegatedTo !== undefined)) { + return refuse( + "note-required", + `${taskNumberOf(item.text) ?? "This task"} is ${item.humanOnly === true ? "Human-only" : `delegated to ${item.delegatedTo}`}: say what was checked, so the note can be written under it.`, + ); + } + + const written = line.replace(/\[[ xX]\]/, options.done ? "[x]" : "[ ]"); + lines[options.lineNumber] = written; + + let noteLine: string | undefined; + if (note.length > 0) { + const by = options.by ?? (await createGitWrapper({ cwd: worktree.path }).configuredIdentity().catch(() => undefined)) ?? "a person"; + const when = localDate((options.now ?? (() => new Date()))()); + const indent = `${/^[ \t]*/.exec(line)?.[0] ?? ""} `; + noteLine = `${indent}${options.done ? "Closed" : "Reopened"} by ${by} on ${when}: ${note.replace(/\s+/g, " ")}`; + // Under the lines that carry the item on, before any blank line: the + // merge gate reads what continues an item, and a note after a blank + // line would not count as written under it. + let at = options.lineNumber + 1; + while (at < lines.length && /^[ \t]+(?![-*+][ \t])\S/.test(lines[at] ?? "")) at += 1; + lines.splice(at, 0, noteLine); + } + + const temporary = `${worktree.tasksPath}.${randomUUID()}.tmp`; + try { + await writeFile(temporary, lines.join(eol), "utf8"); + await rename(temporary, worktree.tasksPath); + } finally { + await rm(temporary, { force: true }).catch(() => undefined); + } + return { ok: true, tasksPath: worktree.tasksPath, line: written, ...(noteLine !== undefined ? { noteLine } : {}) }; +} + +export interface CommitTaskListResult { + ok: true; + commit: string; + branch: string; + /** Where it was pushed: `/`. */ + pushedTo: string; + message: string; +} + +export interface CommitTaskListRefusal extends OwnWorktreeRefusal { + /** The commit made before a push was refused, which stays. */ + commit?: string; +} + +/** The commit's message from what changed in the task list: the numbers + * ticked and reopened, which is what a log reader wants to know. */ +export function taskListCommitMessage(changeName: string, diff: string): string { + const ticked: string[] = []; + const reopened: string[] = []; + for (const line of diff.split(/\r?\n/)) { + const added = /^\+[ \t]*-\s\[([ xX])\]\s*(\d+(?:\.\d+)*)/.exec(line); + if (added === null) continue; + ((added[1] ?? "").toLowerCase() === "x" ? ticked : reopened).push(added[2] ?? ""); + } + const parts = [ + ...(ticked.length > 0 ? [`ticked ${ticked.join(", ")}`] : []), + ...(reopened.length > 0 ? [`reopened ${reopened.join(", ")}`] : []), + ]; + return `tasks(${changeName}): ${parts.length > 0 ? parts.join("; ") : "update the task list"}`; +} + +function gitMessage(error: unknown): string { + return (error instanceof Error ? error.message : String(error)).trim(); +} + +/** Commits the change's `tasks.md` alone in its own worktree and pushes the + * worktree's branch. Nothing else in the worktree is staged or committed. */ +export async function commitTaskList( + options: OwnWorktreeOptions, +): Promise { + const worktree = await resolveOwnWorktree(options); + if (!worktree.ok) return worktree; + const { branch } = worktree; + if (branch === worktree.mainBranch || branch === "main" || branch === "master") { + return refuse("default-branch", `${options.changeName}'s worktree is on ${branch}, the default branch; a task list is never committed there.`); + } + + const git = simpleGit(worktree.path); + const relative = path.relative(worktree.path, worktree.tasksPath).split(path.sep).join("/"); + const head = (await git.raw(["rev-parse", "--abbrev-ref", "HEAD"])).trim(); + if (head === "HEAD") return refuse("detached-head", `${options.changeName}'s worktree has no branch checked out.`); + + const diff = await git.raw(["diff", "HEAD", "--", relative]); + if (diff.trim().length === 0) { + return refuse("nothing-to-commit", `${options.changeName}'s tasks.md has nothing to commit.`); + } + const message = taskListCommitMessage(options.changeName, diff); + // `--only` with the path: the commit holds this file, whatever else the + // index holds, and leaves the rest of the index as it was. + await git.raw(["commit", "--only", "-m", message, "--", relative]); + const commit = (await git.raw(["rev-parse", "HEAD"])).trim(); + + const upstreams = await createGitWrapper({ cwd: worktree.path }).branchUpstreams(); + const upstream = upstreams.find((entry) => entry.branch === branch)?.upstream; + const remote = upstream !== undefined && upstream.includes("/") ? upstream.slice(0, upstream.indexOf("/")) : "origin"; + try { + await git.raw(upstream !== undefined ? ["push", remote, branch] : ["push", "-u", remote, branch]); + } catch (error) { + return { ...refuse("push-rejected", `Committed ${commit.slice(0, 8)}, but the push was refused: ${gitMessage(error)}`), commit }; + } + return { ok: true, commit, branch, pushedTo: `${remote}/${branch}`, message }; +} + +/** A change's task rows, for a page that shows them whole: from its own + * worktree, where the card's controls act, or from this checkout's copy, + * read-only, where it has no worktree of its own. */ +export interface ChangeTaskRows { + ok: true; + changeName: string; + /** Where they were read. `ownWorktree` is the only place anything is + * offered. */ + source: "own-worktree" | "this-checkout"; + path: string; + tasksPath: string; + rows: TaskRowFacts[]; +} + +/** One task as the page shows it. The shape the survey's rows have, so a + * card and the page render one task one way. */ +export interface TaskRowFacts { + number?: string; + text: string; + section?: string; + lineNumber: number; + body?: string; + done: boolean; + closedBy: "agent" | "person" | "named-agent"; + agent?: string; +} + +function rowOf(item: TaskChecklistItem): TaskRowFacts { + const number = taskNumberOf(item.text); + return { + ...(number !== undefined ? { number } : {}), + text: number === undefined ? item.text.trim() : item.text.trim().replace(/^\d+(?:\.\d+)*\.?\s*/u, ""), + ...(item.section !== undefined ? { section: item.section } : {}), + lineNumber: item.lineNumber, + ...(item.body !== undefined ? { body: item.body } : {}), + done: item.done, + ...(item.humanOnly === true + ? { closedBy: "person" as const } + : item.delegatedTo !== undefined + ? { closedBy: "named-agent" as const, agent: item.delegatedTo } + : { closedBy: "agent" as const }), + }; +} + +export async function readChangeTaskRows(options: OwnWorktreeOptions): Promise { + const own = await resolveOwnWorktree(options); + if (own.ok) { + return { + ok: true, + changeName: options.changeName, + source: "own-worktree", + path: own.path, + tasksPath: own.tasksPath, + rows: parseTaskChecklist(await readFile(own.tasksPath, "utf8")).map(rowOf), + }; + } + if (own.kind !== "no-worktree") return own; + const here = path.join(path.resolve(options.repositoryRoot), "openspec", "changes", options.changeName, "tasks.md"); + if (!isValidChangeName(options.changeName) || !(await exists(here))) { + return refuse("no-task-list", `${options.changeName} has no tasks.md here or in a worktree of its own.`); + } + return { + ok: true, + changeName: options.changeName, + source: "this-checkout", + path: path.resolve(options.repositoryRoot), + tasksPath: here, + rows: parseTaskChecklist(await readFile(here, "utf8")).map(rowOf), + }; +} + +export interface OwnDelegatedRunOptions extends OwnWorktreeOptions, RunWorkingOptions { + lineNumber: number; + /** The runners for a working directory. A host builds them for the own + * worktree as `openspec-ui-cli run --cwd` does, so the run's allowlist, + * sandbox and audit are that directory's. */ + runnersFor: (workspaceRoot: string) => (agentId: string) => AgentRunner | undefined; + auditLog?: AuditLog; + onEvent?: (event: Event) => void; +} + +/** Runs the agent a delegated task names, on that task, in the change's own + * worktree: the same `runDelegatedItem` the inbox uses, pointed at the + * worktree. */ +export async function runOwnDelegatedItem(options: OwnDelegatedRunOptions): Promise { + const worktree = await resolveOwnWorktree(options); + if (!worktree.ok) return worktree; + const working = await runWorkingIn(worktree, options); + if (working !== undefined) { + return refuse("run-working", `${options.changeName}'s worktree is being worked by ${working}; start this when that run ends.`); + } + return runDelegatedItem({ + workspaceRoot: worktree.path, + changeName: options.changeName, + lineNumber: options.lineNumber, + resolveRunner: options.runnersFor(worktree.path), + // The worktree's own log, where its runners record the run: a request + // and its reply belong beside the run they were about. + auditLog: options.auditLog ?? new FileAuditLog(auditLogPath(worktree.path)), + ...(options.onEvent !== undefined ? { onEvent: options.onEvent } : {}), + }); +} + +/** The line of task `` in the change's own worktree, with its text, + * for a caller that names a task by number (the terminal). */ +export async function findOwnTask( + options: OwnWorktreeOptions & { number: string }, +): Promise<{ ok: true; item: TaskChecklistItem } | OwnWorktreeRefusal> { + const worktree = await resolveOwnWorktree(options); + if (!worktree.ok) return worktree; + const item = parseTaskChecklist(await readFile(worktree.tasksPath, "utf8")) + .find((candidate) => taskNumberOf(candidate.text) === options.number); + return item === undefined + ? refuse("not-a-task", `${options.changeName}'s tasks.md has no task ${options.number}.`) + : { ok: true, item }; +} diff --git a/packages/core/src/task-checklist.test.ts b/packages/core/src/task-checklist.test.ts index acfdd307..d1a4f853 100644 --- a/packages/core/src/task-checklist.test.ts +++ b/packages/core/src/task-checklist.test.ts @@ -18,7 +18,9 @@ import { getArchivedChangeSummary, delegatedAgentFor, isHumanOnlyTask, + parseTaskChecklist, readTaskChecklist, + TASK_BODY_LIMIT, } from "./task-checklist.js"; import { discoverOpenSpecWorkspace } from "./workbench.js"; @@ -130,6 +132,22 @@ describe("readTaskChecklist", () => { ["1.3 Wrapped over", "two more lines"], ]); expect(items[1]).not.toHaveProperty("continued"); + // a-card-works-its-own-tasks 2.2: the body is everything indented under + // an item, the record after a blank line and a nested list included, + // with its line breaks; a heading ends it. + expect(items.map((item) => item.body)).toEqual([ + "package's count.\n\nDone: every package passes.", + "- a nested note is not the sentence", + "two more\nlines", + ]); + }); + + it("cuts a body at its limit and says so (a-card-works-its-own-tasks)", () => { + const long = "x".repeat(TASK_BODY_LIMIT + 50); + const [item] = parseTaskChecklist(`- [ ] 1.1 A task\n ${long}\n`); + expect(item?.body?.length).toBeLessThan(TASK_BODY_LIMIT + 60); + expect(item?.body).toMatch(/cut at 4000 characters\)$/); + expect(parseTaskChecklist("- [ ] 1.1 Alone\n- [ ] 1.2 Next\n")[0]).not.toHaveProperty("body"); }); it("reads archived changes from openspec/changes/archive//", async () => { diff --git a/packages/core/src/task-checklist.ts b/packages/core/src/task-checklist.ts index 819ae01c..7ffcf74c 100644 --- a/packages/core/src/task-checklist.ts +++ b/packages/core/src/task-checklist.ts @@ -55,6 +55,13 @@ export interface TaskChecklistItem { * every marker and declaration is read from * (the-change-timeline-looks-like-the-mockup). */ continued?: string; + /** Everything indented under the item, up to the next line that is not + * indented: the wrapped sentence and any record written under it, blank + * lines inside included. Dedented, with its line breaks, and cut at + * `TASK_BODY_LIMIT`. Absent for an item with nothing under it. For + * showing a task whole (a-card-works-its-own-tasks); nothing decides on + * it, and `continued` stays what every rule reads. */ + body?: string; /** How a closed item ended (a-change-lands-with-nothing-open). * Absent for an open item; `done` for a closed one that declares * nothing, which is how every item written before this existed @@ -359,15 +366,49 @@ export function parseTaskChecklist(content: string): TaskChecklistItem[] { return parseChecklist(content); } +/** The most of a task's body a reading carries. A record under a task is a + * paragraph or two; a body longer than this is cut and says so, so one + * task cannot make every reading of the board heavy. */ +export const TASK_BODY_LIMIT = 4_000; + +/** A body's lines as one text: blank lines at either end dropped, the + * indent they share removed, line breaks kept. `undefined` for none. */ +function taskBodyOf(lines: readonly string[]): string | undefined { + let start = 0; + let end = lines.length; + while (start < end && (lines[start] ?? "").trim().length === 0) start += 1; + while (end > start && (lines[end - 1] ?? "").trim().length === 0) end -= 1; + // A leading tab counts as two spaces, the indent `tasks.md` is written + // with, so a body indented both ways still shares one indent. + const kept = lines.slice(start, end).map((line) => line.replace(/^[ \t]+/, (lead) => lead.replace(/\t/g, " "))); + if (kept.length === 0) return undefined; + const indents = kept.filter((line) => line.trim().length > 0).map((line) => /^[ \t]*/.exec(line)?.[0].length ?? 0); + const shared = Math.min(...indents); + const text = kept.map((line) => (line.trim().length === 0 ? "" : line.slice(shared))).join("\n"); + return text.length <= TASK_BODY_LIMIT ? text : `${text.slice(0, TASK_BODY_LIMIT)}\n… (cut at ${TASK_BODY_LIMIT} characters)`; +} + function parseChecklist(content: string): TaskChecklistItem[] { const items: TaskChecklistItem[] = []; let section: string | undefined; /** The item the lines being read still carry on, until one does not. */ let continuing: TaskChecklistItem | undefined; + /** The item whose body the lines being read belong to, and those lines: + * everything indented under it, blank lines inside included, until a + * line that is not indented (a-card-works-its-own-tasks). */ + let owning: { item: TaskChecklistItem; lines: string[] } | undefined; + const closeBody = (): void => { + if (owning !== undefined) { + const body = taskBodyOf(owning.lines); + if (body !== undefined) owning.item.body = body; + } + owning = undefined; + }; content.split(/\r?\n/).forEach((line, lineNumber) => { const heading = SECTION_HEADING_RE.exec(line); if (heading) { continuing = undefined; + closeBody(); const title = (heading[1] ?? "").replace(SECTION_NUMBER_RE, "").trim(); section = title.length > 0 ? title : undefined; return; @@ -380,8 +421,13 @@ function parseChecklist(content: string): TaskChecklistItem[] { } else { continuing = undefined; } + if (owning !== undefined) { + if (line.trim().length === 0 || /^[ \t]/.test(line)) owning.lines.push(line); + else closeBody(); + } return; } + closeBody(); const text = (match[2] ?? "").trim(); const done = (match[1] ?? "").toLowerCase() === "x"; const check = parseTaskCheckDeclaration(text); @@ -399,7 +445,9 @@ function parseChecklist(content: string): TaskChecklistItem[] { } items.push(item); continuing = item; + owning = { item, lines: [] }; }); + closeBody(); // The ending is read once the continued lines are on the item: a // waiver or a deferral is usually written under the item rather than // on its line. diff --git a/packages/core/src/worktree-survey-facts.ts b/packages/core/src/worktree-survey-facts.ts index 1f693580..9238d851 100644 --- a/packages/core/src/worktree-survey-facts.ts +++ b/packages/core/src/worktree-survey-facts.ts @@ -29,6 +29,12 @@ export interface SurveyedTask { closedBy: "agent" | "person" | "named-agent"; /** The agent a `named-agent` item is delegated to. */ agent?: string; + /** The item's line in `tasks.md`, counted from 0: what a control acting + * on this one task names (a-card-works-its-own-tasks). */ + lineNumber?: number; + /** Everything written under the item: the rest of its sentence and any + * record under it, with its line breaks (a-card-works-its-own-tasks). */ + body?: string; } /** One change in one working directory's own queue. @@ -129,6 +135,12 @@ interface SurveyedDirectoryBase { * above, read from here, and is not drawn a second time among this * directory's changes (ADR 0029). */ belongsTo?: string; + /** The change this directory was made for, by its branch, whether or not + * that change is active in the main working directory yet: a change is + * usually only here until its pull request merges. A card for that change + * read from here is the change's own and may act on it (ADR 0026, + * amended 2026-10-05; a-card-works-its-own-tasks). */ + ownChange?: string; /** What the runs reporting from this directory say. Empty means no run * this product started reports here — never that nobody is in it: a * person editing, or an agent started some other way, writes no record. */ diff --git a/packages/core/src/worktree-survey.test.ts b/packages/core/src/worktree-survey.test.ts index 690100ff..99c68d75 100644 --- a/packages/core/src/worktree-survey.test.ts +++ b/packages/core/src/worktree-survey.test.ts @@ -291,14 +291,37 @@ describe("surveyWorktrees — what a card needs from a task list (a-card-says-wh const [directory] = survey.directories; const change = directory?.readable ? directory.changes.find((candidate) => candidate.changeName === "rowed") : undefined; + // Each with its line, which a card's control names + // (a-card-works-its-own-tasks). expect(change?.tasks).toEqual([ - { text: "An item before any heading", done: false, closedBy: "agent" }, - { number: "1.1", text: "Done already", section: "Reading", done: true, closedBy: "agent" }, - { number: "1.2", text: "**Human-only**: look at it", section: "Reading", done: false, closedBy: "person" }, - { number: "2.1", text: "**Delegated to claude-cli**: check it", section: "Checking", done: false, closedBy: "named-agent", agent: "claude-cli" }, + { text: "An item before any heading", lineNumber: 0, done: false, closedBy: "agent" }, + { number: "1.1", text: "Done already", section: "Reading", lineNumber: 2, done: true, closedBy: "agent" }, + { number: "1.2", text: "**Human-only**: look at it", section: "Reading", lineNumber: 3, done: false, closedBy: "person" }, + { number: "2.1", text: "**Delegated to claude-cli**: check it", section: "Checking", lineNumber: 5, done: false, closedBy: "named-agent", agent: "claude-cli" }, ]); }); + // a-card-works-its-own-tasks 2.2: a task's body travels with it, and a + // worktree made for a change is marked its own even where the change is + // not active in the main checkout yet. + it("carries a task's body, and marks a change's own worktree before the change reaches main", async () => { + const { main, rootSources } = await repository(); + const worktree = path.join(path.dirname(main), "wt-fresh"); + const dir = path.join(worktree, "openspec", "changes", "fresh"); + await mkdir(dir, { recursive: true }); + await writeFile(path.join(dir, ".openspec.yaml"), `schema: spec-driven${LF}`, "utf8"); + await writeFile(path.join(dir, "tasks.md"), ["- [x] 1.1 **Human-only**: look", " Closed by ada on 2026-10-05: seen.", ""].join(LF), "utf8"); + const { git } = recordingGit([{ path: main, branch: "main" }, { path: worktree, branch: "fresh" }]); + + const survey = await surveyWorktrees({ workspaceRoot: main, git, rootSources }); + + const own = survey.directories.find((directory) => directory.path === worktree); + expect(own).toMatchObject({ ownChange: "fresh" }); + expect(own).not.toHaveProperty("belongsTo"); + const change = own?.readable ? own.changes.find((candidate) => candidate.changeName === "fresh") : undefined; + expect(change?.tasks?.[0]).toMatchObject({ lineNumber: 0, body: "Closed by ada on 2026-10-05: seen." }); + }); + it("carries none of the four for a change with no task list", async () => { const { main, rootSources } = await repository(); const dir = path.join(main, "openspec", "changes", "listless"); diff --git a/packages/core/src/worktree-survey.ts b/packages/core/src/worktree-survey.ts index 1b36c5da..80ecaf71 100644 --- a/packages/core/src/worktree-survey.ts +++ b/packages/core/src/worktree-survey.ts @@ -161,6 +161,8 @@ function surveyedTask(item: TaskChecklistItem): SurveyedTask { ...(number !== undefined ? { number } : {}), text, ...(item.section !== undefined ? { section: item.section } : {}), + lineNumber: item.lineNumber, + ...(item.body !== undefined ? { body: item.body } : {}), done: item.done, ...(item.humanOnly === true ? { closedBy: "person" as const } @@ -361,6 +363,7 @@ export async function surveyWorktrees(options: WorktreeSurveyOptions): Promise | undefined; +/** The runners for another working directory of this repository, built as + * `runners` is, for that root: a change's own worktree, where a card runs a + * delegated task (a-card-works-its-own-tasks). */ +let runnersFor: ((root: string) => Map) | undefined; let auditLog: FileAuditLog | undefined; let optionalServer: OptionalServerManager | undefined; @@ -536,13 +540,25 @@ export async function activate(context: vscode.ExtensionContext): Promise("baseUrl", "").trim(); const localLlmModel = localLlm.get("model", "").trim(); const localLlmApiKey = await context.secrets.get(LOCAL_LLM_API_KEY_SECRET).then((value) => value, () => undefined); + const localLlmSettings = { + ...(localLlmBaseUrl.length > 0 ? { localLlmBaseUrl } : {}), + ...(localLlmModel.length > 0 ? { localLlmModel } : {}), + ...(localLlmApiKey !== undefined && localLlmApiKey.length > 0 ? { localLlmApiKey } : {}), + }; runners = buildDefaultAgentRunners({ workspaceRoot, auditLog, runLogs: createFileRunLogs(workspaceRoot), - ...(localLlmBaseUrl.length > 0 ? { localLlmBaseUrl } : {}), - ...(localLlmModel.length > 0 ? { localLlmModel } : {}), - ...(localLlmApiKey !== undefined && localLlmApiKey.length > 0 ? { localLlmApiKey } : {}), + ...localLlmSettings, + ...readAgentSwitches(), + }); + // A worktree records its runs in its own log, as `openspec-ui-cli run + // --cwd` does there. + runnersFor = (root) => buildDefaultAgentRunners({ + workspaceRoot: root, + auditLog: new FileAuditLog(auditLogPath(root)), + runLogs: createFileRunLogs(root), + ...localLlmSettings, ...readAgentSwitches(), }); context.subscriptions.push( @@ -816,6 +832,9 @@ export async function activate(context: vscode.ExtensionContext): Promise runnersFor?.(root) ?? new Map(), }); context.subscriptions.push( vscode.commands.registerCommand("openspec-ui.openPipeline", () => pipelinePanel.show()), diff --git a/packages/extension/src/optional-server.ts b/packages/extension/src/optional-server.ts index 37bfcb3e..e31b365f 100644 --- a/packages/extension/src/optional-server.ts +++ b/packages/extension/src/optional-server.ts @@ -45,6 +45,14 @@ export class OptionalServerManager { port: 0, auditLog, runners: buildDefaultAgentRunners({ workspaceRoot: this.workspaceRoot, auditLog, runLogs: createFileRunLogs(this.workspaceRoot), ...this.agentSwitches }), + // A change's own worktree, where a card runs a delegated task, records + // in its own log (a-card-works-its-own-tasks). + runnersFor: (root: string) => buildDefaultAgentRunners({ + workspaceRoot: root, + auditLog: new FileAuditLog(auditLogPath(root)), + runLogs: createFileRunLogs(root), + ...this.agentSwitches, + }), staticAssets: { indexHtmlPath: path.join(this.distDir, "standalone", "index.html"), appJsPath: path.join(this.distDir, "standalone", "app.js"), diff --git a/packages/extension/src/test-utils/vscode-mock.ts b/packages/extension/src/test-utils/vscode-mock.ts index ac4d5ec2..0a0863a5 100644 --- a/packages/extension/src/test-utils/vscode-mock.ts +++ b/packages/extension/src/test-utils/vscode-mock.ts @@ -37,6 +37,14 @@ export class Selection { constructor(public readonly anchor: unknown, public readonly active: unknown) { } } +export class Position { + constructor(public readonly line: number, public readonly character: number) { } +} + +export class Range { + constructor(public readonly start: Position, public readonly end: Position) { } +} + export class EventEmitter { private listeners: Array<(e: T) => void> = []; readonly event = (listener: (e: T) => void) => { @@ -116,6 +124,8 @@ export function createVscodeMock() { ColorThemeKind, TextEditorRevealType, Selection, + Position, + Range, EventEmitter, ThemeIcon, ThemeColor, @@ -197,6 +207,7 @@ export function createVscodeMock() { }, env: { openExternal: vi.fn(async () => true), + clipboard: { writeText: vi.fn(async (_text: string) => undefined) }, }, _documentContents: documentContents, commands: { diff --git a/packages/extension/src/webview/pipeline-panel.test.ts b/packages/extension/src/webview/pipeline-panel.test.ts index dd11f279..517dd3c5 100644 --- a/packages/extension/src/webview/pipeline-panel.test.ts +++ b/packages/extension/src/webview/pipeline-panel.test.ts @@ -121,6 +121,12 @@ function createPipelinePanel(overrides: { refreshRuns: vi.fn(async (survey: unknown) => survey), statusDirectory: vi.fn(overrides.statusDirectory ?? (async () => "/wt/repo/.agent-status")), findActiveChange: vi.fn(async (_root: string, name: string) => (name === "alpha" ? ACTIVE_CHANGE : undefined)), + // `beta` is worked in its own worktree and is not in this checkout at + // all, as a change is until its pull request merges + // (a-card-works-its-own-tasks). + ownWorktree: vi.fn(async (_root: string, name: string) => (name === "beta" + ? { ok: true as const, path: "/wt/beta", branch: "beta", tasksPath: "/wt/beta/openspec/changes/beta/tasks.md" } + : { ok: false as const, kind: "no-worktree" as const, reason: `${name} has no worktree of its own` })), standingsNow: vi.fn(async () => ({ readAt: "2026-09-14T00:00:00.000Z", standings: [], @@ -257,6 +263,26 @@ describe("PipelinePanel — answering the view", () => { expect(post).toHaveBeenCalledWith(expect.objectContaining({ id: "g:3", ok: false, error: "no log is kept for run never-ran" })); }); + // a-card-works-its-own-tasks 3.2: a card's task controls, refused by + // shape before anything is looked up, and answered with core's refusal + // where the change has no worktree of its own. + it("refuses a task control that names no change or no line, and answers core's refusal for one with no worktree", async () => { + const { pipeline } = createPipelinePanel(); + pipeline.show(); + + await pipeline.deliverMessageForTesting({ type: "openspec-ui/request", id: "t:0", op: "pipeline/task-set", args: { changeName: "../x", lineNumber: 1, expectedText: "a", done: true } }); + await pipeline.deliverMessageForTesting({ type: "openspec-ui/request", id: "t:1", op: "pipeline/task-commit", args: {} }); + await pipeline.deliverMessageForTesting({ type: "openspec-ui/request", id: "t:2", op: "pipeline/task-run", args: { changeName: "alpha", lineNumber: 1 } }); + await pipeline.deliverMessageForTesting({ type: "openspec-ui/request", id: "t:3", op: "pipeline/task-commit", args: { changeName: "alpha" } }); + + const post = created[0]!.webview.postMessage; + expect(post).toHaveBeenCalledWith(expect.objectContaining({ id: "t:0", ok: false })); + expect(post).toHaveBeenCalledWith(expect.objectContaining({ id: "t:1", ok: false, error: "a change name is required" })); + // No runners were given to this panel, so none is started. + expect(post).toHaveBeenCalledWith(expect.objectContaining({ id: "t:2", ok: false })); + expect(post).toHaveBeenCalledWith(expect.objectContaining({ id: "t:3", ok: true, value: expect.objectContaining({ ok: false, kind: "no-worktree" }) })); + }); + // a-team-names-its-columns: the team's columns, from this host's own root. it("answers the team's own columns, from its own root whatever the page names", async () => { const columns = vi.fn(async () => ({ kind: "refused", reason: "not in this test" })); @@ -543,7 +569,7 @@ describe("PipelinePanel — file events", () => { }); describe("PipelinePanel — opening a change", () => { - it("reveals an active change in the Changes tree and opens its proposal", async () => { + it("reveals a change of this checkout in the Changes tree and opens its tasks in a tab of their own", async () => { const { pipeline, revealChange } = createPipelinePanel(); pipeline.show(); @@ -551,11 +577,42 @@ describe("PipelinePanel — opening a change", () => { expect(revealChange).toHaveBeenCalledWith(ACTIVE_CHANGE); const opened = vscodeMock.workspace.openTextDocument.mock.calls[0]?.[0] as { fsPath: string } | undefined; - expect(opened?.fsPath.replaceAll("\\", "/")).toBe("/repo/openspec/changes/alpha/proposal.md"); - expect(vscodeMock.window.showTextDocument).toHaveBeenCalledOnce(); + expect(opened?.fsPath.replaceAll("\\", "/")).toBe("/repo/openspec/changes/alpha/tasks.md"); + expect(vscodeMock.window.showTextDocument).toHaveBeenCalledWith(expect.anything(), { preview: false }); + }); + + // a-card-works-its-own-tasks 3.2: the change's own worktree is where it is + // worked, and where it usually only is until its pull request merges. + it("opens a change worked in its own worktree from there, at a line where one is named", async () => { + const { pipeline, revealChange } = createPipelinePanel(); + pipeline.show(); + + await pipeline.deliverMessageForTesting({ type: "openspec-ui/open-change", changeName: "beta", line: 12 }); + + expect(revealChange).not.toHaveBeenCalled(); + const opened = vscodeMock.workspace.openTextDocument.mock.calls[0]?.[0] as { fsPath: string } | undefined; + expect(opened?.fsPath.replaceAll("\\", "/")).toBe("/wt/beta/openspec/changes/beta/tasks.md"); + const options = (vscodeMock.window.showTextDocument.mock.calls[0] as unknown as [unknown, { preview: boolean; selection: { start: { line: number } } }])[1]; + expect(options.preview).toBe(false); + expect(options.selection.start.line).toBe(0); + }); + + it("opens the worktree in a new window, copies its path, and reveals its specs, for the menu", async () => { + const { pipeline } = createPipelinePanel(); + pipeline.show(); + + await pipeline.deliverMessageForTesting({ type: "openspec-ui/open-change", changeName: "beta", target: "window" }); + await pipeline.deliverMessageForTesting({ type: "openspec-ui/open-change", changeName: "beta", target: "copyPath" }); + await pipeline.deliverMessageForTesting({ type: "openspec-ui/open-change", changeName: "beta", target: "specs" }); + + const commands = vscodeMock.commands.executeCommand.mock.calls as unknown as Array<[string, { fsPath: string }, unknown?]>; + expect(commands.find(([id]) => id === "vscode.openFolder")?.[1].fsPath).toBe("/wt/beta"); + expect(commands.find(([id]) => id === "vscode.openFolder")?.[2]).toEqual({ forceNewWindow: true }); + expect(vscodeMock.env.clipboard.writeText).toHaveBeenCalledWith("/wt/beta"); + expect(commands.find(([id]) => id === "revealFileInOS")?.[1].fsPath.replaceAll("\\", "/")).toBe("/wt/beta/openspec/changes/beta/specs"); }); - it("opens nothing for a name that is not an active change, and says so", async () => { + it("opens nothing for a change that is neither in its own worktree nor here, and says so", async () => { const { pipeline, revealChange } = createPipelinePanel(); pipeline.show(); @@ -563,7 +620,7 @@ describe("PipelinePanel — opening a change", () => { expect(revealChange).not.toHaveBeenCalled(); expect(vscodeMock.workspace.openTextDocument).not.toHaveBeenCalled(); - expect(vscodeMock.window.showInformationMessage).toHaveBeenCalledWith(expect.stringContaining("gone-already is not an active change")); + expect(vscodeMock.window.showInformationMessage).toHaveBeenCalledWith(expect.stringContaining("gone-already is neither in a worktree of its own")); }); it("refuses a name containing a path separator before looking anything up", async () => { diff --git a/packages/extension/src/webview/pipeline-panel.ts b/packages/extension/src/webview/pipeline-panel.ts index aa0d0000..767fb25b 100644 --- a/packages/extension/src/webview/pipeline-panel.ts +++ b/packages/extension/src/webview/pipeline-panel.ts @@ -41,6 +41,12 @@ import { isRunLogId, listRunLogs, readRunLog, + commitTaskList, + resolveOwnWorktree, + resolveRunner, + runOwnDelegatedItem, + setTaskDone, + type AgentRunner, } from "@openspec-ui/core"; import { EMBED_THEME_PARAMETER, editorThemeName, frameFillingStyle } from "./embedded-page.js"; import { REQUEST_MESSAGE_TYPE, RESPONSE_MESSAGE_TYPE } from "./harness-requests.js"; @@ -135,6 +141,8 @@ export interface PipelineReaders { refreshRuns: (survey: WorktreeSurvey) => Promise; statusDirectory: (workspaceRoot: string) => Promise; findActiveChange: (workspaceRoot: string, changeName: string) => Promise; + /** The change's own worktree, where it is worked (a-card-works-its-own-tasks). */ + ownWorktree: (workspaceRoot: string, changeName: string) => ReturnType; /** Where every change stands, with the refs fetched now * (a-change-says-where-it-stands). */ standingsNow: (workspaceRoot: string) => Promise; @@ -185,6 +193,7 @@ const DEFAULT_READERS: PipelineReaders = { (await discoverOpenSpecWorkspace(workspaceRoot, { changes: "active", drafts: true })).changes.find((change) => change.name === changeName), myLabel: (statusDirectory) => myRosterLabel(statusDirectory), askLiveRun: (options) => askLiveRunToStop(options), + ownWorktree: (workspaceRoot, changeName) => resolveOwnWorktree({ repositoryRoot: workspaceRoot, changeName }), }; export interface PipelinePanelDeps { @@ -213,8 +222,15 @@ export interface PipelinePanelDeps { * message bridge (the-pipeline-answers-while-a-run-works). The panel never * starts the server itself. */ getLocalServerUrl?: () => string | undefined; + /** The runners for a change's own worktree, where a card runs a delegated + * task (a-card-works-its-own-tasks). Without it, no task is run. */ + runnersFor?: (workspaceRoot: string) => Map; } +/** What a card's name, or its menu, asks to open (a-card-works-its-own-tasks). */ +export type OpenChangeTarget = "tasks" | "proposal" | "design" | "specs" | "window" | "copyPath"; +const OPEN_CHANGE_TARGETS: ReadonlySet = new Set(["tasks", "proposal", "design", "specs", "window", "copyPath"]); + interface PipelineRequest { id: string; op: string; @@ -368,12 +384,16 @@ export class PipelinePanel { private async handleMessage(panel: vscode.WebviewPanel, message: unknown): Promise { if (typeof message === "object" && message !== null && (message as { type?: unknown }).type === OPEN_CHANGE_MESSAGE_TYPE) { - const { changeName, origin } = message as { changeName?: unknown; origin?: unknown }; + const { changeName, origin, target, line } = message as { changeName?: unknown; origin?: unknown; target?: unknown; line?: unknown }; // In embed mode, only a message carrying this embed's own origin is // honoured — one from any other origin is ignored, whatever change // name it names (the-pipeline-answers-while-a-run-works). if (this.embedOrigin !== undefined && origin !== this.embedOrigin) return; - await this.openChange(changeName); + await this.openChange( + changeName, + typeof target === "string" && OPEN_CHANGE_TARGETS.has(target) ? target as OpenChangeTarget : "tasks", + typeof line === "number" && Number.isInteger(line) && line >= 0 ? line : undefined, + ); return; } if (typeof message === "object" && message !== null && (message as { type?: unknown }).type === RUN_CHANGE_MESSAGE_TYPE) { @@ -481,6 +501,62 @@ export class PipelinePanel { reply({ ok: true, value: records }); return; } + // A card's task controls (a-card-works-its-own-tasks). Each names a + // change and a line; core resolves the change's own worktree from + // this host's root, so nothing a message names is written to. + case "pipeline/task-set": { + const args = request.args as { changeName?: unknown; lineNumber?: unknown; expectedText?: unknown; done?: unknown; note?: unknown } | undefined; + if (!isValidChangeName(args?.changeName) || typeof args?.lineNumber !== "number" || typeof args.expectedText !== "string" + || typeof args.done !== "boolean" || (args.note !== undefined && typeof args.note !== "string")) { + reply({ ok: false, error: "a change name, a line, its text and done are required" }); + return; + } + reply({ + ok: true, + value: await setTaskDone({ + repositoryRoot: workspaceRoot, + changeName: args.changeName, + lineNumber: args.lineNumber, + expectedText: args.expectedText, + done: args.done, + ...(typeof args.note === "string" ? { note: args.note } : {}), + }), + }); + return; + } + case "pipeline/task-commit": { + const changeName = (request.args as { changeName?: unknown } | undefined)?.changeName; + if (!isValidChangeName(changeName)) { + reply({ ok: false, error: "a change name is required" }); + return; + } + reply({ ok: true, value: await commitTaskList({ repositoryRoot: workspaceRoot, changeName }) }); + return; + } + case "pipeline/task-run": { + const args = request.args as { changeName?: unknown; lineNumber?: unknown } | undefined; + const runnersFor = this.deps.runnersFor; + if (!isValidChangeName(args?.changeName) || typeof args?.lineNumber !== "number" || runnersFor === undefined) { + reply({ ok: false, error: "a change name and a line are required, and this host must run agents" }); + return; + } + reply({ + ok: true, + value: await runOwnDelegatedItem({ + repositoryRoot: workspaceRoot, + changeName: args.changeName, + lineNumber: args.lineNumber, + runnersFor: (root) => { + const runners = runnersFor(root); + return (agentId) => { + const runner = resolveRunner(runners, agentId); + return runner !== undefined && this.deps.liveRuns !== undefined ? this.deps.liveRuns.runner(runner) : runner; + }; + }, + }), + }); + return; + } case "pipeline/refresh": { // Fetches now, whatever the interval, and forgets the survey held, // so the readings the view asks for next are taken afresh. @@ -612,23 +688,66 @@ export class PipelinePanel { void panel.webview.postMessage(response); } - private async openChange(changeName: unknown): Promise { + /** Opens what a card asks for, from where the change is worked: its own + * worktree where it has one, otherwise this checkout's copy + * (a-card-works-its-own-tasks). A change worked in its own worktree + * usually is not in this checkout at all until its pull request merges, + * which is why looking here alone opened nothing. The paths come from + * this host's own lookup, never from the message. */ + private async openChange(changeName: unknown, target: OpenChangeTarget = "tasks", line?: number): Promise { const workspaceRoot = this.deps.getWorkspaceRoot(); if (!workspaceRoot) return; if (!isValidChangeName(changeName)) { void vscode.window.showInformationMessage(`OpenSpec Workbench: "${String(changeName)}" is not a change name this workspace can have.`); return; } - const change = await this.readers.findActiveChange(workspaceRoot, changeName); - if (!change) { - 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.`, - ); + let root: string; + let changeDir: string; + const own = await this.readers.ownWorktree(workspaceRoot, changeName).catch(() => undefined); + if (own?.ok === true) { + root = own.path; + changeDir = path.dirname(own.tasksPath); + } else { + const change = await this.readers.findActiveChange(workspaceRoot, changeName); + if (!change) { + void vscode.window.showInformationMessage( + `OpenSpec Workbench: ${changeName} is neither in a worktree of its own nor an active change of this checkout — it may have been archived or deleted since the Pipeline was read.`, + ); + return; + } + await this.deps.revealChange(change); + root = workspaceRoot; + changeDir = change.path; + } + + if (target === "window") { + await vscode.commands.executeCommand("vscode.openFolder", vscode.Uri.file(root), { forceNewWindow: true }); return; } - await this.deps.revealChange(change); - const document = await vscode.workspace.openTextDocument(vscode.Uri.file(path.join(change.path, "proposal.md"))); - await vscode.window.showTextDocument(document, { preview: false }); + if (target === "copyPath") { + await vscode.env.clipboard.writeText(root); + void vscode.window.showInformationMessage(`OpenSpec Workbench: copied ${root}`); + return; + } + if (target === "specs") { + // The folder in the system's file manager: a worktree is outside this + // window's folder, so the Explorer cannot show it. + await vscode.commands.executeCommand("revealFileInOS", vscode.Uri.file(path.join(changeDir, "specs"))); + return; + } + const file = path.join(changeDir, `${target}.md`); + let document: vscode.TextDocument; + try { + document = await vscode.workspace.openTextDocument(vscode.Uri.file(file)); + } catch { + void vscode.window.showInformationMessage(`OpenSpec Workbench: ${changeName} has no ${target}.md in ${root}.`); + return; + } + const at = line !== undefined ? new vscode.Position(Math.min(line, Math.max(document.lineCount - 1, 0)), 0) : undefined; + await vscode.window.showTextDocument(document, { + preview: false, + ...(at !== undefined ? { selection: new vscode.Range(at, at) } : {}), + }); } /** Embeds the local server's own Pipeline tab, the way `AiPanel`'s diff --git a/packages/server/src/cli.ts b/packages/server/src/cli.ts index d487927d..7c576532 100644 --- a/packages/server/src/cli.ts +++ b/packages/server/src/cli.ts @@ -21,6 +21,13 @@ const server = createServer({ allowExternalCwd, auditLog, runners: buildDefaultAgentRunners({ workspaceRoot, allowExternalCwd, auditLog, runLogs: createFileRunLogs(workspaceRoot) }), + // A change's own worktree records its runs in its own log, as + // `openspec-ui-cli run --cwd` does there (a-card-works-its-own-tasks). + runnersFor: (root) => buildDefaultAgentRunners({ + workspaceRoot: root, + auditLog: new FileAuditLog(auditLogPath(root)), + runLogs: createFileRunLogs(root), + }), }); const address = await server.listen(); console.log( diff --git a/packages/server/src/own-worktree-rest.test.ts b/packages/server/src/own-worktree-rest.test.ts new file mode 100644 index 00000000..893a2c44 --- /dev/null +++ b/packages/server/src/own-worktree-rest.test.ts @@ -0,0 +1,80 @@ +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it, vi } from "vitest"; +import { createServer } from "./server.js"; + +// a-card-works-its-own-tasks 3.1. The rules are core's (resolving the own +// worktree, the note, the run check); these routes read a body, check the +// cwd as every route does, and answer what core said. A workspace that is +// not a git repository has no worktree of a change's own, which is the one +// fact these assert without starting git. +// every-varying-check-has-a-budget: a local HTTP server and temporary files, +// set up inside each test rather than in a hook. Measured 2026-10-05 at +// 718 ms for the four tests. +vi.setConfig({ testTimeout: 20_000 }); + +const TOKEN = "own-worktree-test-token"; +const HEADERS = { "content-type": "application/json", "x-openspec-ui-token": TOKEN }; + +/** A workspace with one change and its task list, and a server on it, for + * the length of one test. */ +async function withServer(test: (post: (route: string, body: unknown) => Promise, workspace: string) => Promise): Promise { + const workspace = await mkdtemp(path.join(os.tmpdir(), "openspec-own-rest-")); + const dir = path.join(workspace, "openspec", "changes", "demo"); + await mkdir(dir, { recursive: true }); + await writeFile(path.join(dir, "tasks.md"), "- [ ] 1.1 **Human-only**: look\n closely.\n", "utf8"); + const server = createServer({ workspaceRoot: workspace, host: "127.0.0.1", port: 0, runners: new Map(), accessToken: TOKEN }); + try { + const address = await server.listen(); + const post = (route: string, body: unknown) => fetch(`http://127.0.0.1:${address.port}${route}`, { method: "POST", headers: HEADERS, body: JSON.stringify(body) }); + await test(post, workspace); + } finally { + await server.close(); + await rm(workspace, { recursive: true, force: true }); + } +} + +describe("the routes a card's task controls use", () => { + it("reads a change's tasks whole, from this checkout where it has no worktree of its own", async () => { + await withServer(async (post, workspace) => { + const response = await post("/api/change-tasks", { cwd: workspace, changeName: "demo" }); + + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + ok: true, + source: "this-checkout", + rows: [{ number: "1.1", lineNumber: 0, body: "closely.", closedBy: "person" }], + }); + }); + }); + + it("refuses to tick, commit or run in a change with no worktree of its own, and writes nothing", async () => { + await withServer(async (post, workspace) => { + for (const [route, extra] of [ + ["/api/change-tasks/set", { lineNumber: 0, expectedText: "1.1 **Human-only**: look", done: true, note: "seen" }], + ["/api/change-tasks/commit", {}], + ["/api/change-tasks/run", { lineNumber: 0 }], + ] as const) { + const response = await post(route, { cwd: workspace, changeName: "demo", ...extra }); + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ ok: false, kind: "no-worktree" }); + } + }); + }); + + it("refuses a body without a change, or without the line a tick names", async () => { + await withServer(async (post, workspace) => { + expect((await post("/api/change-tasks", { cwd: workspace })).status).toBe(400); + expect((await post("/api/change-tasks/set", { cwd: workspace, changeName: "demo", done: true })).status).toBe(400); + expect((await post("/api/change-tasks/run", { cwd: workspace, changeName: "demo" })).status).toBe(400); + }); + }); + + it("refuses a cwd outside the workspace, as every route does", async () => { + await withServer(async (post) => { + const response = await post("/api/change-tasks", { cwd: os.tmpdir(), changeName: "demo" }); + expect(response.status).toBe(403); + }); + }); +}); diff --git a/packages/server/src/own-worktree-rest.ts b/packages/server/src/own-worktree-rest.ts new file mode 100644 index 00000000..bbf32274 --- /dev/null +++ b/packages/server/src/own-worktree-rest.ts @@ -0,0 +1,131 @@ +// The routes a card's task controls use — a-card-works-its-own-tasks, ADR +// 0026 amended 2026-10-05. +// +// Every route takes the workspace (`cwd`, checked as every route checks it) +// and a change's name, and core resolves the change's own worktree from the +// repository's worktree list. No route takes a path to act on: a request is +// data from a page, and the directory is the host's to find. + +import type { IncomingMessage, ServerResponse } from "node:http"; +import { + commitTaskList, + readChangeTaskRows, + resolveRunner, + runOwnDelegatedItem, + setTaskDone, + type AgentRunner, + type LiveRuns, +} from "@openspec-ui/core"; +import { authorizeCwd, readJsonBody, sendBodyError, sendJson, type RestRequestPolicy } from "./rest.js"; + +interface ChangeRequest { + cwd: string; + changeName: string; +} + +function isChangeRequest(value: unknown): value is ChangeRequest { + if (typeof value !== "object" || value === null) return false; + const record = value as Record; + return typeof record.cwd === "string" && record.cwd.trim().length > 0 + && typeof record.changeName === "string" && record.changeName.trim().length > 0; +} + +function hasLine(value: ChangeRequest): value is ChangeRequest & { lineNumber: number } { + const lineNumber = (value as { lineNumber?: unknown }).lineNumber; + return typeof lineNumber === "number" && Number.isInteger(lineNumber) && lineNumber >= 0; +} + +/** Reads a body that names a change in an allowed workspace, or answers + * the request itself and returns `undefined`. */ +async function changeRequestOf(req: IncomingMessage, res: ServerResponse, policy: RestRequestPolicy): Promise { + let parsed: unknown; + try { + parsed = await readJsonBody(req, policy.maxPayloadBytes); + } catch (error) { + sendBodyError(res, error); + return undefined; + } + if (!isChangeRequest(parsed)) { + sendJson(res, 400, { error: "body must contain a non-empty cwd and changeName" }); + return undefined; + } + if (!authorizeCwd(res, policy, parsed.cwd)) return undefined; + return parsed; +} + +/** A refusal is an answer, not a transport failure: the caller asked a + * legitimate question and is told why nothing was done. */ +async function answer(res: ServerResponse, work: () => Promise): Promise { + try { + sendJson(res, 200, await work()); + } catch (error) { + sendJson(res, 500, { error: error instanceof Error ? error.message : String(error) }); + } +} + +/** POST /api/change-tasks — a change's tasks, whole, from its own worktree + * or, read-only, from this checkout. */ +export async function handleChangeTasksRequest(req: IncomingMessage, res: ServerResponse, policy: RestRequestPolicy): Promise { + const request = await changeRequestOf(req, res, policy); + if (request === undefined) return; + await answer(res, () => readChangeTaskRows({ repositoryRoot: request.cwd, changeName: request.changeName })); +} + +/** POST /api/change-tasks/set — ticks or unticks one task, with a note. */ +export async function handleChangeTaskSetRequest(req: IncomingMessage, res: ServerResponse, policy: RestRequestPolicy): Promise { + const request = await changeRequestOf(req, res, policy); + if (request === undefined) return; + const body = request as ChangeRequest & { expectedText?: unknown; done?: unknown; note?: unknown }; + if (!hasLine(request) || typeof body.expectedText !== "string" || typeof body.done !== "boolean" + || (body.note !== undefined && typeof body.note !== "string")) { + sendJson(res, 400, { error: "body must also contain a zero-based lineNumber, the expectedText and done" }); + return; + } + const expectedText = body.expectedText; + const done = body.done; + const note = typeof body.note === "string" ? body.note : undefined; + await answer(res, () => setTaskDone({ + repositoryRoot: request.cwd, + changeName: request.changeName, + lineNumber: request.lineNumber, + expectedText, + done, + ...(note !== undefined ? { note } : {}), + })); +} + +/** POST /api/change-tasks/commit — commits the task list alone and pushes. */ +export async function handleChangeTasksCommitRequest(req: IncomingMessage, res: ServerResponse, policy: RestRequestPolicy): Promise { + const request = await changeRequestOf(req, res, policy); + if (request === undefined) return; + await answer(res, () => commitTaskList({ repositoryRoot: request.cwd, changeName: request.changeName })); +} + +/** POST /api/change-tasks/run — runs a delegated task's agent in the + * change's own worktree, with runners built for that worktree. */ +export async function handleChangeTaskRunRequest( + req: IncomingMessage, + res: ServerResponse, + policy: RestRequestPolicy, + runnersFor: (workspaceRoot: string) => Map, + liveRuns?: LiveRuns, +): Promise { + const request = await changeRequestOf(req, res, policy); + if (request === undefined) return; + if (!hasLine(request)) { + sendJson(res, 400, { error: "body must also contain a zero-based lineNumber" }); + return; + } + await answer(res, () => runOwnDelegatedItem({ + repositoryRoot: request.cwd, + changeName: request.changeName, + lineNumber: request.lineNumber, + runnersFor: (root) => { + const runners = runnersFor(root); + return (agentId) => { + const runner = resolveRunner(runners, agentId); + return runner !== undefined && liveRuns !== undefined ? liveRuns.runner(runner) : runner; + }; + }, + })); +} diff --git a/packages/server/src/rest.ts b/packages/server/src/rest.ts index c6a85e47..407fb9ee 100644 --- a/packages/server/src/rest.ts +++ b/packages/server/src/rest.ts @@ -113,7 +113,7 @@ export interface RestRequestPolicy { class PayloadTooLargeError extends Error {} -async function readJsonBody(req: IncomingMessage, maxPayloadBytes: number): Promise { +export async function readJsonBody(req: IncomingMessage, maxPayloadBytes: number): Promise { const chunks: Buffer[] = []; let totalBytes = 0; for await (const chunk of req) { @@ -125,12 +125,12 @@ async function readJsonBody(req: IncomingMessage, maxPayloadBytes: number): Prom return JSON.parse(Buffer.concat(chunks).toString("utf8")); } -function sendJson(res: ServerResponse, statusCode: number, body: unknown): void { +export function sendJson(res: ServerResponse, statusCode: number, body: unknown): void { res.writeHead(statusCode, { "content-type": "application/json" }); res.end(JSON.stringify(body)); } -function sendBodyError(res: ServerResponse, error: unknown): void { +export function sendBodyError(res: ServerResponse, error: unknown): void { if (error instanceof PayloadTooLargeError) { sendJson(res, 413, { error: "request payload is too large" }); } else { @@ -138,7 +138,7 @@ function sendBodyError(res: ServerResponse, error: unknown): void { } } -function authorizeCwd(res: ServerResponse, policy: RestRequestPolicy, cwd: string): boolean { +export function authorizeCwd(res: ServerResponse, policy: RestRequestPolicy, cwd: string): boolean { if (policy.isCwdAllowed(cwd)) return true; sendJson(res, 403, { error: "cwd is outside the configured workspace" }); return false; diff --git a/packages/server/src/server.ts b/packages/server/src/server.ts index 4fb9ddd5..2ca09de5 100644 --- a/packages/server/src/server.ts +++ b/packages/server/src/server.ts @@ -62,6 +62,12 @@ import { handleTemplatesListRequest, handleTemplatesRenderRequest, } from "./rest.js"; +import { + handleChangeTaskRunRequest, + handleChangeTaskSetRequest, + handleChangeTasksCommitRequest, + handleChangeTasksRequest, +} from "./own-worktree-rest.js"; import { handleSocketMessage } from "./websocket.js"; import { tryServeStatic, type StaticAssetPaths } from "./static.js"; import { @@ -98,6 +104,11 @@ export interface ServerOptions { /** Tests only: substitutes the `AgentRunner` registry instead of the * real CLI adapters (see server.test.ts) — not used in production. */ runners?: Map; + /** The runners for another working directory of this repository — a + * change's own worktree, where a card runs a delegated task + * (a-card-works-its-own-tasks). Built as `runners` is, for that root. + * Absent, `runners` serves every directory, which is what a test wants. */ + runnersFor?: (workspaceRoot: string) => Map; /** Explicit paths to index.html/app.js — needed when `server` is * embedded into a bundled CJS host (see static.ts, `extension`'s * optional-server.ts). */ @@ -342,6 +353,22 @@ export function createServer(options: ServerOptions): OpenSpecUiServer { void handleDelegatedItemRunRequest(req, res, runners, requestPolicy, options.auditLog, liveRuns); return; } + if (req.method === "POST" && req.url === "/api/change-tasks") { + void handleChangeTasksRequest(req, res, requestPolicy); + return; + } + if (req.method === "POST" && req.url === "/api/change-tasks/set") { + void handleChangeTaskSetRequest(req, res, requestPolicy); + return; + } + if (req.method === "POST" && req.url === "/api/change-tasks/commit") { + void handleChangeTasksCommitRequest(req, res, requestPolicy); + return; + } + if (req.method === "POST" && req.url === "/api/change-tasks/run") { + void handleChangeTaskRunRequest(req, res, requestPolicy, options.runnersFor ?? (() => runners), liveRuns); + return; + } if (req.method === "POST" && req.url === "/api/scheduled-runs") { void handleScheduledRunsRequest(req, res, requestPolicy); return; diff --git a/packages/webui/src/bridge-request.ts b/packages/webui/src/bridge-request.ts index 90e8a1f9..562e6d19 100644 --- a/packages/webui/src/bridge-request.ts +++ b/packages/webui/src/bridge-request.ts @@ -65,7 +65,11 @@ export type BridgeOperation = | "pipeline/live-runs" // A change's run logs, and one run's (a-change-shows-its-run-logs). | "pipeline/run-logs" - | "pipeline/run-log"; + | "pipeline/run-log" + // A card's task controls (a-card-works-its-own-tasks). + | "pipeline/task-set" + | "pipeline/task-commit" + | "pipeline/task-run"; export interface BridgeRequestMessage { type: typeof BRIDGE_REQUEST_MESSAGE_TYPE; diff --git a/packages/webui/src/components/PipelineView.test.tsx b/packages/webui/src/components/PipelineView.test.tsx index dc2a446c..7463fc5a 100644 --- a/packages/webui/src/components/PipelineView.test.tsx +++ b/packages/webui/src/components/PipelineView.test.tsx @@ -1866,3 +1866,130 @@ describe("PipelineView - what the default branch archived", () => { expect(screen.queryByTestId("pipeline-node-gone")).toBeNull(); }); }); + +// a-card-works-its-own-tasks 3.3: a task shown whole, and what a card may do +// in its change's own worktree and nowhere else. +describe("PipelineView — a card works its own tasks", () => { + const ownRows = [ + { number: "1.1", text: "Write the module", section: "Work", lineNumber: 2, done: true, closedBy: "agent" as const }, + { + number: "1.2", + text: "**Human-only**: see it", + section: "Work", + lineNumber: 3, + body: "in the Pipeline.\n\nAn earlier remark.", + done: false, + closedBy: "person" as const, + }, + ]; + // `fresh` is worked in the worktree made for it, and is not in this + // checkout at all, as a change is until its pull request merges. + const own = (overrides: Partial> = {}) => theirs({ + path: "/wt/repo/fresh", + label: "fresh", + branch: "fresh", + ownChange: "fresh", + changes: [{ changeName: "fresh", tasksDone: 1, tasksTotal: 2, blockers: [], alsoIn: [], tasks: ownRows }], + ...overrides, + }); + const actions = () => ({ + set: vi.fn(async () => ({ ok: true, said: "Closed 1.2." })), + commit: vi.fn(async () => ({ ok: true, said: "Committed abc1234 and pushed to origin/fresh." })), + run: vi.fn(async () => ({ ok: true, said: "Ran." })), + open: vi.fn(), + openTargets: ["tasks", "proposal", "copyPath"] as const, + }); + + async function openOwnCard(props: Partial[0]> = {}) { + render( + report(change("alpha"))} + survey={async () => survey(directory(), own())} + {...props} + />, + ); + fireEvent.click(await screen.findByTestId("pipeline-directory-0-node-fresh-tasks-toggle")); + } + + 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']"); + expect(row?.getAttribute("title")).toBe("1.2 **Human-only**: see it (only a person can close it)\n\nin the Pipeline.\n\nAn earlier remark."); + }); + + it("opens a task whole beside the board, and closes a Human-only task only with a note", async () => { + const taskActions = actions(); + await openOwnCard({ taskActions: taskActions as never }); + + fireEvent.click(screen.getByTestId("pipeline-directory-0-node-fresh-tasks-row-1.2")); + const panel = screen.getByTestId("task-panel"); + expect(within(panel).getByTestId("task-panel-body").textContent).toContain("An earlier remark."); + + fireEvent.click(within(panel).getByTestId("task-panel-set")); + expect(within(panel).getByTestId("task-panel-said").textContent).toContain("closed with a note"); + expect(taskActions.set).not.toHaveBeenCalled(); + + fireEvent.change(within(panel).getByTestId("task-panel-note"), { target: { value: "seen in the Pipeline" } }); + fireEvent.click(within(panel).getByTestId("task-panel-set")); + await waitFor(() => expect(taskActions.set).toHaveBeenCalledWith("fresh", { lineNumber: 3, text: "1.2 **Human-only**: see it" }, true, "seen in the Pipeline")); + expect(await within(panel).findByText("Closed 1.2.")).toBeTruthy(); + }); + + it("commits and pushes from the card's list, and goes to a task's line", async () => { + const taskActions = actions(); + await openOwnCard({ taskActions: taskActions as never }); + + fireEvent.click(screen.getByTestId("pipeline-directory-0-node-fresh-task-list")); + fireEvent.click(within(screen.getByTestId("task-panel")).getByTestId("task-panel-commit")); + await waitFor(() => expect(taskActions.commit).toHaveBeenCalledWith("fresh")); + fireEvent.click(within(screen.getByTestId("task-panel")).getByTestId("task-panel-open-proposal")); + expect(taskActions.open).toHaveBeenCalledWith("fresh", "proposal"); + + fireEvent.click(screen.getByTestId("pipeline-directory-0-node-fresh-tasks-row-1.2")); + fireEvent.click(within(screen.getByTestId("task-panel")).getByTestId("task-panel-goto")); + expect(taskActions.open).toHaveBeenCalledWith("fresh", "tasks", 3); + }); + + it("opens the change from its name in its own worktree, and offers nothing on a card that is not its own", async () => { + const onOpenChange = vi.fn(); + const taskActions = actions(); + render( + report(change("alpha"))} + survey={async () => survey(directory(), own(), theirs({ changes: [{ changeName: "their-change", tasksDone: 0, tasksTotal: 1, blockers: [], alsoIn: [], tasks: [ownRows[1]!] }] }))} + onOpenChange={onOpenChange} + taskActions={taskActions as never} + />, + ); + fireEvent.click(await screen.findByTestId("pipeline-directory-0-node-fresh-open")); + expect(onOpenChange).toHaveBeenCalledWith("fresh"); + + // Another directory's change: its name is text, it has no list actions, + // and its task opens read-only. + expect(screen.queryByTestId("pipeline-directory-1-node-their-change-open")).toBeNull(); + expect(screen.queryByTestId("pipeline-directory-1-node-their-change-task-list")).toBeNull(); + fireEvent.click(screen.getByTestId("pipeline-directory-1-node-their-change-tasks-toggle")); + fireEvent.click(screen.getByTestId("pipeline-directory-1-node-their-change-tasks-row-1.2")); + const panel = screen.getByTestId("task-panel"); + expect(within(panel).getByTestId("task-panel-where").textContent).toContain("Nothing here acts on it"); + expect(within(panel).queryByTestId("task-panel-form")).toBeNull(); + expect(within(panel).queryByTestId("task-panel-commit")).toBeNull(); + }); + + it("hides a card's done tasks, makes it as tall as what it lists, and keeps the choice", async () => { + const remembered: PipelineViewMemory[] = []; + await openOwnCard({ viewState: { read: () => undefined, write: (memory) => { remembered.push(memory); } } }); + const list = screen.getByTestId("pipeline-directory-0-node-fresh-tasks"); + expect(list.querySelectorAll("li")).toHaveLength(2); + const heightOf = () => Number(screen.getByTestId("pipeline-directory-0-node-fresh").style.getPropertyValue("--h")); + const before = heightOf(); + + fireEvent.click(screen.getByTestId("pipeline-directory-0-node-fresh-hide-done")); + + expect(screen.getByTestId("pipeline-directory-0-node-fresh-tasks").querySelectorAll("li")).toHaveLength(1); + expect(heightOf()).toBeLessThan(before); + expect(remembered.at(-1)?.hideDone).toEqual([{ directory: "/wt/repo/fresh", changeName: "fresh" }]); + }); +}); diff --git a/packages/webui/src/components/PipelineView.tsx b/packages/webui/src/components/PipelineView.tsx index ffc1f6a7..c1f094e8 100644 --- a/packages/webui/src/components/PipelineView.tsx +++ b/packages/webui/src/components/PipelineView.tsx @@ -23,7 +23,7 @@ // is drawn between directories — the repository declares no order // between them. See openspec/changes/what-the-others-are-doing. -import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; +import { useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { COLUMN_GAP, LANE_HEADING, @@ -80,6 +80,14 @@ import { } from "@openspec-ui/core/browser"; import { HintList } from "./HintList.js"; import { Icon } from "./Icon.js"; +import { + TaskCardContext, + TaskPanel, + wholeTaskText, + type TaskActions, + type TaskCardContextValue, + type TaskSelection, +} from "./TaskPanel.js"; import { ChevronIcon, CheckIcon, @@ -210,6 +218,11 @@ export interface PipelineViewProps { * else (a-screen-says-what-it-is-doing). A later poll does not report, * or the tab would blink every ten seconds. */ onReadingChange?: (reading: string | null) => void; + /** What a card may do in its change's own worktree: tick, untick, commit + * and push, run a delegated task, open the change's files + * (a-card-works-its-own-tasks). Absent, every card is read-only, as + * before, and a task opens read-only. */ + taskActions?: TaskActions; } /** What the Pipeline says while its first report has not returned. */ @@ -235,6 +248,9 @@ export interface PipelineViewMemory { arrangement?: PipelineArrangement; /** The order each column's cards stand in (the-board-sorts-its-cards). */ order?: ChangeOrder; + /** The cards whose done tasks are hidden, kept as the open ones are + * (a-card-works-its-own-tasks). */ + hideDone?: Array<{ directory: string; changeName: string }>; } /** How the picture is arranged: `steps` by what each change waits for, @@ -252,36 +268,53 @@ function openKey(directory: string, changeName: string): string { return JSON.stringify([directory, changeName]); } -function readViewMemory(viewState: PipelineViewProps["viewState"]): { zoom: number; open: string[]; arrangement: PipelineArrangement; order: ChangeOrder } { +/** Remembered cards, as the keys `openKey` makes; anything malformed is + * dropped. */ +function rememberedKeys(value: unknown): string[] { + const entries: unknown[] = Array.isArray(value) ? value : []; + return entries.flatMap((entry) => { + if (typeof entry !== "object" || entry === null) return []; + const { directory, changeName } = entry as { directory?: unknown; changeName?: unknown }; + return typeof directory === "string" && typeof changeName === "string" ? [openKey(directory, changeName)] : []; + }); +} + +function keysAsCards(keys: ReadonlySet): Array<{ directory: string; changeName: string }> { + return [...keys].map((key) => { + const [directory, changeName] = JSON.parse(key) as [string, string]; + return { directory, changeName }; + }); +} + +function readViewMemory(viewState: PipelineViewProps["viewState"]): { zoom: number; open: string[]; arrangement: PipelineArrangement; order: ChangeOrder; hideDone: string[] } { try { const memory = viewState?.read() as Partial | undefined; - if (typeof memory !== "object" || memory === null) return { zoom: DEFAULT_ZOOM, open: [], arrangement: "steps", order: "name" }; + if (typeof memory !== "object" || memory === null) return { zoom: DEFAULT_ZOOM, open: [], arrangement: "steps", order: "name", hideDone: [] }; const zoom = typeof memory.zoom === "number" && PIPELINE_ZOOM_STEPS.includes(memory.zoom) ? memory.zoom : DEFAULT_ZOOM; - const entries: unknown[] = Array.isArray(memory.open) ? memory.open : []; - const open = entries.flatMap((entry) => { - if (typeof entry !== "object" || entry === null) return []; - const { directory, changeName } = entry as { directory?: unknown; changeName?: unknown }; - return typeof directory === "string" && typeof changeName === "string" ? [openKey(directory, changeName)] : []; - }); const arrangement: PipelineArrangement = memory.arrangement === "stages" ? "stages" : "steps"; const order: ChangeOrder = CHANGE_ORDERS.find((one) => one === memory.order) ?? "name"; - return { zoom, open, arrangement, order }; + return { zoom, open: rememberedKeys(memory.open), arrangement, order, hideDone: rememberedKeys(memory.hideDone) }; } catch { - return { zoom: DEFAULT_ZOOM, open: [], arrangement: "steps", order: "name" }; + return { zoom: DEFAULT_ZOOM, open: [], arrangement: "steps", order: "name", hideDone: [] }; } } -function writeViewMemory(viewState: PipelineViewProps["viewState"], zoom: number, open: ReadonlySet, arrangement: PipelineArrangement, order: ChangeOrder): void { +function writeViewMemory( + viewState: PipelineViewProps["viewState"], + zoom: number, + open: ReadonlySet, + arrangement: PipelineArrangement, + order: ChangeOrder, + hideDone: ReadonlySet, +): void { if (viewState === undefined) return; try { viewState.write({ zoom, arrangement, order, - open: [...open].map((key) => { - const [directory, changeName] = JSON.parse(key) as [string, string]; - return { directory, changeName }; - }), + open: keysAsCards(open), + ...(hideDone.size > 0 ? { hideDone: keysAsCards(hideDone) } : {}), }); } catch { // A host that cannot keep what the viewer left still draws the picture. @@ -300,10 +333,17 @@ function taskGroups(rows: readonly T[]): Array<{ section return groups; } +/** The rows a card lists: every one, or the open ones where its done tasks + * are hidden (a-card-works-its-own-tasks). */ +function shownRows(rows: readonly T[], hideDone: boolean): T[] { + return hideDone ? rows.filter((row) => !row.done) : [...rows]; +} + /** What an open card lists, for its height: its rows and its headings. */ -function openParts(rows: readonly SurveyedTask[], open: boolean): { rows: number; sections: number } | undefined { - if (!open || rows.length === 0) return undefined; - return { rows: rows.length, sections: taskGroups(rows).filter((group) => group.section !== undefined).length }; +function openParts(rows: readonly SurveyedTask[], open: boolean, hideDone = false): { rows: number; sections: number } | undefined { + const listed = shownRows(rows, hideDone); + if (!open || listed.length === 0) return undefined; + return { rows: listed.length, sections: taskGroups(listed).filter((group) => group.section !== undefined).length }; } /** A request to stop a run held elsewhere, as a card sends it. */ @@ -412,6 +452,7 @@ export function PipelineView({ viewState, onAskToStop, onReadingChange, + taskActions, }: PipelineViewProps) { const local = usePolledReading(load, isActive, PIPELINE_POLL_INTERVAL_MS, { name: "readiness", subscribe }); const others = usePolledReading(survey, isActive, SURVEY_POLL_INTERVAL_MS, { name: "survey", subscribe }); @@ -444,7 +485,23 @@ export function PipelineView({ const [arrangement, setArrangement] = useState(remembered.arrangement); const [order, setOrder] = useState(remembered.order); const [open, setOpen] = useState>(() => new Set(remembered.open)); - useEffect(() => writeViewMemory(viewState, zoom, open, arrangement, order), [viewState, zoom, open, arrangement, order]); + // The cards whose done tasks are hidden, and the task or list chosen on a + // card (a-card-works-its-own-tasks). + const [hideDone, setHideDone] = useState>(() => new Set(remembered.hideDone)); + const [chosen, setChosen] = useState(undefined); + useEffect(() => writeViewMemory(viewState, zoom, open, arrangement, order, hideDone), [viewState, zoom, open, arrangement, order, hideDone]); + const taskCards = useMemo(() => ({ + hidesDone: (directory, changeName) => hideDone.has(openKey(directory, changeName)), + toggleHideDone: (directory, changeName) => setHideDone((current) => { + const next = new Set(current); + const key = openKey(directory, changeName); + if (next.has(key)) next.delete(key); + else next.add(key); + return next; + }), + select: setChosen, + ...(onOpenChange !== undefined ? { openOwn: onOpenChange } : {}), + }), [hideDone, onOpenChange]); const openCards = useMemo(() => ({ isOpen: (directory, changeName) => open.has(openKey(directory, changeName)), toggle: (directory, changeName) => setOpen((current) => { @@ -634,6 +691,24 @@ export function PipelineView({ && directory.readable && drawsAnEdge(directory.changes.filter((change) => change.changeName !== directory.belongsTo))); + // The chosen task as the latest reading has it, by its directory, its + // change and its line: a tick or a note changes the row, and the panel + // shows what is now true rather than what was pressed. A line does not + // move when a note is written under it (a-card-works-its-own-tasks). + const currentSelection = (selection: TaskSelection): TaskSelection => { + const line = selection.row?.lineNumber; + if (line === undefined) return selection; + const card = cards.get(selection.changeName); + const fromCard = card !== undefined && (card.where.path === selection.directory || (card.where.path === "" && selection.directory === localDirectory)) + ? card.tasks + : undefined; + const directory = others.value?.directories.find((one) => one.path === selection.directory); + const surveyed = directory?.readable ? directory.changes.find((change) => change.changeName === selection.changeName)?.tasks : undefined; + const rows = fromCard ?? (surveyed !== undefined ? describeTaskRows(surveyed, undefined) : undefined); + const row = rows?.find((candidate) => candidate.lineNumber === line); + return row === undefined ? selection : { ...selection, row }; + }; + // Each reading is shown when it arrives (the-pipeline-shows-what-it-has-read). // This directory's part says it is still being read, or why it could not // be, in its own place; the other working directories are drawn whatever @@ -642,6 +717,7 @@ export function PipelineView({ return ( // The zoom is one factor on everything the picture draws, cards, text // and lines alike; no layout unit changes with it. +
}>
@@ -889,7 +965,19 @@ export function PipelineView({ onCancel={() => setStopFor(undefined)} /> ) : null} + {chosen !== undefined ? ( + setChosen(undefined)} + // What a press changed is read again at once, and the panel shows + // the task as it now reads. + onChanged={() => void Promise.all([local.read(), others.read(), ended.read()])} + /> + ) : null}
+ ); } @@ -993,8 +1081,11 @@ function LocalPicture({ report, cards, now, onOpenChange, alsoIn, controls, dire // nothing is measured (the-pipeline-cards-wear-metro). const models = new Map(); const heights = new Map(); + // A card whose done tasks are hidden lists fewer rows, and is as tall as + // what it lists (a-card-works-its-own-tasks). + const taskCards = useContext(TaskCardContext); for (const [name, { directory: where, change }] of elsewhere) { - const openRows = openParts(change.tasks ?? [], openCards.isOpen(where.path, name)); + const openRows = openParts(change.tasks ?? [], openCards.isOpen(where.path, name), taskCards.hidesDone(where.path, name)); heights.set(name, pipelineCardHeight({ hasState: false, hasProgress: !change.tasksUnreadable && change.tasksTotal > 0, @@ -1009,7 +1100,7 @@ function LocalPicture({ report, cards, now, onOpenChange, alsoIn, controls, dire if (card === undefined) continue; const model = localCardModel(change, card, now, alsoIn.get(change.changeName), controls, stages.get(change.changeName)); models.set(change.changeName, model); - const openRows = openParts(model.rows, openCards.isOpen(directory, change.changeName)); + const openRows = openParts(model.rows, openCards.isOpen(directory, change.changeName), taskCards.hidesDone(directory, change.changeName)); heights.set(change.changeName, pipelineCardHeight({ hasState: true, hasProgress: hasProgress(card), @@ -1101,6 +1192,7 @@ function LocalPicture({ report, cards, now, onOpenChange, alsoIn, controls, dire node={node} model={model} onOpenChange={onOpenChange} + directory={directory} open={openCards.isOpen(directory, name)} onToggle={() => openCards.toggle(directory, name)} /> @@ -1169,10 +1261,26 @@ function taskTag(row: TaskRow): { text: string; tone: "done" | "hand" | "next" | /** An open card's tasks, in the task list's order, under their headings, * as rows of a bordered list. Kept in the page while the card is closed, * and hidden, so the control that shows it always names an element. */ -function TaskList({ id, rows, open, testId }: { id: string; rows: readonly TaskRow[]; open: boolean; testId: string }) { +function TaskList({ id, rows, open, testId, onSelect }: { + id: string; + rows: readonly TaskRow[]; + open: boolean; + testId: string; + /** Opens a row whole, beside the board (a-card-works-its-own-tasks). + * Absent, a row is text, as before. */ + onSelect?: (row: TaskRow) => void; +}) { const groups = taskGroups(rows); + const list = useRef(null); + // The task in hand is where a person looks first: an opened card brings + // it into view. + useEffect(() => { + if (!open) return; + const inHand = list.current?.querySelector("[data-word='in hand'], [data-word='probably next']"); + if (inHand !== null && inHand !== undefined && typeof inHand.scrollIntoView === "function") inHand.scrollIntoView({ block: "nearest" }); + }, [open]); return ( -