From dd2d552fe68f63f108d140dce8e7a70ee01c5212 Mon Sep 17 00:00:00 2001 From: Tom Brown <58399955+ToruGuy@users.noreply.github.com> Date: Mon, 27 Jul 2026 02:13:33 +0200 Subject: [PATCH] fix: show changes created by OpenSpec 1.0+, and what each one is waiting for MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A change was only recognised if it contained proposal.md. Current OpenSpec writes .openspec.yaml when the change is created and the proposal only once an agent drafts it — so every freshly created change was missing from the board until someone wrote a proposal, which is the exact moment you most want to see it. Verified against OpenSpec 1.6: `openspec new change x` produces a directory with nothing but the marker. Either marker now identifies a change. Then the board says more than "these files exist". Each change reports its artifact chain — proposal, design, specs, tasks — as complete, ready (dependencies met, nothing blocking) or blocked, with the artifacts it waits for. The vocabulary and the dependency graph match `openspec status --change ` output field for field, but everything is derived from the files on disk, so no OpenSpec install is needed in the repos being watched and it stays read-only. Cards mark the next actionable artifact instead of hiding what has not been written yet. Housekeeping the release needed: - CI on push and pull request (frontend build, cargo test, clippy). Nothing verified commits before, which is how 14 clippy warnings and a broken parser assumption accumulated unnoticed. Warnings fixed. - release.yml publishes archives from a v* tag for four targets, adding macOS Intel. The four dispatch-only workflows it replaces built the same binaries but only uploaded them as build artifacts, so cutting a release meant downloading and re-uploading by hand. A workflow_dispatch entry point is kept for builds without a tag. - Removed `readyForReview` from the Change type: declared in the frontend contract, never sent by the backend, never read by any component. - Removed a file literally named `--config` — a shell mishap committed a copy of the example config under that name. - CHANGELOG.md, and VERSION/Cargo.toml stamped 0.2.0. --- --config | 13 - .github/workflows/ci-all.yml | 162 --------- .github/workflows/ci-linux.yml | 115 ------- .github/workflows/ci-macos.yml | 115 ------- .github/workflows/ci-windows.yml | 118 ------- .github/workflows/ci.yml | 40 +++ .github/workflows/release.yml | 119 +++++++ CHANGELOG.md | 32 ++ README.md | 37 ++- VERSION | 2 +- backend/Cargo.toml | 2 +- backend/src/parser.rs | 333 ++++++++++++++----- frontend/src/components/ChangeCard.tsx | 90 +++-- frontend/src/components/KanbanBoard.test.tsx | 8 +- frontend/src/types/index.ts | 16 +- 15 files changed, 555 insertions(+), 647 deletions(-) delete mode 100644 --config delete mode 100644 .github/workflows/ci-all.yml delete mode 100644 .github/workflows/ci-linux.yml delete mode 100644 .github/workflows/ci-macos.yml delete mode 100644 .github/workflows/ci-windows.yml create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/release.yml create mode 100644 CHANGELOG.md diff --git a/--config b/--config deleted file mode 100644 index 7e9bcf6..0000000 --- a/--config +++ /dev/null @@ -1,13 +0,0 @@ -{ - "sources": [ - { - "name": "openspec-ui", - "path": "./openspec" - }, - { - "name": "example", - "path": "./example-openspec" - } - ], - "port": 3000 -} diff --git a/.github/workflows/ci-all.yml b/.github/workflows/ci-all.yml deleted file mode 100644 index 655ea54..0000000 --- a/.github/workflows/ci-all.yml +++ /dev/null @@ -1,162 +0,0 @@ -name: CI - All Platforms - -on: - workflow_dispatch: - inputs: - version: - description: 'Version to build (leave empty to use VERSION file)' - required: false - default: '' - -jobs: - build-and-test: - name: Build and Test on ${{ matrix.os }} - runs-on: ${{ matrix.os }} - strategy: - matrix: - os: [ubuntu-latest, macos-latest, windows-latest] - include: - - os: ubuntu-latest - platform: linux - - os: macos-latest - platform: darwin - - os: windows-latest - platform: windows - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Read version from VERSION file - id: version - shell: pwsh - run: | - if ("${{ github.event.inputs.version }}" -ne "") { - "version=${{ github.event.inputs.version }}" >> $env:GITHUB_OUTPUT - } else { - $version = Get-Content VERSION -Raw - "version=$version" >> $env:GITHUB_OUTPUT - } - - - name: Setup Rust - uses: actions-rs/toolchain@v1 - with: - profile: minimal - toolchain: stable - override: true - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: '20' - - - name: Cache cargo registry - uses: actions/cache@v4 - with: - path: ~/.cargo/registry - key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }} - - - name: Cache cargo index - uses: actions/cache@v4 - with: - path: ~/.cargo/git - key: ${{ runner.os }}-cargo-index-${{ hashFiles('**/Cargo.lock') }} - - - name: Cache cargo build - uses: actions/cache@v4 - with: - path: target - key: ${{ runner.os }}-cargo-build-target-${{ hashFiles('**/Cargo.lock') }} - - - name: Cache node modules - uses: actions/cache@v4 - with: - path: frontend/node_modules - key: ${{ runner.os }}-node-modules-${{ hashFiles('frontend/package-lock.json') }} - - - name: Install frontend dependencies - run: | - cd frontend - npm ci - - - name: Run frontend tests - run: | - cd frontend - npm test -- --run - - - name: Build frontend - run: | - cd frontend - npm run build - - - name: Run backend tests - run: | - cd backend - cargo test --verbose - - - name: Build backend - run: | - cd backend - cargo build --release - - - name: Determine architecture (Unix) - if: runner.os != 'Windows' - id: arch-unix - run: | - ARCH=$(uname -m) - if [ "$ARCH" = "arm64" ]; then - echo "arch=aarch64" >> $GITHUB_OUTPUT - elif [ "$ARCH" = "aarch64" ]; then - echo "arch=aarch64" >> $GITHUB_OUTPUT - else - echo "arch=x86_64" >> $GITHUB_OUTPUT - fi - - - name: Determine architecture (Windows) - if: runner.os == 'Windows' - id: arch-windows - shell: pwsh - run: | - $arch = $env:PROCESSOR_ARCHITECTURE - if ($arch -eq "AMD64") { - "arch=x86_64" >> $env:GITHUB_OUTPUT - } elseif ($arch -eq "ARM64") { - "arch=aarch64" >> $env:GITHUB_OUTPUT - } else { - "arch=$arch" >> $env:GITHUB_OUTPUT - } - - - name: Create release archive (Unix) - if: runner.os != 'Windows' - run: | - mkdir -p release - cp backend/target/release/openspec-ui release/ - cp -r frontend/dist release/frontend - cd release - tar -czf ../openspec-ui-v${{ steps.version.outputs.version }}-${{ matrix.platform }}-${{ steps.arch-unix.outputs.arch }}.tar.gz * - cd .. - - - name: Create release archive (Windows) - if: runner.os == 'Windows' - shell: pwsh - run: | - New-Item -ItemType Directory -Force -Path release - Copy-Item backend\target\release\openspec-ui.exe release\ - Copy-Item -Recurse frontend\dist release\frontend - Compress-Archive -Path release\* -DestinationPath "openspec-ui-v${{ steps.version.outputs.version }}-${{ matrix.platform }}-${{ steps.arch-windows.outputs.arch }}.zip" - - - name: Upload build artifact (Unix) - if: runner.os != 'Windows' - uses: actions/upload-artifact@v4 - with: - name: openspec-ui-v${{ steps.version.outputs.version }}-${{ matrix.platform }}-${{ steps.arch-unix.outputs.arch }} - path: openspec-ui-v${{ steps.version.outputs.version }}-${{ matrix.platform }}-${{ steps.arch-unix.outputs.arch }}.tar.gz - retention-days: 30 - - - name: Upload build artifact (Windows) - if: runner.os == 'Windows' - uses: actions/upload-artifact@v4 - with: - name: openspec-ui-v${{ steps.version.outputs.version }}-${{ matrix.platform }}-${{ steps.arch-windows.outputs.arch }} - path: openspec-ui-v${{ steps.version.outputs.version }}-${{ matrix.platform }}-${{ steps.arch-windows.outputs.arch }}.zip - retention-days: 30 diff --git a/.github/workflows/ci-linux.yml b/.github/workflows/ci-linux.yml deleted file mode 100644 index da65aa8..0000000 --- a/.github/workflows/ci-linux.yml +++ /dev/null @@ -1,115 +0,0 @@ -name: CI - Linux - -on: - workflow_dispatch: - inputs: - version: - description: 'Version to build (leave empty to use VERSION file)' - required: false - default: '' - -jobs: - build-and-test: - name: Build and Test on Linux - runs-on: ubuntu-latest - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Read version from VERSION file - id: version - run: | - if [ -n "${{ github.event.inputs.version }}" ]; then - echo "version=${{ github.event.inputs.version }}" >> $GITHUB_OUTPUT - else - VERSION=$(cat VERSION | tr -d '\n') - echo "version=$VERSION" >> $GITHUB_OUTPUT - fi - - - name: Setup Rust - uses: actions-rs/toolchain@v1 - with: - profile: minimal - toolchain: stable - override: true - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: '20' - - - name: Cache cargo registry - uses: actions/cache@v4 - with: - path: ~/.cargo/registry - key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }} - - - name: Cache cargo index - uses: actions/cache@v4 - with: - path: ~/.cargo/git - key: ${{ runner.os }}-cargo-index-${{ hashFiles('**/Cargo.lock') }} - - - name: Cache cargo build - uses: actions/cache@v4 - with: - path: target - key: ${{ runner.os }}-cargo-build-target-${{ hashFiles('**/Cargo.lock') }} - - - name: Cache node modules - uses: actions/cache@v4 - with: - path: frontend/node_modules - key: ${{ runner.os }}-node-modules-${{ hashFiles('frontend/package-lock.json') }} - - - name: Install frontend dependencies - run: | - cd frontend - npm ci - - - name: Run frontend tests - run: | - cd frontend - npm test -- --run - - - name: Build frontend - run: | - cd frontend - npm run build - - - name: Run backend tests - run: | - cd backend - cargo test --verbose - - - name: Build backend - run: | - cd backend - cargo build --release - - - name: Determine architecture - id: arch - run: | - ARCH=$(uname -m) - if [ "$ARCH" = "aarch64" ]; then - echo "arch=aarch64" >> $GITHUB_OUTPUT - else - echo "arch=x86_64" >> $GITHUB_OUTPUT - fi - - - name: Create release archive - run: | - mkdir -p release - cp backend/target/release/openspec-ui release/ - cp -r frontend/dist release/frontend - cd release - tar -czf ../openspec-ui-v${{ steps.version.outputs.version }}-linux-${{ steps.arch.outputs.arch }}.tar.gz * - cd .. - - - name: Upload build artifact - uses: actions/upload-artifact@v4 - with: - name: openspec-ui-v${{ steps.version.outputs.version }}-linux-${{ steps.arch.outputs.arch }} - path: openspec-ui-v${{ steps.version.outputs.version }}-linux-${{ steps.arch.outputs.arch }}.tar.gz - retention-days: 30 diff --git a/.github/workflows/ci-macos.yml b/.github/workflows/ci-macos.yml deleted file mode 100644 index 6ab7fcb..0000000 --- a/.github/workflows/ci-macos.yml +++ /dev/null @@ -1,115 +0,0 @@ -name: CI - macOS - -on: - workflow_dispatch: - inputs: - version: - description: 'Version to build (leave empty to use VERSION file)' - required: false - default: '' - -jobs: - build-and-test: - name: Build and Test on macOS - runs-on: macos-latest - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Read version from VERSION file - id: version - run: | - if [ -n "${{ github.event.inputs.version }}" ]; then - echo "version=${{ github.event.inputs.version }}" >> $GITHUB_OUTPUT - else - VERSION=$(cat VERSION | tr -d '\n') - echo "version=$VERSION" >> $GITHUB_OUTPUT - fi - - - name: Setup Rust - uses: actions-rs/toolchain@v1 - with: - profile: minimal - toolchain: stable - override: true - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: '20' - - - name: Cache cargo registry - uses: actions/cache@v4 - with: - path: ~/.cargo/registry - key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }} - - - name: Cache cargo index - uses: actions/cache@v4 - with: - path: ~/.cargo/git - key: ${{ runner.os }}-cargo-index-${{ hashFiles('**/Cargo.lock') }} - - - name: Cache cargo build - uses: actions/cache@v4 - with: - path: target - key: ${{ runner.os }}-cargo-build-target-${{ hashFiles('**/Cargo.lock') }} - - - name: Cache node modules - uses: actions/cache@v4 - with: - path: frontend/node_modules - key: ${{ runner.os }}-node-modules-${{ hashFiles('frontend/package-lock.json') }} - - - name: Install frontend dependencies - run: | - cd frontend - npm ci - - - name: Run frontend tests - run: | - cd frontend - npm test -- --run - - - name: Build frontend - run: | - cd frontend - npm run build - - - name: Run backend tests - run: | - cd backend - cargo test --verbose - - - name: Build backend - run: | - cd backend - cargo build --release - - - name: Determine architecture - id: arch - run: | - ARCH=$(uname -m) - if [ "$ARCH" = "arm64" ]; then - echo "arch=aarch64" >> $GITHUB_OUTPUT - else - echo "arch=x86_64" >> $GITHUB_OUTPUT - fi - - - name: Create release archive - run: | - mkdir -p release - cp backend/target/release/openspec-ui release/ - cp -r frontend/dist release/frontend - cd release - tar -czf ../openspec-ui-v${{ steps.version.outputs.version }}-darwin-${{ steps.arch.outputs.arch }}.tar.gz * - cd .. - - - name: Upload build artifact - uses: actions/upload-artifact@v4 - with: - name: openspec-ui-v${{ steps.version.outputs.version }}-darwin-${{ steps.arch.outputs.arch }} - path: openspec-ui-v${{ steps.version.outputs.version }}-darwin-${{ steps.arch.outputs.arch }}.tar.gz - retention-days: 30 diff --git a/.github/workflows/ci-windows.yml b/.github/workflows/ci-windows.yml deleted file mode 100644 index 6777b5a..0000000 --- a/.github/workflows/ci-windows.yml +++ /dev/null @@ -1,118 +0,0 @@ -name: CI - Windows - -on: - workflow_dispatch: - inputs: - version: - description: 'Version to build (leave empty to use VERSION file)' - required: false - default: '' - -jobs: - build-and-test: - name: Build and Test on Windows - runs-on: windows-latest - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Read version from VERSION file - id: version - shell: pwsh - run: | - if ("${{ github.event.inputs.version }}" -ne "") { - "version=${{ github.event.inputs.version }}" >> $env:GITHUB_OUTPUT - } else { - $version = Get-Content VERSION -Raw - "version=$version" >> $env:GITHUB_OUTPUT - } - - - name: Setup Rust - uses: actions-rs/toolchain@v1 - with: - profile: minimal - toolchain: stable - override: true - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: '20' - - - name: Cache cargo registry - uses: actions/cache@v4 - with: - path: ~/.cargo/registry - key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }} - - - name: Cache cargo index - uses: actions/cache@v4 - with: - path: ~/.cargo/git - key: ${{ runner.os }}-cargo-index-${{ hashFiles('**/Cargo.lock') }} - - - name: Cache cargo build - uses: actions/cache@v4 - with: - path: target - key: ${{ runner.os }}-cargo-build-target-${{ hashFiles('**/Cargo.lock') }} - - - name: Cache node modules - uses: actions/cache@v4 - with: - path: frontend/node_modules - key: ${{ runner.os }}-node-modules-${{ hashFiles('frontend/package-lock.json') }} - - - name: Install frontend dependencies - run: | - cd frontend - npm ci - - - name: Run frontend tests - run: | - cd frontend - npm test -- --run - - - name: Build frontend - run: | - cd frontend - npm run build - - - name: Run backend tests - run: | - cd backend - cargo test --verbose - - - name: Build backend - run: | - cd backend - cargo build --release - - - name: Determine architecture - id: arch - shell: pwsh - run: | - $arch = $env:PROCESSOR_ARCHITECTURE - if ($arch -eq "AMD64") { - "arch=x86_64" >> $env:GITHUB_OUTPUT - } elseif ($arch -eq "ARM64") { - "arch=aarch64" >> $env:GITHUB_OUTPUT - } else { - "arch=$arch" >> $env:GITHUB_OUTPUT - } - - - name: Create release archive - shell: pwsh - run: | - New-Item -ItemType Directory -Force -Path release - Copy-Item backend\target\release\openspec-ui.exe release\ - Copy-Item -Recurse frontend\dist release\frontend - Compress-Archive -Path release\* -DestinationPath "openspec-ui-v${{ steps.version.outputs.version }}-windows-${{ steps.arch.outputs.arch }}.zip" - - - name: Upload build artifact - uses: actions/upload-artifact@v4 - with: - name: openspec-ui-v${{ steps.version.outputs.version }}-windows-${{ steps.arch.outputs.arch }} - path: openspec-ui-v${{ steps.version.outputs.version }}-windows-${{ steps.arch.outputs.arch }}.zip - retention-days: 30 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..6d4e251 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,40 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + - uses: actions/setup-node@v6 + with: + node-version: 22 + cache: npm + cache-dependency-path: frontend/package-lock.json + + - name: Build frontend + working-directory: frontend + run: | + npm ci + npm run build + + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy + + - uses: Swatinem/rust-cache@v2 + with: + workspaces: backend + + - name: Test backend + working-directory: backend + run: cargo test + + - name: Lint backend + working-directory: backend + run: cargo clippy -- -D warnings diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..aaa4652 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,119 @@ +name: Release + +on: + push: + tags: ['v*'] + workflow_dispatch: + inputs: + tag: + description: 'Tag to build (e.g. v0.2.0)' + required: true + +permissions: + contents: write + +jobs: + build: + name: ${{ matrix.label }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - os: macos-latest + target: aarch64-apple-darwin + label: macOS (Apple Silicon) + asset: darwin-aarch64 + - os: macos-latest + target: x86_64-apple-darwin + label: macOS (Intel) + asset: darwin-x86_64 + - os: ubuntu-latest + target: x86_64-unknown-linux-gnu + label: Linux (x86_64) + asset: linux-x86_64 + - os: windows-latest + target: x86_64-pc-windows-msvc + label: Windows (x86_64) + asset: windows-x86_64 + + steps: + - uses: actions/checkout@v5 + with: + ref: ${{ github.event.inputs.tag || github.ref }} + + - uses: actions/setup-node@v6 + with: + node-version: 22 + cache: npm + cache-dependency-path: frontend/package-lock.json + + # The frontend is embedded into the binary via rust-embed, so it must be + # built before cargo runs — otherwise ../frontend/dist is empty and the + # binary serves nothing. + - name: Build frontend + working-directory: frontend + run: | + npm ci + npm run build + + - uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + + - uses: Swatinem/rust-cache@v2 + with: + workspaces: backend + key: ${{ matrix.target }} + + - name: Build binary + working-directory: backend + run: cargo build --release --target ${{ matrix.target }} + + - name: Package (unix) + if: runner.os != 'Windows' + run: | + VERSION="${{ github.event.inputs.tag || github.ref_name }}" + STAGE="openspec-ui-${VERSION}-${{ matrix.asset }}" + mkdir -p "$STAGE" + cp "backend/target/${{ matrix.target }}/release/openspec-ui" "$STAGE/" + cp README.md LICENSE "$STAGE/" 2>/dev/null || cp README.md "$STAGE/" + zip -r "${STAGE}.zip" "$STAGE" + + - name: Package (windows) + if: runner.os == 'Windows' + shell: pwsh + run: | + $version = if ("${{ github.event.inputs.tag }}") { "${{ github.event.inputs.tag }}" } else { "${{ github.ref_name }}" } + $stage = "openspec-ui-$version-${{ matrix.asset }}" + New-Item -ItemType Directory -Path $stage | Out-Null + Copy-Item "backend/target/${{ matrix.target }}/release/openspec-ui.exe" $stage + Copy-Item README.md $stage + if (Test-Path LICENSE) { Copy-Item LICENSE $stage } + Compress-Archive -Path $stage -DestinationPath "$stage.zip" + + - uses: actions/upload-artifact@v5 + with: + name: ${{ matrix.asset }} + path: '*.zip' + + release: + needs: build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + - uses: actions/download-artifact@v6 + with: + path: dist + merge-multiple: true + + - name: Publish release + env: + GH_TOKEN: ${{ github.token }} + run: | + VERSION="${{ github.event.inputs.tag || github.ref_name }}" + gh release create "$VERSION" dist/*.zip \ + --title "$VERSION" \ + --notes-from-tag \ + || gh release upload "$VERSION" dist/*.zip --clobber diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..e5d7ed7 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,32 @@ +# Changelog + +All notable changes to this project. Format: [Keep a Changelog](https://keepachangelog.com); scheme: [SemVer](https://semver.org). + +The version number lives in `VERSION` and is stamped on `main` at merge — pull requests add entries under `[Unreleased]` and leave the number alone. + +## [Unreleased] + +## [0.2.0] - 2026-07-27 + +### Fixed +- **Changes created by OpenSpec 1.0+ were invisible on the board.** A directory was only treated as a change if it contained `proposal.md`, but current OpenSpec writes `.openspec.yaml` when the change is created and the proposal only once an agent drafts it. Every freshly created change was therefore missing from the dashboard until someone wrote a proposal — the exact moment you most want to see it. Either marker now identifies a change. + +### Added +- **Artifact chain per change.** Each change reports `proposal`, `design`, `specs` and `tasks` as `complete`, `ready` (dependencies met, not written yet) or `blocked`, with the artifacts it is waiting for. Vocabulary and dependency graph match `openspec status --change `, verified against OpenSpec 1.6, but are computed from the files on disk — so no OpenSpec install is needed in the repos being watched. Exposed on `/api/changes` and `/api/changes/{id}`, and rendered on each card with the next actionable artifact marked. +- CI on every push and pull request: frontend build, `cargo test`, `cargo clippy -D warnings`. Nothing verified commits before this. +- `release.yml` builds and publishes release archives from a `v*` tag for four targets (adds macOS Intel), replacing manual assembly from workflow artifacts. +- `CHANGELOG.md`, following the ToruAI versioning standard. + +### Changed +- Cards show the whole workflow chain instead of badges for artifacts that happen to exist, so a glance answers "what is next here" rather than only "what has been written". +- Consolidated four dispatch-only workflows (`ci-all`, `ci-linux`, `ci-macos`, `ci-windows`) into `ci.yml` + `release.yml`. They built the same binaries but only uploaded them as build artifacts, so publishing a release meant downloading and re-uploading by hand. `release.yml` keeps a `workflow_dispatch` entry point for builds without a tag. +- README leads with what the dashboard answers and states OpenSpec version compatibility; download table no longer hard-codes `v0.1.0`. + +### Removed +- `readyForReview` from the `Change` type: declared in the frontend contract, never sent by the backend, never read by any component. + +## [0.1.0] - 2026-01-07 + +### Added +- Multi-repo kanban dashboard for OpenSpec changes, with ideas capture, specs browser, detail view and SSE live refresh. +- Single-binary distribution (embedded frontend) for macOS, Linux and Windows; Docker image. diff --git a/README.md b/README.md index ed98c12..a7d0a4d 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,12 @@ -# OpenSpec UI +# OpenSpec UI — a web dashboard for OpenSpec -A dashboard for tracking [OpenSpec](https://github.com/Fission-AI/OpenSpec) changes across multiple repositories. Built for agentic development workflows. +[![CI](https://github.com/ToruAI/openspec-ui/actions/workflows/ci.yml/badge.svg)](https://github.com/ToruAI/openspec-ui/actions/workflows/ci.yml) +[![OpenSpec 1.6](https://img.shields.io/badge/OpenSpec-1.6%20compatible-blue)](https://github.com/Fission-AI/OpenSpec) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) + +**A single kanban board over every [OpenSpec](https://github.com/Fission-AI/OpenSpec) repo you work in** — which change is where in the workflow, what is ready to write next, and what is still blocked. `openspec view` shows you one repo in the terminal; this shows all of them, in a browser, on your phone too. + +Read-only, runs locally, one binary. It never writes to your specs.

OpenSpec UI Desktop @@ -31,10 +37,13 @@ OpenSpec UI reads this structure and displays it as a kanban board. OpenSpec UI gives you a bird's-eye view of all your AI-assisted projects: -- **Capture ideas** — Quick-capture thoughts that AI agents can later expand into proposals -- **Track progress** — Watch changes move from Ideas → Todo → In Progress → Done -- **Multi-repo visibility** — Monitor multiple OpenSpec repositories from one dashboard -- **Real-time updates** — Auto-refreshes as your agents work through tasks +- **Artifact chain per change** — see `proposal → design → specs → tasks` with each one marked written, **ready to write next**, or blocked and waiting on another artifact. Same vocabulary as `openspec status`, read straight from disk. +- **Multi-repo visibility** — monitor every OpenSpec repository from one board +- **Capture ideas** — quick-capture thoughts that AI agents can later expand into proposals +- **Track progress** — watch changes move from Ideas → Todo → In Progress → Done +- **Real-time updates** — auto-refreshes as your agents work through tasks + +Because the artifact states are derived from the files themselves, the dashboard needs no OpenSpec install in the repos it watches, and it works the same whether a change was created by you or by an agent. ## The Workflow @@ -59,9 +68,10 @@ Download the latest release from [GitHub Releases](https://github.com/ToruAI/ope | Platform | File | |----------|------| -| macOS (Apple Silicon) | `openspec-ui-v0.1.0-darwin-aarch64.zip` | -| Linux (x86_64) | `openspec-ui-v0.1.0-linux-x86_64.zip` | -| Windows (x86_64) | `openspec-ui-v0.1.0-windows-x86_64.zip` | +| macOS (Apple Silicon) | `openspec-ui--darwin-aarch64.zip` | +| macOS (Intel) | `openspec-ui--darwin-x86_64.zip` | +| Linux (x86_64) | `openspec-ui--linux-x86_64.zip` | +| Windows (x86_64) | `openspec-ui--windows-x86_64.zip` | ```bash # Extract and run (Linux/macOS) @@ -118,12 +128,21 @@ Create `openspec-ui.json`: ## Features - **Kanban Board** — Ideas, Todo, In Progress, Done, Archived columns +- **Artifact chain** — per change: written / ready to write / blocked, with what it is waiting for - **Specs Browser** — Browse specifications across all repositories - **Detail View** — View proposals, specs, tasks, and design documents - **Real-time Updates** — Auto-refreshes when files change (SSE) - **Mobile-first** — Works great on phone and tablet - **Light/Dark Theme** — Toggle between themes +## OpenSpec compatibility + +Tested against **OpenSpec 1.6**, and still reads the pre-1.0 layout. + +A change is recognised either by its `.openspec.yaml` marker (written by `openspec new change` from 1.0 onward) or by a `proposal.md` (older layout). This matters: OpenSpec creates the marker first and the proposal only once your agent drafts it, so a change that exists but has no proposal yet is still a change — and shows on the board as `proposal: ready` instead of being invisible. + +Artifact states follow the `spec-driven` schema — proposal gates design and specs, which together gate tasks — and are computed from the files on disk, so the values match `openspec status --change ` without shelling out to the CLI. + ## Tech Stack - **Frontend**: React + TypeScript + Tailwind CSS + shadcn/ui diff --git a/VERSION b/VERSION index 6e8bf73..0ea3a94 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.0 +0.2.0 diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 1dc35d6..9ee463b 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "openspec-ui" -version = "0.1.0" +version = "0.2.0" edition = "2021" authors = ["ToruGuy"] repository = "https://github.com/ToruAI/openspec-ui" diff --git a/backend/src/parser.rs b/backend/src/parser.rs index 8057934..fb5f4d2 100644 --- a/backend/src/parser.rs +++ b/backend/src/parser.rs @@ -19,6 +19,26 @@ pub enum ChangeStatus { Archived, } +/// State of one artifact in a change, using the same vocabulary as +/// `openspec status`: complete (written), ready (deps met, not written yet), +/// blocked (waiting on other artifacts). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ArtifactState { + Complete, + Ready, + Blocked, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Artifact { + pub id: String, + pub state: ArtifactState, + /// Artifacts this one waits for, when blocked + pub missing_deps: Vec, +} + #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct Change { @@ -31,6 +51,10 @@ pub struct Change { pub has_tasks: bool, pub has_design: bool, pub task_stats: Option, + /// Workflow schema from .openspec.yaml (OpenSpec >= 1.0), e.g. "spec-driven" + pub schema: Option, + /// Per-artifact progress through the change's workflow + pub artifacts: Vec, } #[derive(Debug, Clone, Serialize)] @@ -44,6 +68,8 @@ pub struct ChangeDetail { pub design: Option, pub specs: Vec, pub tasks: Option, + pub schema: Option, + pub artifacts: Vec, } #[derive(Debug, Clone, Serialize)] @@ -100,7 +126,7 @@ struct IdeaFrontmatter { fn parse_idea_frontmatter(content: &str) -> Option { let lines: Vec<&str> = content.lines().collect(); - if !lines.get(0).map(|l| l.trim() == "---").unwrap_or(false) { + if !lines.first().map(|l| l.trim() == "---").unwrap_or(false) { return None; } @@ -172,6 +198,71 @@ pub fn parse_task_stats(content: &str) -> TaskStats { } } +/// Read the workflow schema from a change's `.openspec.yaml`. +/// +/// OpenSpec >= 1.0 marks every change directory with this file the moment it is +/// created, before any artifact is written. It is the only reliable signal that a +/// directory is a change rather than stray content. +fn read_change_schema(change_path: &Path) -> Option { + let content = std::fs::read_to_string(change_path.join(".openspec.yaml")).ok()?; + for line in content.lines() { + if let Some(value) = line.strip_prefix("schema:") { + let value = value.trim(); + if !value.is_empty() { + return Some(value.to_string()); + } + } + } + // Present but without a readable schema key: still a change. + Some("spec-driven".to_string()) +} + +/// Build the artifact chain for the spec-driven workflow: proposal gates design +/// and specs, which together gate tasks. Computed from the files on disk so the +/// dashboard stays read-only and needs no OpenSpec install per repo. +fn compute_artifacts( + has_proposal: bool, + has_design: bool, + has_specs: bool, + has_tasks: bool, +) -> Vec { + let state = |present: bool, missing: Vec<&str>| { + if present { + (ArtifactState::Complete, Vec::new()) + } else if missing.is_empty() { + (ArtifactState::Ready, Vec::new()) + } else { + ( + ArtifactState::Blocked, + missing.into_iter().map(String::from).collect(), + ) + } + }; + + let blocked_by_proposal = if has_proposal { vec![] } else { vec!["proposal"] }; + let mut blocked_by_both = Vec::new(); + if !has_design { + blocked_by_both.push("design"); + } + if !has_specs { + blocked_by_both.push("specs"); + } + + [ + ("proposal", state(has_proposal, vec![])), + ("design", state(has_design, blocked_by_proposal.clone())), + ("specs", state(has_specs, blocked_by_proposal)), + ("tasks", state(has_tasks, blocked_by_both)), + ] + .into_iter() + .map(|(id, (state, missing_deps))| Artifact { + id: id.to_string(), + state, + missing_deps, + }) + .collect() +} + /// Compute change status from artifacts and task stats fn compute_status(has_tasks: bool, task_stats: &Option, is_archived: bool) -> ChangeStatus { if is_archived { @@ -205,9 +296,13 @@ fn scan_change(change_path: &Path, source_id: &str, is_archived: bool) -> Option let has_tasks = tasks_path.exists(); let has_design = design_path.exists(); let has_specs = specs_path.exists() && specs_path.is_dir(); + let schema = read_change_schema(change_path); - // Only include if it has at least a proposal - if !has_proposal { + // A directory is a change if OpenSpec marked it (.openspec.yaml, >= 1.0) or it + // already has a proposal (pre-1.0 layout). Requiring a proposal alone hid every + // freshly created change, since OpenSpec writes the marker first and the + // proposal only once the agent drafts it. + if schema.is_none() && !has_proposal { return None; } @@ -231,6 +326,8 @@ fn scan_change(change_path: &Path, source_id: &str, is_archived: bool) -> Option has_tasks, has_design, task_stats, + schema, + artifacts: compute_artifacts(has_proposal, has_design, has_specs, has_tasks), }) } @@ -244,31 +341,27 @@ pub fn scan_changes(source_path: &Path, source_id: &str) -> Vec { } // Scan active changes - for entry in std::fs::read_dir(&changes_path).into_iter().flatten() { - if let Ok(entry) = entry { - let path = entry.path(); - let name = path.file_name().and_then(|n| n.to_str()).unwrap_or(""); + for entry in std::fs::read_dir(&changes_path).into_iter().flatten().flatten() { + let path = entry.path(); + let name = path.file_name().and_then(|n| n.to_str()).unwrap_or(""); - // Skip archive directory - if name == "archive" { - continue; - } + // Skip archive directory + if name == "archive" { + continue; + } - if let Some(change) = scan_change(&path, source_id, false) { - changes.push(change); - } + if let Some(change) = scan_change(&path, source_id, false) { + changes.push(change); } } // Scan archived changes let archive_path = changes_path.join("archive"); if archive_path.exists() { - for entry in std::fs::read_dir(&archive_path).into_iter().flatten() { - if let Ok(entry) = entry { - let path = entry.path(); - if let Some(change) = scan_change(&path, source_id, true) { - changes.push(change); - } + for entry in std::fs::read_dir(&archive_path).into_iter().flatten().flatten() { + let path = entry.path(); + if let Some(change) = scan_change(&path, source_id, true) { + changes.push(change); } } } @@ -286,15 +379,13 @@ pub fn get_change_detail(source_path: &Path, source_id: &str, change_name: &str) // Try archive - need to search for name ending let archive_path = source_path.join("changes").join("archive"); if archive_path.exists() { - for entry in std::fs::read_dir(&archive_path).into_iter().flatten() { - if let Ok(entry) = entry { - let name = entry.file_name(); - let name_str = name.to_string_lossy(); - if name_str.ends_with(change_name) || name_str == change_name { - change_path = entry.path(); - is_archived = true; - break; - } + for entry in std::fs::read_dir(&archive_path).into_iter().flatten().flatten() { + let name = entry.file_name(); + let name_str = name.to_string_lossy(); + if name_str.ends_with(change_name) || name_str == change_name { + change_path = entry.path(); + is_archived = true; + break; } } } @@ -324,23 +415,27 @@ pub fn get_change_detail(source_path: &Path, source_id: &str, change_name: &str) // Scan specs within the change let mut specs = Vec::new(); if specs_path.exists() { - for entry in WalkDir::new(&specs_path).min_depth(1) { - if let Ok(entry) = entry { - let path = entry.path(); - if path.is_file() && path.extension().map_or(false, |e| e == "md") { - let relative = path.strip_prefix(&specs_path).unwrap_or(path); - if let Ok(content) = std::fs::read_to_string(path) { - specs.push(SpecContent { - path: relative.display().to_string(), - content, - }); - } + for entry in WalkDir::new(&specs_path).min_depth(1).into_iter().flatten() { + let path = entry.path(); + if path.is_file() && path.extension().is_some_and(|e| e == "md") { + let relative = path.strip_prefix(&specs_path).unwrap_or(path); + if let Ok(content) = std::fs::read_to_string(path) { + specs.push(SpecContent { + path: relative.display().to_string(), + content, + }); } } } } let name = change_path.file_name()?.to_str()?.to_string(); + let artifacts = compute_artifacts( + proposal.is_some(), + design.is_some(), + !specs.is_empty(), + has_tasks, + ); Some(ChangeDetail { id: format!("{}/{}", source_id, change_name), @@ -351,6 +446,8 @@ pub fn get_change_detail(source_path: &Path, source_id: &str, change_name: &str) design, specs, tasks, + schema: read_change_schema(&change_path), + artifacts, }) } @@ -360,39 +457,35 @@ pub fn scan_specs(source_path: &Path, source_id: &str) -> Vec { let specs_path = source_path.join("specs"); // Include root-level markdown files - for entry in std::fs::read_dir(source_path).into_iter().flatten() { - if let Ok(entry) = entry { - let path = entry.path(); - if path.is_file() && path.extension().map_or(false, |e| e == "md") { - let name = path.file_name().and_then(|n| n.to_str()).unwrap_or(""); - // Skip common change files and changes directory - if name != "proposal.md" && name != "tasks.md" && name != "design.md" && name != "changes" { - let id = format!("{}/{}", source_id, name.replace(".md", "")); - specs.push(Spec { - id, - source_id: source_id.to_string(), - path: name.to_string(), - }); - } + for entry in std::fs::read_dir(source_path).into_iter().flatten().flatten() { + let path = entry.path(); + if path.is_file() && path.extension().is_some_and(|e| e == "md") { + let name = path.file_name().and_then(|n| n.to_str()).unwrap_or(""); + // Skip common change files and changes directory + if name != "proposal.md" && name != "tasks.md" && name != "design.md" && name != "changes" { + let id = format!("{}/{}", source_id, name.replace(".md", "")); + specs.push(Spec { + id, + source_id: source_id.to_string(), + path: name.to_string(), + }); } } } // Scan specs/ directory if specs_path.exists() { - for entry in WalkDir::new(&specs_path).min_depth(1) { - if let Ok(entry) = entry { - let path = entry.path(); - if path.is_file() && path.extension().map_or(false, |e| e == "md") { - let relative = path.strip_prefix(&specs_path).unwrap_or(path); - let path_str = relative.display().to_string(); - let id = format!("{}/{}", source_id, path_str.replace("/spec.md", "").replace(".md", "")); - specs.push(Spec { - id, - source_id: source_id.to_string(), - path: path_str, - }); - } + for entry in WalkDir::new(&specs_path).min_depth(1).into_iter().flatten() { + let path = entry.path(); + if path.is_file() && path.extension().is_some_and(|e| e == "md") { + let relative = path.strip_prefix(&specs_path).unwrap_or(path); + let path_str = relative.display().to_string(); + let id = format!("{}/{}", source_id, path_str.replace("/spec.md", "").replace(".md", "")); + specs.push(Spec { + id, + source_id: source_id.to_string(), + path: path_str, + }); } } } @@ -433,24 +526,22 @@ pub fn scan_ideas(source_path: &Path, source_id: &str) -> Vec { return ideas; } - for entry in std::fs::read_dir(&ideas_path).into_iter().flatten() { - if let Ok(entry) = entry { - let path = entry.path(); - - if path.is_file() && path.extension().map_or(false, |e| e == "md") { - if let Some(content) = std::fs::read_to_string(&path).ok() { - if let Some(frontmatter) = parse_idea_frontmatter(&content) { - let (title, description) = extract_idea_title_and_description(&content); - ideas.push(Idea { - id: format!("{}/{}", source_id, frontmatter.id), - source_id: source_id.to_string(), - project_id: frontmatter.project_id, - title, - description, - created_at: frontmatter.created_at, - updated_at: frontmatter.updated_at, - }); - } + for entry in std::fs::read_dir(&ideas_path).into_iter().flatten().flatten() { + let path = entry.path(); + + if path.is_file() && path.extension().is_some_and(|e| e == "md") { + if let Ok(content) = std::fs::read_to_string(&path) { + if let Some(frontmatter) = parse_idea_frontmatter(&content) { + let (title, description) = extract_idea_title_and_description(&content); + ideas.push(Idea { + id: format!("{}/{}", source_id, frontmatter.id), + source_id: source_id.to_string(), + project_id: frontmatter.project_id, + title, + description, + created_at: frontmatter.created_at, + updated_at: frontmatter.updated_at, + }); } } } @@ -613,6 +704,78 @@ Description"#; assert_eq!(stats.done, 2); } + #[test] + fn test_compute_artifacts_empty_change() { + // A change OpenSpec just created: marker only, nothing written yet. + let artifacts = compute_artifacts(false, false, false, false); + let by_id = |id: &str| artifacts.iter().find(|a| a.id == id).unwrap().clone(); + + assert_eq!(by_id("proposal").state, ArtifactState::Ready); + assert_eq!(by_id("design").state, ArtifactState::Blocked); + assert_eq!(by_id("design").missing_deps, vec!["proposal"]); + assert_eq!(by_id("tasks").state, ArtifactState::Blocked); + assert_eq!(by_id("tasks").missing_deps, vec!["design", "specs"]); + } + + #[test] + fn test_compute_artifacts_unblocks_after_proposal() { + let artifacts = compute_artifacts(true, false, false, false); + let by_id = |id: &str| artifacts.iter().find(|a| a.id == id).unwrap().clone(); + + assert_eq!(by_id("proposal").state, ArtifactState::Complete); + assert_eq!(by_id("design").state, ArtifactState::Ready); + assert_eq!(by_id("specs").state, ArtifactState::Ready); + // tasks still waits for both + assert_eq!(by_id("tasks").state, ArtifactState::Blocked); + assert_eq!(by_id("tasks").missing_deps, vec!["design", "specs"]); + } + + #[test] + fn test_compute_artifacts_all_written() { + let artifacts = compute_artifacts(true, true, true, true); + assert!(artifacts.iter().all(|a| a.state == ArtifactState::Complete)); + assert!(artifacts.iter().all(|a| a.missing_deps.is_empty())); + } + + #[test] + fn test_read_change_schema() { + let dir = std::env::temp_dir().join(format!("ospec-ui-schema-{}", std::process::id())); + let change = dir.join("changes").join("some-change"); + std::fs::create_dir_all(&change).unwrap(); + + // No marker: not recognised as a change by itself + assert_eq!(read_change_schema(&change), None); + + std::fs::write( + change.join(".openspec.yaml"), + "schema: spec-driven\ncreated: 2026-07-26\n", + ) + .unwrap(); + assert_eq!(read_change_schema(&change).as_deref(), Some("spec-driven")); + + // Marker without a readable schema key still counts as a change + std::fs::write(change.join(".openspec.yaml"), "created: 2026-07-26\n").unwrap(); + assert_eq!(read_change_schema(&change).as_deref(), Some("spec-driven")); + + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn test_scan_change_finds_marker_only_change() { + let dir = std::env::temp_dir().join(format!("ospec-ui-scan-{}", std::process::id())); + let change = dir.join("changes").join("fresh-change"); + std::fs::create_dir_all(&change).unwrap(); + std::fs::write(change.join(".openspec.yaml"), "schema: spec-driven\n").unwrap(); + + let found = scan_change(&change, "repo", false).expect("marker-only change must be visible"); + assert_eq!(found.name, "fresh-change"); + assert_eq!(found.status, ChangeStatus::Draft); + assert!(!found.has_proposal); + assert_eq!(found.schema.as_deref(), Some("spec-driven")); + + std::fs::remove_dir_all(&dir).ok(); + } + #[test] fn test_compute_status() { // No tasks = Draft diff --git a/frontend/src/components/ChangeCard.tsx b/frontend/src/components/ChangeCard.tsx index 25eb9d1..d256af9 100644 --- a/frontend/src/components/ChangeCard.tsx +++ b/frontend/src/components/ChangeCard.tsx @@ -1,4 +1,4 @@ -import type { Change } from '../types'; +import type { Change, Artifact } from '../types'; import { Card, CardContent } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { FileText, Layers, CheckSquare, Palette } from 'lucide-react'; @@ -9,10 +9,47 @@ interface ChangeCardProps { onClick: () => void; } +const ARTIFACTS: { id: string; label: string; Icon: typeof FileText; tone: string }[] = [ + { id: 'proposal', label: 'Proposal', Icon: FileText, tone: 'bg-blue-500/10 text-blue-500 dark:text-blue-400' }, + { id: 'design', label: 'Design', Icon: Palette, tone: 'bg-purple-500/10 text-purple-500 dark:text-purple-400' }, + { id: 'specs', label: 'Specs', Icon: Layers, tone: 'bg-emerald-500/10 text-emerald-500 dark:text-emerald-400' }, + { id: 'tasks', label: 'Tasks', Icon: CheckSquare, tone: 'bg-amber-500/10 text-amber-500 dark:text-amber-400' }, +]; + +/** Fall back to the boolean flags when a source is served by an older backend. */ +function artifactStates(change: Change): Record { + const states: Record = {}; + + if (change.artifacts?.length) { + for (const artifact of change.artifacts) states[artifact.id] = artifact; + return states; + } + + const present: Record = { + proposal: change.hasProposal, + design: change.hasDesign, + specs: change.hasSpecs, + tasks: change.hasTasks, + }; + for (const { id } of ARTIFACTS) { + states[id] = { id, state: present[id] ? 'complete' : 'blocked', missingDeps: [] }; + } + return states; +} + +function artifactTitle(artifact: Artifact): string { + if (artifact.state === 'complete') return 'Written'; + if (artifact.state === 'ready') return 'Ready to write — nothing blocking it'; + return artifact.missingDeps.length > 0 + ? `Waiting for ${artifact.missingDeps.join(' and ')}` + : 'Not written yet'; +} + export function ChangeCard({ change, onClick }: ChangeCardProps) { const taskStats = change.taskStats; const progress = taskStats && taskStats.total > 0 ? (taskStats.done / taskStats.total) * 100 : 0; const isComplete = progress === 100; + const states = artifactStates(change); return ( )} - {/* Document indicators */} + {/* Artifact chain: written, ready to write, or waiting on another artifact */}

