Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,8 +162,8 @@ release artifact. Current release versions are:

| Package | Version | Release role |
| --- | ---: | --- |
| `@openspec-ui/core` | 0.16.0 | Shared behavior and persistence contract |
| `openspec-ui-vscode` | 0.10.0 | VS Code delivery |
| `@openspec-ui/core` | 0.17.0 | Shared behavior and persistence contract |
| `openspec-ui-vscode` | 0.11.0 | VS Code delivery |
| `@openspec-ui/server` | 1.7.0 | Standalone server delivery |
| `@openspec-ui/webui` | 1.8.0 | Shared browser UI |
| `@openspec-ui/cli` | 0.1.0 | CI merge-gate delivery |
Expand Down
2 changes: 2 additions & 0 deletions openspec/changes/tasks-tree-expand/.openspec.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-08-17
125 changes: 125 additions & 0 deletions openspec/changes/tasks-tree-expand/design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
## Context

`ChangeTreeItem` (`changes-tree.ts`) already carries a real, discovery-
sourced `changeDir` (from `discoverOpenSpecWorkspace`, not reconstructed
from a raw user-typed name) and an `archived` flag. Both
`ChangesTreeProvider.getChildren`/`ArchiveTreeProvider.getChildren`
already duplicate near-identical artifact-expansion logic for a
`ChangeTreeItem` element (`archive-tree.ts` literally mirrors
`changes-tree.ts`'s artifact-mapping block).

`task-templates.ts`'s own comment explains the existing security pattern
this change follows: "`changeName` is never used to build a path
directly... a crafted name cannot escape [the workspace]" — paths always
come from `discoverOpenSpecWorkspace`'s own allowlisted `artifacts[]`,
never from joining a caller-supplied string onto a directory.
`change-state.ts` already has a `TASK_CHECKBOX_RE` regex for *counting*
checked/unchecked tasks, but nothing in core extracts individual tasks
with a line number.

No existing command in this codebase does "reveal a specific line in an
open/to-be-opened editor" (`TextEditorRevealType`, `editor.selection`) —
the closest precedent, `ArtifactTreeItem`'s `.command = { command:
"vscode.open", ... }`, only opens a file, it does not jump to a line.

## Goals / Non-Goals

**Goals:**
- Both trees stay one shared `getChangeChildren` implementation, not two
independently-maintained copies — closes an existing duplication gap
(artifacts) while adding tasks, rather than duplicating the new logic a
third time.
- Delete never removes the wrong line, even if the tree is stale relative
to a file edited outside the tree (another editor, a CLI agent run, git
pull) since the tree was last refreshed.

**Non-Goals:**
- No task *editing* (checking/unchecking, renaming) from the tree — only
reveal (both trees) and delete (active only). Marking a task done is
already possible by editing `tasks.md` directly (the checkbox syntax is
the same convention already used everywhere in this repository); adding
a second, tree-driven way to toggle it is a separate, unrequested
feature.
- No conflict resolution if `tasks.md` is open with unsaved edits when a
delete happens elsewhere (CLI, another window). This is an existing,
already-accepted risk class in this codebase — `deleteChange` already
deletes files that could be open in an editor with no special handling
— not a new gap introduced here.
- No standalone/webui equivalent. See proposal.md's Impact — deferred as
a clean follow-up, not out of scope by principle.

## Decisions

### `deleteTaskLine` re-verifies the exact line's text before deleting, not just its index

The tree's `TaskTreeItem` line numbers are a snapshot from the last
`getChildren()` call; the real file can change before the user clicks
Delete (their own edit, a CLI agent run, another window). Rejected
trusting the stored line number blindly: an off-by-N delete after the
file shifted would silently remove the *wrong* task — a correctness bug
users would not immediately notice (task text just gone, no error).
Instead, `deleteTaskLine` re-reads the file fresh at delete time, checks
that the target line still parses as a checkbox item whose description
text exactly matches what the tree captured, and only then removes it;
otherwise it throws `TaskListChangedError` (shown as a warning, "refresh
and try again") — fails closed, matching this repository's own
fail-closed philosophy (`docs/adr/0006-fail-closed-journal-compatibility.md`).

Rejected content-only matching (search the whole file for the first line
with matching text, ignore the stored line number entirely): silently
ambiguous when two tasks share identical text (e.g. copy-pasted
sub-tasks) — could delete a different occurrence than the one the user
actually clicked. Line-number-plus-text-verification is unambiguous and
still self-healing (the tree refreshes and the next click gets a fresh,
correct line number) without guessing across duplicates.

### Reveal degrades gracefully instead of failing closed

Unlike delete, revealing a task is non-destructive — if the stored line
number no longer matches (file changed since last refresh), falling back
to a whole-file search for the first line with matching text (and, if
that also fails, just opening the file at the top) is the better
trade-off: showing *something* useful beats blocking on a warning dialog
for a read-only navigation action.

### Shared `getChangeChildren(workspaceRoot, element)` exported from `changes-tree.ts`, reused by `archive-tree.ts`

`archive-tree.ts` already imports `ArtifactTreeItem`/`ChangeTreeItem`/
`EmptyTreeItem` from `changes-tree.js` — extending that existing reuse
pattern to the children-computation logic itself (which was, before this
change, independently duplicated in both files) removes an existing
drift risk instead of adding a third copy of increasingly complex
children logic.

### `openspec-ui.revealTask` is not exposed via `contributes.commands`

It only makes sense with a specific `TaskTreeItem` argument (which
Command Palette invocation cannot supply); `deleteTask` reads `item ??
return` too but is deliberately still exposed in `contributes.commands`
because the context-menu `when`-clause needs a Palette-registered command
id to bind to — `revealTask` is wired only via each `TaskTreeItem`'s own
`.command` property, matching the (also `contributes.commands`-absent)
generic `vscode.open` command already used the same way by
`ArtifactTreeItem`.

## Risks / Trade-offs

- **[Risk]** Parsing `tasks.md` client-side (regex over raw file content)
instead of via the `openspec` CLI's own `status --json` command could
drift from how the CLI itself defines "a task" if its checklist
convention ever changes. → **Mitigation**: accepted; `change-state.ts`
already has this exact same class of direct-regex dependency
(`TASK_CHECKBOX_RE`) for counting, so this isn't a new coupling, just a
second, consistent use of it.
- **[Risk]** Large `tasks.md` files (hundreds of tasks) render every task
as a tree node with no pagination/grouping. → **Mitigation**: accepted
for now — no repository's `tasks.md` in this project's own history has
approached that scale; revisit only if it becomes a real problem.

## Migration Plan

- No data migration; purely additive (new tree children, two new
commands, one new core module).
- Version bump (minor) for `@openspec-ui/core`, `openspec-ui-vscode`.
- Rollback: revert the package changes together; no persisted state
beyond `tasks.md` edits a user explicitly made via "Delete Task."
67 changes: 67 additions & 0 deletions openspec/changes/tasks-tree-expand/proposal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
## Why

A specific gap raised in review: the "Changes" and "Archive" tree views
(`openspec/specs/vscode-extension/spec.md`) already expand a change node
to show its artifacts (`proposal.md`/`design.md`/`tasks.md`/delta specs)
via `ArtifactTreeItem`, but not the *individual tasks* inside
`tasks.md` — a user who wants to see or manage progress on one specific
task has to open `tasks.md` and read/edit it manually, with no tree-level
view of task status, no per-task delete, and no quick way to jump from
"I clicked this task in the tree" to "here it is in the open markdown
file." This is the same class of gap `archive-tasks-as-template`/
`template-catalog` already closed for other change artifacts — tree-level
interaction was extended to those, but never to tasks.md's own checklist
items.

## What Changes

- Expand each `ChangeTreeItem` (in both the "Changes" and "Archive" tree
views) to also list its individual checklist tasks from `tasks.md` as
child tree items, alongside the existing artifact children.
- Clicking a task tree item opens (or reveals, if already open)
`tasks.md` with the cursor moved to and the line selected/centered at
that exact task — in both trees, since this is a non-destructive,
purely informational action.
- Add "Delete Task" to the task context menu, **active changes only**
(never on archived tasks — the Archive tree's task children are
read-only/informational, matching how the Archive tree already has no
mutating actions beyond "Copy Tasks as Template" and "Unarchive" on the
change itself). Deleting a task removes that exact checklist line from
the change's `tasks.md`, with a confirmation prompt.
- Core: new `readTaskChecklist`/`deleteTaskLine` functions
(`packages/core/src/task-checklist.ts`) — `deleteTaskLine` re-verifies
the target line still contains the expected task text at delete time
(re-reading the file fresh, not trusting a potentially-stale line
number from when the tree was last refreshed) and fails closed with a
clear error if the file changed underneath, rather than risking
deleting the wrong line.

## Capabilities

### New Capabilities

(none)

### Modified Capabilities

- `vscode-extension`: the Changes and Archive tree views now expose
individual tasks as child nodes, with reveal-in-editor (both trees) and
delete (active changes only).

## Impact

- `packages/core/src/task-checklist.ts` (new), `index.ts` (export).
- `packages/extension/src/tree/changes-tree.ts` (`TaskTreeItem`, shared
`getChangeChildren` helper reused by both tree providers),
`src/tree/archive-tree.ts` (calls the shared helper instead of
duplicating the artifact-only logic it has today).
- `packages/extension/src/commands.ts` (`openspec-ui.revealTask`,
`openspec-ui.deleteTask`), `package.json` (command registration +
context-menu entry scoped to active-task items only).
- No REST endpoint / standalone UI — this is a VS Code tree/editor
interaction, not a data operation standalone's browser shell has an
equivalent surface for today (no per-task tree view exists there); core
still owns the actual mutation logic, so standalone parity remains a
clean, non-breaking follow-up if ever wanted, per ADR-0001's
single-source-of-truth-in-core invariant.
- No change to the command/event protocol.
42 changes: 42 additions & 0 deletions openspec/changes/tasks-tree-expand/smoke-test-notes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
## Core: real, not just mocked

Ran `readTaskChecklist`/`deleteTaskLine` directly via `tsx` (temporary
scratch scripts, deleted afterward — `git status` confirmed no trace
left):

- **`readTaskChecklist` against this repository's own live file**:
pointed it at this very change's own `tasks-tree-expand/tasks.md`
(self-referential, genuinely real data) — correctly found 14 tasks
with accurate line numbers and `done` state matching the actual
checked/unchecked boxes in the file.
- **`deleteTaskLine` against a real temp `tasks.md`**: created a 3-task
file, deleted the middle one — confirmed via `readTaskChecklist`
afterward (2 tasks left, correct ones) and by reading the raw file
content directly (`- [ ] 1.2 Delete me` line genuinely gone, the other
two untouched, headings/blank lines preserved).
- **Stale-delete rejection, for real**: immediately retried the same
delete call (now referring to a line that no longer holds that text) —
correctly threw `TaskListChangedError` without further modifying the
file, exactly as designed.

## Extension: real VS Code Extension Host

`npm run test:integration --workspace openspec-ui-vscode` — a genuine
`@vscode/test-electron` run, not a mock — 6/6 passing, including
"activates and registers all contributed commands," confirming the
extension activates cleanly with `openspec-ui.revealTask`/
`openspec-ui.deleteTask` registered and the tree providers wired, with
no runtime errors in a real VS Code instance.

## Not driven live: actual tree expansion / click-to-reveal / delete-via-UI

As with the two prior VS Code-UI-only changes this session
(`template-catalog-v2`'s delete, `extension-release-tagging`'s
customize-manifest fix), there is no desktop-app UI automation tool
available in this environment — can't literally click a task tree node
and watch the editor jump to it. Coverage for that interaction rests on:
33 passing `commands.test.ts` cases (including the line-fallback logic
for `revealTask` and the confirmation/archived-guard logic for
`deleteTask`, using a `vscode-mock.ts` extended specifically to model
`TextEditorRevealType`/`Selection`/a real `lineAt().text` for this
change) plus the real Extension Host activation check above.
43 changes: 43 additions & 0 deletions openspec/changes/tasks-tree-expand/specs/vscode-extension/spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
## ADDED Requirements

### Requirement: Changes and Archive trees expand to individual tasks, with reveal and scoped delete

Expanding a change node in either the "Changes" or "Archive" tree view
SHALL also list that change's individual `tasks.md` checklist items as
child tree items, alongside its existing artifact children. Selecting a
task item SHALL open (or reveal, if already open) `tasks.md` with the
cursor moved to that task's line, in both trees. A "Delete Task" action
SHALL be available only on task items belonging to an active (non-
archived) change; selecting it, after confirmation, SHALL remove exactly
that task's checklist line from the change's `tasks.md`. Task items
belonging to archived changes SHALL NOT offer a delete action.

#### Scenario: Expanding an active change shows its tasks

- **WHEN** the user expands a change node in the "Changes" tree
- **THEN** its individual `tasks.md` checklist items appear as child
items alongside the existing artifact children

#### Scenario: Selecting a task reveals it in the editor

- **WHEN** the user selects a task tree item (in either tree)
- **THEN** `tasks.md` opens (or is revealed, if already open) with the
cursor at that task's line

#### Scenario: Deleting a task from an active change

- **WHEN** the user confirms "Delete Task" on a task belonging to an
active change
- **THEN** that exact line is removed from the change's `tasks.md`

#### Scenario: Archived tasks offer no delete action

- **WHEN** the user views a task item under the "Archive" tree
- **THEN** no delete action is available for it

#### Scenario: The underlying file changed since the tree was last refreshed

- **WHEN** the user attempts to delete a task whose stored position no
longer matches the current content of `tasks.md`
- **THEN** the system reports that the task list has changed and makes no
filesystem change, rather than risking deletion of a different line
86 changes: 86 additions & 0 deletions openspec/changes/tasks-tree-expand/tasks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
## 1. Core: task checklist read/delete

- [x] 1.1 `packages/core/src/task-checklist.ts`: `TaskChecklistItem { lineNumber:
number; text: string; done: boolean }`, `TASK_CHECKBOX_LINE_RE` (shared
regex), `TaskListChangedError`, `readTaskChecklist(workspaceRoot,
changeName, archived)` (via `discoverOpenSpecWorkspace`'s allowlisted
artifact path, never a reconstructed path — same pattern as
`readArchivedChangeTasksTemplate`), `deleteTaskLine(workspaceRoot,
changeName, archived, lineNumber, expectedText)` (re-verifies the line's
text before removing it; preserves the file's original line-ending
style).
- [x] 1.2 `task-checklist.test.ts`: reads real checklist items with correct
line numbers/done state from a fixture `tasks.md`; deletes an existing
task (line gone, rest of file unchanged); throws `TaskListChangedError`
for a stale line number/mismatched text without modifying the file;
returns `[]` for a change with no `tasks.md`. 8/8 tests passing
(also covers archived-change path and CRLF preservation, beyond the
originally listed cases).
- [x] 1.3 Export from `packages/core/src/index.ts`.

## 2. Extension: tree expansion

- [x] 2.1 `changes-tree.ts`: `TaskTreeItem` (contextValue
`openspec-ui.activeTask`/`openspec-ui.archivedTask`, `.command` set to
`openspec-ui.revealTask` with itself as the argument); export
`getChangeChildren(workspaceRoot, element)` combining the existing
artifact-mapping logic with `readTaskChecklist`-derived task items;
`WorkbenchTreeItem` union extended with `TaskTreeItem`.
- [x] 2.2 `ChangesTreeProvider.getChildren`/`ArchiveTreeProvider.getChildren`:
both delegate to the shared `getChangeChildren` for a `ChangeTreeItem`
element instead of each having their own artifact-only mapping.
- [x] 2.3 `changes-tree.test.ts`/`archive-tree.test.ts`: expanding a change
now also returns task children with correct label/description/
contextValue; archived task items get `openspec-ui.archivedTask`,
active get `openspec-ui.activeTask`. 5 + 3 = 8/8 tests passing.

## 3. Extension: commands

- [x] 3.1 `commands.ts`: `openspec-ui.revealTask` — opens/reveals
`tasks.md`, selects and centers the task's line; re-verifies the stored
line number against current file content first, falls back to a
whole-file text search, then to line 0, rather than failing.
Extended `test-utils/vscode-mock.ts` with `TextEditorRevealType`,
`Selection`, a real `lineAt().text`, and a `showTextDocument` that
returns a usable fake editor (`selection`/`revealRange`) — none of
these existed in the mock before this change.
- [x] 3.2 `commands.ts`: `openspec-ui.deleteTask` — no-ops for an archived
item; modal confirmation (`showWarningMessage(..., {modal:true},
"Delete")`, same pattern as `deleteChange`); calls `deleteTaskLine`;
refreshes trees; `TaskListChangedError` surfaces as a warning, not
`showCommandError`.
- [x] 3.3 `package.json`: registered `openspec-ui.deleteTask` (title,
icon); context-menu entry `"when": "viewItem ==
openspec-ui.activeTask"` only. `revealTask` is not added to
`contributes.commands` (see design.md).
- [x] 3.4 `commands.test.ts`: `revealTask` opens the right document and
sets the right selection for a fresh line number, and falls back
correctly for a stale one; `deleteTask` respects the confirmation gate,
no-ops for an archived item (without even prompting), calls core +
refreshes trees on success, surfaces `TaskListChangedError` as a
warning. 6 new tests, 33/33 extension command tests passing.

