diff --git a/openspec/changes/archive/2026-08-26-add-changeset-archive-reminder/design.md b/openspec/changes/archive/2026-08-26-add-changeset-archive-reminder/design.md new file mode 100644 index 0000000..a1069cb --- /dev/null +++ b/openspec/changes/archive/2026-08-26-add-changeset-archive-reminder/design.md @@ -0,0 +1,73 @@ +## Context + +`2026-08-26-add-adopt-changesets-template` covers the "propose Changesets +adoption from an OpenSpec change" half of the agreed integration scope. +This change covers the other half: once a project has actually adopted +Changesets, a nudge at the moment a change is archived (the point where +a forgotten changeset is most likely to be discovered too late — after +the code has already landed) that a pending changeset might be missing. + +## Goals / Non-Goals + +**Goals:** +- A project that has adopted Changesets and archives a change without a + pending changeset sees a single, dismissible reminder with a one-click + way to start `npx changeset`. +- A project that has not adopted Changesets (no `.changeset/config.json`) + sees nothing at all — this feature is invisible until a project opts + in by adopting Changesets in the first place. +- The check is read-only and best-effort: it never blocks, delays, or + can fail the archive operation that already succeeded. + +**Non-Goals:** +- Not a visual panel or dedicated view for Changesets state (rejected — + see the 2026-08-26 discussion; the product stays release-tool-agnostic + per `docs/adr/0001-shared-core-two-delivery-targets.md`). +- Not correlating the archived change's specific file diff against which + packages actually need a version bump — that requires a git diff + against a base ref, which the archive command does not currently have + and which would meaningfully raise the false-negative/false-positive + surface for a first version of this feature. The reminder only asks + "is anything pending at all," not "does the right package have a + changeset." +- Not implemented for `webui`/standalone: the action (opening an + integrated terminal) has no equivalent in a browser tab or the VS Code + local-server iframe embed. A future change could add a copy-to-clipboard + or in-page instruction instead, if requested. +- Not auto-running `npx changeset` or auto-installing `@changesets/cli` + — the user explicitly asked whether to auto-install and the answer + here is no: the reminder only appears once a project has already + chosen to adopt Changesets (`.changeset/config.json` exists), so + `@changesets/cli` is already an expected dependency by that point. + +## Decisions + +### The core function reports facts only, never prompts + +`checkChangesetReminder(cwd)` in `packages/core/src/changeset-reminder.ts` +returns `{ changesetsAdopted, pendingChangesetCount }` and does nothing +else — no `vscode` import, no UI. `packages/extension/src/commands.ts` +decides whether and how to surface it. This mirrors +`agent-detection.ts`'s existing shape in the same package (a pure +presence check the host acts on). + +### Best-effort, silently swallowed failures + +`remindAboutPendingChangeset` wraps its entire body in try/catch with no +error surfaced anywhere — a broken or unreadable `.changeset` directory +should never turn into a scary error message immediately after a +successful archive. `checkChangesetReminder` itself already treats a +missing/unreadable `.changeset` directory as "not adopted," so the +try/catch is a second line of defense against anything unexpected (e.g. +a VS Code API call failing). + +## Risks / Trade-offs + +- **[Risk]** A project with per-package changesets workflows unrelated + to the archived change (e.g. an already-pending changeset for a + different, older piece of work) suppresses the reminder even though + the archived change itself introduced an uncovered package bump. + → **Mitigation**: accepted for a first version — see the Non-Goals + above on not correlating against the specific diff. The reminder is a + best-effort nudge, not a gate; `require-changeset` (Gitea/GitHub CI, if + adopted) remains the actual enforcement point. diff --git a/openspec/changes/archive/2026-08-26-add-changeset-archive-reminder/proposal.md b/openspec/changes/archive/2026-08-26-add-changeset-archive-reminder/proposal.md new file mode 100644 index 0000000..4da5661 --- /dev/null +++ b/openspec/changes/archive/2026-08-26-add-changeset-archive-reminder/proposal.md @@ -0,0 +1,42 @@ +## Why + +Second and final piece of the narrower Changesets-integration scope the +user confirmed on 2026-08-26 (template + conditional archive-time +reminder, not a full Changesets visual panel baked into the product +core — see `2026-08-26-add-adopt-changesets-template` for the template +half). The product should not assume every project uses Changesets, so +this is a best-effort, silent-by-default nudge: only a project that has +already adopted Changesets (`.changeset/config.json` present) and has +just archived a change without a pending changeset sees anything at +all. + +## What Changes + +- Add `checkChangesetReminder(cwd)` to `@openspec-ui/core`: reports + whether `.changeset/config.json` exists and, if so, how many pending + `.changeset/*.md` files exist (excluding `README.md`). Pure + filesystem read, no prompting — the host decides what to do with the + result, per `docs/adr/0001-shared-core-two-delivery-targets.md`. +- Wire it into the VS Code extension's `openspec-ui.archiveChange` + command: after a successful archive, if Changesets is adopted and + nothing is pending, show an information message with a "Run npx + changeset" action that opens an integrated terminal and runs it. + Never blocks or delays the archive result itself; any failure in the + check is swallowed silently. +- Scoped to the VS Code extension only, not `webui`/standalone: the + reminder's action (an integrated terminal) has no equivalent in a + browser-hosted or message-bridge iframe context. + +## Capabilities + +### Modified Capabilities + +- `vscode-extension`: adds a new Requirement for the archive-time + Changesets reminder. + +## Impact + +- `packages/core/src/changeset-reminder.ts` (new) +- `packages/core/src/index.ts` +- `packages/extension/src/commands.ts` +- `openspec/specs/vscode-extension/spec.md` diff --git a/openspec/changes/archive/2026-08-26-add-changeset-archive-reminder/specs/vscode-extension/spec.md b/openspec/changes/archive/2026-08-26-add-changeset-archive-reminder/specs/vscode-extension/spec.md new file mode 100644 index 0000000..346a57a --- /dev/null +++ b/openspec/changes/archive/2026-08-26-add-changeset-archive-reminder/specs/vscode-extension/spec.md @@ -0,0 +1,39 @@ +## ADDED Requirements + +### Requirement: Archiving a change offers a Changesets reminder when appropriate + +For a workspace that has adopted Changesets (`.changeset/config.json` +exists), the extension SHALL check, after a successful archive, whether +any changeset is currently pending, and SHALL offer to start `npx +changeset` in an integrated terminal when none is. A workspace that has +not adopted Changesets SHALL see no such reminder. The check SHALL NOT +block, delay, or affect the outcome of the archive operation. + +#### Scenario: Archiving with Changesets adopted and nothing pending + +- **WHEN** a change is archived in a workspace with + `.changeset/config.json` and no pending `.changeset/*.md` file +- **THEN** the extension shows an information message offering to run + `npx changeset` +- **AND** choosing that action opens an integrated terminal and runs + `npx changeset` + +#### Scenario: Archiving with a changeset already pending + +- **WHEN** a change is archived in a workspace with + `.changeset/config.json` and at least one pending `.changeset/*.md` + file +- **THEN** no reminder is shown + +#### Scenario: Archiving in a workspace that has not adopted Changesets + +- **WHEN** a change is archived in a workspace with no + `.changeset/config.json` +- **THEN** no reminder is shown + +#### Scenario: The reminder check fails + +- **WHEN** the Changesets presence/pending check throws or the + filesystem is unreadable +- **THEN** the archive operation's own success result is unaffected +- **AND** no error is surfaced for the failed check diff --git a/openspec/changes/archive/2026-08-26-add-changeset-archive-reminder/tasks.md b/openspec/changes/archive/2026-08-26-add-changeset-archive-reminder/tasks.md new file mode 100644 index 0000000..810c1e7 --- /dev/null +++ b/openspec/changes/archive/2026-08-26-add-changeset-archive-reminder/tasks.md @@ -0,0 +1,47 @@ +## 1. Core: report Changesets adoption/pending state + +- [x] 1.1 Add `checkChangesetReminder(cwd)` to + `packages/core/src/changeset-reminder.ts`: `changesetsAdopted` (does + `.changeset/config.json` exist), `pendingChangesetCount` (count of + `.changeset/*.md` files, excluding `README.md`). Never throws. +- [x] 1.2 Export it from `packages/core/src/index.ts`. +- [x] 1.3 Add `packages/core/src/changeset-reminder.test.ts` covering: + no `.changeset` directory, `.changeset` present without + `config.json`, adopted with zero pending, adopted with pending + changesets counted correctly (README.md excluded). + +## 2. Extension: archive-time reminder + +- [x] 2.1 Add `remindAboutPendingChangeset(workspaceRoot)` to + `packages/extension/src/commands.ts`: calls `checkChangesetReminder`; + if adopted and nothing pending, shows an information message with a + "Run npx changeset" action that opens an integrated terminal + (`vscode.window.createTerminal`) and sends `npx changeset`. Wrapped in + try/catch with no surfaced error. +- [x] 2.2 Call it (fire-and-forget, not awaited) from + `openspec-ui.archiveChange` after the archive succeeds and trees + refresh, so it never delays or can fail the archive result itself. +- [x] 2.3 Add tests to `packages/extension/src/commands.test.ts`: the + reminder appears and opens a terminal with `npx changeset` when + adopted with nothing pending and the user picks the action; no + terminal opens when a changeset is already pending. + +## 3. Spec + +- [x] 3.1 Add the `ADDED Requirements` delta to + `openspec/specs/vscode-extension/spec.md` via + `specs/vscode-extension/spec.md` in this change. + +## 4. Verification + +- [x] 4.1 `npm run typecheck` passes workspace-wide. +- [x] 4.2 `npm run lint` (including `lint:english`) passes workspace-wide. +- [x] 4.3 `npm run test` passes workspace-wide, including the new test + files. +- [x] 4.4 Rebuild the VSIX (`npm run package --workspace + openspec-ui-vscode`) and confirm it packages without error. +- [x] 4.5 Propose a changeset (`npx changeset`) for `openspec-ui-vscode` + and `@openspec-ui/core` (both minor: new capability, no breaking + change) instead of hand-editing `version`/`CHANGELOG.md`; apply it via + `npx changeset version`. +- [x] 4.6 Run `openspec change validate --strict add-changeset-archive-reminder`. diff --git a/openspec/specs/vscode-extension/spec.md b/openspec/specs/vscode-extension/spec.md index c94176f..3fb67e4 100644 --- a/openspec/specs/vscode-extension/spec.md +++ b/openspec/specs/vscode-extension/spec.md @@ -269,3 +269,41 @@ report that instead of prompting for confirmation. rollback-eligible processes - **THEN** the system reports this without showing a confirmation dialog +### Requirement: Archiving a change offers a Changesets reminder when appropriate + +For a workspace that has adopted Changesets (`.changeset/config.json` +exists), the extension SHALL check, after a successful archive, whether +any changeset is currently pending, and SHALL offer to start `npx +changeset` in an integrated terminal when none is. A workspace that has +not adopted Changesets SHALL see no such reminder. The check SHALL NOT +block, delay, or affect the outcome of the archive operation. + +#### Scenario: Archiving with Changesets adopted and nothing pending + +- **WHEN** a change is archived in a workspace with + `.changeset/config.json` and no pending `.changeset/*.md` file +- **THEN** the extension shows an information message offering to run + `npx changeset` +- **AND** choosing that action opens an integrated terminal and runs + `npx changeset` + +#### Scenario: Archiving with a changeset already pending + +- **WHEN** a change is archived in a workspace with + `.changeset/config.json` and at least one pending `.changeset/*.md` + file +- **THEN** no reminder is shown + +#### Scenario: Archiving in a workspace that has not adopted Changesets + +- **WHEN** a change is archived in a workspace with no + `.changeset/config.json` +- **THEN** no reminder is shown + +#### Scenario: The reminder check fails + +- **WHEN** the Changesets presence/pending check throws or the + filesystem is unreadable +- **THEN** the archive operation's own success result is unaffected +- **AND** no error is surfaced for the failed check + diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index 4211724..a51b5e7 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -1,5 +1,16 @@ # @openspec-ui/core +## 0.26.0 + +### Minor Changes + +- Add an archive-time Changesets reminder to the VS Code extension. When a + workspace has adopted Changesets (`.changeset/config.json` exists) and no + changeset is currently pending, archiving a change now offers to run + `npx changeset` in an integrated terminal. Silent for workspaces that + have not adopted Changesets, and never affects the archive operation's + own result. + ## 0.25.0 ### Minor Changes diff --git a/packages/core/package.json b/packages/core/package.json index f43698f..b9bff6c 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,7 +1,7 @@ { "name": "@openspec-ui/core", "private": true, - "version": "0.25.0", + "version": "0.26.0", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/core/src/changeset-reminder.test.ts b/packages/core/src/changeset-reminder.test.ts new file mode 100644 index 0000000..955cfc3 --- /dev/null +++ b/packages/core/src/changeset-reminder.test.ts @@ -0,0 +1,65 @@ +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { checkChangesetReminder } from "./changeset-reminder.js"; + +const temporaryRoots: string[] = []; + +async function temporaryRoot(): Promise { + const root = await mkdtemp(path.join(os.tmpdir(), "openspec-changeset-reminder-")); + temporaryRoots.push(root); + return root; +} + +afterEach(async () => { + await Promise.all(temporaryRoots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); +}); + +describe("checkChangesetReminder", () => { + it("reports not adopted when there is no .changeset directory at all", async () => { + const root = await temporaryRoot(); + + expect(await checkChangesetReminder(root)).toEqual({ + changesetsAdopted: false, + pendingChangesetCount: 0, + }); + }); + + it("reports not adopted when .changeset exists but has no config.json", async () => { + const root = await temporaryRoot(); + await mkdir(path.join(root, ".changeset")); + await writeFile(path.join(root, ".changeset", "README.md"), "# Changesets\n"); + + expect(await checkChangesetReminder(root)).toEqual({ + changesetsAdopted: false, + pendingChangesetCount: 0, + }); + }); + + it("reports adopted with zero pending changesets when only config.json and README.md exist", async () => { + const root = await temporaryRoot(); + await mkdir(path.join(root, ".changeset")); + await writeFile(path.join(root, ".changeset", "config.json"), "{}"); + await writeFile(path.join(root, ".changeset", "README.md"), "# Changesets\n"); + + expect(await checkChangesetReminder(root)).toEqual({ + changesetsAdopted: true, + pendingChangesetCount: 0, + }); + }); + + it("counts pending changeset markdown files, excluding README.md", async () => { + const root = await temporaryRoot(); + await mkdir(path.join(root, ".changeset")); + await writeFile(path.join(root, ".changeset", "config.json"), "{}"); + await writeFile(path.join(root, ".changeset", "README.md"), "# Changesets\n"); + await writeFile(path.join(root, ".changeset", "fuzzy-cats-jump.md"), "---\n---\nSome change.\n"); + await writeFile(path.join(root, ".changeset", "brave-lions-run.md"), "---\n---\nAnother change.\n"); + + expect(await checkChangesetReminder(root)).toEqual({ + changesetsAdopted: true, + pendingChangesetCount: 2, + }); + }); +}); diff --git a/packages/core/src/changeset-reminder.ts b/packages/core/src/changeset-reminder.ts new file mode 100644 index 0000000..c527c7e --- /dev/null +++ b/packages/core/src/changeset-reminder.ts @@ -0,0 +1,41 @@ +// Best-effort presence check for whether a workspace has adopted +// Changesets (`.changeset/config.json`) and, if so, whether any changeset +// file is currently pending. A host adapter uses this to decide whether to +// show an archive-time reminder — this module only reports facts, it never +// prompts or writes anything itself (see openspec/changes/ +// add-changeset-archive-reminder/design.md). + +import { access, readdir } from "node:fs/promises"; +import path from "node:path"; + +export interface ChangesetReminderStatus { + changesetsAdopted: boolean; + pendingChangesetCount: number; +} + +const README_NAMES = new Set(["readme.md"]); + +/** Returns whether `cwd` has adopted Changesets (a `.changeset/config.json` + * file exists) and, if so, how many pending `.changeset/*.md` changeset + * files exist (excluding `.changeset/README.md`). Never throws — a missing + * or unreadable `.changeset` directory is reported as "not adopted". */ +export async function checkChangesetReminder(cwd: string): Promise { + const changesetDir = path.join(cwd, ".changeset"); + try { + await access(path.join(changesetDir, "config.json")); + } catch { + return { changesetsAdopted: false, pendingChangesetCount: 0 }; + } + + let entries: string[] = []; + try { + entries = await readdir(changesetDir); + } catch { + entries = []; + } + const pendingChangesetCount = entries.filter( + (name) => name.toLowerCase().endsWith(".md") && !README_NAMES.has(name.toLowerCase()), + ).length; + + return { changesetsAdopted: true, pendingChangesetCount }; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 272a536..a38103a 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -20,6 +20,7 @@ export * from "./checkpoint.js"; export * from "./workbench-run-journal.js"; export * from "./workbench-recovery.js"; export * from "./agent-detection.js"; +export * from "./changeset-reminder.js"; export { ClaudeCliAdapter } from "./agents/claude.js"; export { CopilotCliAdapter } from "./agents/copilot.js"; diff --git a/packages/extension/CHANGELOG.md b/packages/extension/CHANGELOG.md index 94c4c74..066cbac 100644 --- a/packages/extension/CHANGELOG.md +++ b/packages/extension/CHANGELOG.md @@ -1,5 +1,21 @@ # Changelog +## 0.22.0 + +### Minor Changes + +- Add an archive-time Changesets reminder to the VS Code extension. When a + workspace has adopted Changesets (`.changeset/config.json` exists) and no + changeset is currently pending, archiving a change now offers to run + `npx changeset` in an integrated terminal. Silent for workspaces that + have not adopted Changesets, and never affects the archive operation's + own result. + +### Patch Changes + +- Updated dependencies + - @openspec-ui/core@0.26.0 + ## 0.21.0 ### Minor Changes diff --git a/packages/extension/package.json b/packages/extension/package.json index b5109c0..172dd76 100644 --- a/packages/extension/package.json +++ b/packages/extension/package.json @@ -4,7 +4,7 @@ "displayName": "OpenSpec Workbench", "description": "A dashboard + VS Code extension for OpenSpec, with Claude, Copilot, Codex, and Gemini agents built in.", "publisher": "openspec-ui", - "version": "0.21.0", + "version": "0.22.0", "icon": "media/icon.png", "license": "MIT", "repository": { diff --git a/packages/extension/src/commands.test.ts b/packages/extension/src/commands.test.ts index f6b4e96..9053ef6 100644 --- a/packages/extension/src/commands.test.ts +++ b/packages/extension/src/commands.test.ts @@ -10,6 +10,7 @@ const listSpecsMock = vi.fn(); const showChangeMock = vi.fn(); const validateChangeMock = vi.fn(); const archiveChangeMock = vi.fn(); +const checkChangesetReminderMock = vi.fn(); const createChangeMock = vi.fn(); const deleteChangeMock = vi.fn(); const unarchiveChangeMock = vi.fn(); @@ -28,6 +29,7 @@ class TaskListChangedError extends Error {} const TASK_CHECKBOX_LINE_RE = /^[ \t]*-\s\[([ xX])\]\s*(.*)$/; vi.mock("@openspec-ui/core", () => ({ archiveChange: (...args: unknown[]) => archiveChangeMock(...args), + checkChangesetReminder: (...args: unknown[]) => checkChangesetReminderMock(...args), createChange: (...args: unknown[]) => createChangeMock(...args), customizeTemplate: (...args: unknown[]) => customizeTemplateMock(...args), deleteChange: (...args: unknown[]) => deleteChangeMock(...args), @@ -159,6 +161,7 @@ describe("registerCommands", () => { it("archives a confirmed active change and refreshes", async () => { vscodeMock.window.showWarningMessage.mockResolvedValue("Archive"); archiveChangeMock.mockResolvedValue({ ok: true }); + checkChangesetReminderMock.mockResolvedValue({ changesetsAdopted: false, pendingChangesetCount: 0 }); const deps = makeDeps(); registerCommands(makeContext() as unknown as import("vscode").ExtensionContext, deps); @@ -171,6 +174,40 @@ describe("registerCommands", () => { expect(deps.refreshTrees).toHaveBeenCalled(); }); + it("offers to run npx changeset after archiving when Changesets is adopted but nothing is pending", async () => { + vscodeMock.window.showWarningMessage.mockResolvedValue("Archive"); + archiveChangeMock.mockResolvedValue({ ok: true }); + checkChangesetReminderMock.mockResolvedValue({ changesetsAdopted: true, pendingChangesetCount: 0 }); + vscodeMock.window.showInformationMessage.mockResolvedValue("Run npx changeset"); + const deps = makeDeps(); + registerCommands(makeContext() as unknown as import("vscode").ExtensionContext, deps); + + await vscodeMock._registeredCommands.get("openspec-ui.archiveChange")?.({ + changeName: "done-change", + archived: false, + }); + + await vi.waitFor(() => expect(vscodeMock.window.createTerminal).toHaveBeenCalled()); + const terminal = vscodeMock.window.createTerminal.mock.results[0]?.value; + expect(terminal.sendText).toHaveBeenCalledWith("npx changeset", true); + }); + + it("does not offer a changeset reminder when one is already pending", async () => { + vscodeMock.window.showWarningMessage.mockResolvedValue("Archive"); + archiveChangeMock.mockResolvedValue({ ok: true }); + checkChangesetReminderMock.mockResolvedValue({ changesetsAdopted: true, pendingChangesetCount: 1 }); + const deps = makeDeps(); + registerCommands(makeContext() as unknown as import("vscode").ExtensionContext, deps); + + await vscodeMock._registeredCommands.get("openspec-ui.archiveChange")?.({ + changeName: "done-change", + archived: false, + }); + + await vi.waitFor(() => expect(checkChangesetReminderMock).toHaveBeenCalled()); + expect(vscodeMock.window.createTerminal).not.toHaveBeenCalled(); + }); + it("unarchives and deletes only after explicit confirmation", async () => { vscodeMock.window.showWarningMessage .mockResolvedValueOnce("Unarchive") diff --git a/packages/extension/src/commands.ts b/packages/extension/src/commands.ts index a65a029..2641906 100644 --- a/packages/extension/src/commands.ts +++ b/packages/extension/src/commands.ts @@ -10,6 +10,7 @@ import { TemplateAlreadyExistsError, UnknownProjectTemplateError, archiveChange, + checkChangesetReminder, createChange, customizeTemplate, deleteChange, @@ -57,6 +58,30 @@ async function showCommandError(action: string, error: unknown): Promise { await vscode.window.showErrorMessage(`OpenSpec UI: ${action} failed (${message}).`); } +/** Best-effort, non-blocking nudge after a successful archive: if this + * workspace has adopted Changesets but no changeset is currently pending, + * offer to run `npx changeset` in an integrated terminal. Never surfaces an + * error — a failed check silently does nothing, since it must not affect + * the archive operation that already succeeded. */ +async function remindAboutPendingChangeset(workspaceRoot: string): Promise { + try { + const status = await checkChangesetReminder(workspaceRoot); + if (!status.changesetsAdopted || status.pendingChangesetCount > 0) return; + const action = await vscode.window.showInformationMessage( + "OpenSpec UI: this repository uses Changesets, but no pending changeset was found. " + + "If this change affects a published package's version or changelog, add one now.", + "Run npx changeset", + "Dismiss", + ); + if (action !== "Run npx changeset") return; + const terminal = vscode.window.createTerminal({ name: "OpenSpec UI: changeset", cwd: workspaceRoot }); + terminal.show(true); + terminal.sendText("npx changeset", true); + } catch { + // Best-effort only — see the doc comment above. + } +} + async function runTrackedProcess( sessions: ImplementationSessionManager, workspaceRoot: string, @@ -396,6 +421,7 @@ export function registerCommands(context: vscode.ExtensionContext, deps: Command }); deps.refreshTrees(); void vscode.window.showInformationMessage(`OpenSpec UI: archived ${item.changeName}.`); + void remindAboutPendingChangeset(workspaceRoot); } catch (error) { await showCommandError("archive change", error); }