- {change.hasProposal && ( -
- - Proposal -
- )} - {change.hasSpecs && ( -
- - Specs -
- )} - {change.hasTasks && ( -
- - Tasks -
- )} - {change.hasDesign && ( -
- - Design -
- )} + {ARTIFACTS.map(({ id, label, Icon, tone }) => { + const artifact = states[id]; + if (!artifact) return null; + const isWritten = artifact.state === 'complete'; + const isReady = artifact.state === 'ready'; + + return ( +
+ + {label} + {isReady && next} +
+ ); + })}
diff --git a/frontend/src/components/KanbanBoard.test.tsx b/frontend/src/components/KanbanBoard.test.tsx index d77d998..e436dd0 100644 --- a/frontend/src/components/KanbanBoard.test.tsx +++ b/frontend/src/components/KanbanBoard.test.tsx @@ -15,7 +15,13 @@ describe('KanbanBoard', () => { hasSpecs: true, hasTasks: true, hasDesign: false, - readyForReview: false, + schema: 'spec-driven', + artifacts: [ + { id: 'proposal', state: 'complete', missingDeps: [] }, + { id: 'design', state: 'ready', missingDeps: [] }, + { id: 'specs', state: 'complete', missingDeps: [] }, + { id: 'tasks', state: 'complete', missingDeps: [] }, + ], }, ]; diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index ae447fd..1af9abf 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -12,6 +12,16 @@ export interface TaskStats { export type ChangeStatus = 'draft' | 'todo' | 'in_progress' | 'done' | 'archived'; +/** Same vocabulary as `openspec status`. */ +export type ArtifactState = 'complete' | 'ready' | 'blocked'; + +export interface Artifact { + id: string; + state: ArtifactState; + /** Artifacts this one waits for, when blocked */ + missingDeps: string[]; +} + export interface Change { id: string; name: string; @@ -22,7 +32,9 @@ export interface Change { hasTasks: boolean; hasDesign: boolean; taskStats: TaskStats | null; - readyForReview: boolean; + /** Workflow schema from .openspec.yaml (OpenSpec >= 1.0) */ + schema: string | null; + artifacts: Artifact[]; } export interface SpecContent { @@ -44,6 +56,8 @@ export interface ChangeDetail { design: string | null; specs: SpecContent[]; tasks: TasksContent | null; + schema: string | null; + artifacts: Artifact[]; } export interface Spec {