## 4. Verification, versioning, and smoke test

- [ ] 4.1 `npm run typecheck && npm run lint && npm run test` passes for
`packages/core`, `packages/extension`. Re-run `npm run verify` after
`git add`/commit of all new files.
- [x] 4.2 Bump `package.json` versions (minor) for `@openspec-ui/core`,
`openspec-ui-vscode`. core 0.16.0 → 0.17.0, extension 0.10.1 → 0.11.0.
Also added a `packages/extension/CHANGELOG.md` 0.11.0 entry, a README
Features bullet, and caught/fixed `README.md`'s version table having
been stale since the 0.10.1 fix (never updated then).
- [x] 4.3 Manual smoke test: real `readTaskChecklist`/`deleteTaskLine`
runs against this repository's own `tasks.md` files where practical
(core is Node-only, testable without VS Code); VS Code tree/editor
interaction verified via `npm run test:integration` (real Extension
Host) to the extent that harness can drive it, with any remaining gap
noted explicitly in `smoke-test-notes.md` (no desktop-UI automation
tool available in this environment, per the prior session's own
precedent for this exact limitation).
`readTaskChecklist` run against this very change's own live
`tasks.md` (14 real tasks, correct); `deleteTaskLine` run against a
real temp file (correct deletion + correct stale-rejection); real
`@vscode/test-electron` run, 6/6 passing. Full detail in
`smoke-test-notes.md`.
- [x] 4.4 `openspec change validate --strict tasks-tree-expand` passes.
2 changes: 1 addition & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@openspec-ui/core",
"private": true,
"version": "0.16.0",
"version": "0.17.0",
"type": "module",
"main": "src/index.ts",
"exports": {
Expand Down
Loading
Loading