From 69ccc726434b5d0f5c6fba54fb4f6cbe32a19366 Mon Sep 17 00:00:00 2001 From: HelloThisWorld Date: Sun, 12 Jul 2026 03:04:25 +0800 Subject: [PATCH] SpecBridge v0.2: offline spec authoring and approval workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement Phase E on top of the v0.1 read-only runtime. Users can now create Kiro-compatible feature and bugfix specs offline, analyze them deterministically, and gate them through recorded stage approvals — no model, no API key, no network. New commands: - spec new: feature (requirements-first / design-first / quick) and bugfix templates with machine-recognizable placeholders; atomic creation staged in .specbridge/tmp and renamed in one step; strict spec-name validation; --description / --from-file / --dry-run / --json - spec analyze: deterministic structural analysis (placeholders, EARS patterns, vague wording, missing sections, task-plan gaps) with error/warning/info levels, --strict, and exit codes 0/1/2 - spec approve: prerequisite-gated approvals recording the SHA-256 of the exact file bytes in versioned sidecar state (schema 1.0.0); --revoke cascades to dependent approvals; reapproval after content changes invalidates dependents honestly; approved Markdown is never rewritten - spec status: per-stage approval health with stale detection Extended: spec list (mode/status/approval health), spec show (--state/--analysis/--status), doctor (sidecar validation, orphan and stale state detection — report-only). Architecture: new @specbridge/workflow package (cli -> workflow -> compat-kiro -> core); core gains sha256 hashing, fsync'd atomic writes, and the versioned SpecWorkflowState zod schema. Stale approvals are computed in memory at read time; read-only commands never write. Existing Kiro workspaces stay fully usable without state (unmanaged); the first successful approval initializes sidecar state. Testing: 245 tests (was 139), 13 new v02-* fixtures, 6 new end-to-end smoke checks against the built CLI. The byte-identical no-op round-trip guarantee and all v0.1 behavior are unchanged. Versions bumped to 0.2.0 with a new CHANGELOG and docs for authoring, analysis, approvals, and the sidecar schema. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 39 + README.md | 100 ++- docs/approval-workflow.md | 95 +++ docs/architecture.md | 15 +- docs/kiro-compatibility.md | 14 + docs/roadmap.md | 20 +- docs/sidecar-state.md | 71 +- docs/spec-analysis.md | 101 +++ docs/spec-authoring.md | 90 ++ .../state/specs/export-pipeline.json | 26 +- examples/design-first-project/README.md | 2 +- .../state/specs/healthcheck-endpoint.json | 27 +- examples/quick-spec-project/README.md | 2 +- .../state/specs/notification-preferences.json | 35 +- examples/requirements-first-project/README.md | 2 +- .../claude-code/skills/specbridge/SKILL.md | 15 +- .../references/requirements-workflow.md | 9 +- integrations/mcp-server/README.md | 5 +- package.json | 2 +- packages/cli/package.json | 15 +- packages/cli/src/cli.ts | 2 + packages/cli/src/commands/doctor.ts | 75 +- packages/cli/src/commands/spec-analyze.ts | 161 +++- packages/cli/src/commands/spec-approve.ts | 215 ++++- packages/cli/src/commands/spec-list.ts | 52 +- packages/cli/src/commands/spec-new.ts | 173 +++- packages/cli/src/commands/spec-show.ts | 95 ++- packages/cli/src/commands/spec-status.ts | 214 +++++ packages/cli/src/context.ts | 7 + packages/cli/src/version.ts | 2 +- packages/cli/src/workflow-view.ts | 31 + packages/compat-kiro/package.json | 6 +- packages/compat-kiro/src/diagnostics.ts | 4 +- packages/compat-kiro/src/spec-classifier.ts | 4 +- packages/core/package.json | 6 +- packages/core/src/errors.ts | 1 + packages/core/src/hash.ts | 31 + packages/core/src/index.ts | 1 + packages/core/src/spec-state.ts | 186 +++- packages/core/src/types.ts | 50 ++ packages/core/src/workspace.ts | 25 +- packages/drift/package.json | 6 +- packages/reporting/package.json | 6 +- packages/reporting/src/terminal-report.ts | 10 + packages/runners/package.json | 6 +- packages/workflow/package.json | 34 + packages/workflow/src/analyzers.ts | 800 ++++++++++++++++++ packages/workflow/src/approval.ts | 455 ++++++++++ packages/workflow/src/clock.ts | 11 + packages/workflow/src/create-spec.ts | 302 +++++++ packages/workflow/src/ears.ts | 95 +++ packages/workflow/src/health.ts | 180 ++++ packages/workflow/src/index.ts | 12 + packages/workflow/src/placeholders.ts | 123 +++ packages/workflow/src/sidecar-audit.ts | 117 +++ packages/workflow/src/spec-analysis.ts | 64 ++ packages/workflow/src/spec-name.ts | 84 ++ packages/workflow/src/state-machine.ts | 170 ++++ packages/workflow/src/templates.ts | 340 ++++++++ packages/workflow/tsconfig.json | 7 + packages/workflow/tsup.config.ts | 10 + pnpm-lock.yaml | 19 + scripts/smoke.mjs | 95 ++- tests/cli/cli-smoke.test.ts | 5 +- tests/cli/cli-v02-workflow.test.ts | 395 +++++++++ .../compatibility/spec-classification.test.ts | 14 +- tests/drift/evidence-and-state.test.ts | 34 +- .../.kiro/specs/login-timeout-fix/bugfix.md | 42 + .../.kiro/specs/login-timeout-fix/design.md | 32 + .../.kiro/specs/login-timeout-fix/tasks.md | 9 + .../state/specs/login-timeout-fix.json | 30 + .../.kiro/specs/export-pipeline/design.md | 46 + .../specs/export-pipeline/requirements.md | 7 + .../.kiro/specs/export-pipeline/tasks.md | 5 + .../state/specs/export-pipeline.json | 30 + .../v02-empty-workspace/.kiro/.gitkeep | 0 .../.kiro/specs/login-timeout-fix/bugfix.md | 42 + .../.kiro/specs/login-timeout-fix/design.md | 32 + .../.kiro/specs/login-timeout-fix/tasks.md | 9 + .../.kiro/specs/user-authentication/design.md | 46 + .../specs/user-authentication/requirements.md | 41 + .../.kiro/specs/user-authentication/tasks.md | 11 + .../specs/session-timeout/requirements.md | 18 + .../.kiro/specs/broken-state/requirements.md | 41 + .../.kiro/specs/legacy-shape/requirements.md | 41 + .../.kiro/specs/wrong-shape/requirements.md | 41 + .../.specbridge/state/specs/broken-state.json | 1 + .../.specbridge/state/specs/legacy-shape.json | 12 + .../.specbridge/state/specs/wrong-shape.json | 5 + .../.kiro/specs/search-filters/notes.md | 4 + .../specs/search-filters/requirements.md | 37 + .../.kiro/specs/real-spec/requirements.md | 41 + .../.specbridge/state/specs/ghost-spec.json | 30 + .../specs/notification-preferences/design.md | 7 + .../notification-preferences/requirements.md | 40 + .../specs/notification-preferences/tasks.md | 5 + .../state/specs/notification-preferences.json | 30 + .../specs/healthcheck-endpoint/design.md | 46 + .../healthcheck-endpoint/requirements.md | 41 + .../.kiro/specs/healthcheck-endpoint/tasks.md | 11 + .../state/specs/healthcheck-endpoint.json | 30 + .../specs/notification-preferences/design.md | 7 + .../notification-preferences/requirements.md | 41 + .../specs/notification-preferences/tasks.md | 5 + .../state/specs/notification-preferences.json | 30 + .../.kiro/specs/payment-retry/design.md | 46 + .../.kiro/specs/payment-retry/requirements.md | 41 + .../.kiro/specs/payment-retry/tasks.md | 11 + .../state/specs/payment-retry.json | 30 + .../specs/session-timeout/requirements.md | 27 + tests/helpers.ts | 66 ++ tests/workflow/analysis.test.ts | 385 +++++++++ tests/workflow/approval.test.ts | 397 +++++++++ tests/workflow/spec-creation.test.ts | 278 ++++++ tests/workflow/stale-approval.test.ts | 180 ++++ tests/workflow/state.test.ts | 147 ++++ tsconfig.json | 3 +- vitest.config.ts | 4 + 118 files changed, 7734 insertions(+), 247 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 docs/approval-workflow.md create mode 100644 docs/spec-analysis.md create mode 100644 docs/spec-authoring.md create mode 100644 packages/cli/src/commands/spec-status.ts create mode 100644 packages/cli/src/workflow-view.ts create mode 100644 packages/core/src/hash.ts create mode 100644 packages/workflow/package.json create mode 100644 packages/workflow/src/analyzers.ts create mode 100644 packages/workflow/src/approval.ts create mode 100644 packages/workflow/src/clock.ts create mode 100644 packages/workflow/src/create-spec.ts create mode 100644 packages/workflow/src/ears.ts create mode 100644 packages/workflow/src/health.ts create mode 100644 packages/workflow/src/index.ts create mode 100644 packages/workflow/src/placeholders.ts create mode 100644 packages/workflow/src/sidecar-audit.ts create mode 100644 packages/workflow/src/spec-analysis.ts create mode 100644 packages/workflow/src/spec-name.ts create mode 100644 packages/workflow/src/state-machine.ts create mode 100644 packages/workflow/src/templates.ts create mode 100644 packages/workflow/tsconfig.json create mode 100644 packages/workflow/tsup.config.ts create mode 100644 tests/cli/cli-v02-workflow.test.ts create mode 100644 tests/fixtures/v02-bugfix-spec/.kiro/specs/login-timeout-fix/bugfix.md create mode 100644 tests/fixtures/v02-bugfix-spec/.kiro/specs/login-timeout-fix/design.md create mode 100644 tests/fixtures/v02-bugfix-spec/.kiro/specs/login-timeout-fix/tasks.md create mode 100644 tests/fixtures/v02-bugfix-spec/.specbridge/state/specs/login-timeout-fix.json create mode 100644 tests/fixtures/v02-design-first/.kiro/specs/export-pipeline/design.md create mode 100644 tests/fixtures/v02-design-first/.kiro/specs/export-pipeline/requirements.md create mode 100644 tests/fixtures/v02-design-first/.kiro/specs/export-pipeline/tasks.md create mode 100644 tests/fixtures/v02-design-first/.specbridge/state/specs/export-pipeline.json create mode 100644 tests/fixtures/v02-empty-workspace/.kiro/.gitkeep create mode 100644 tests/fixtures/v02-existing-kiro-no-state/.kiro/specs/login-timeout-fix/bugfix.md create mode 100644 tests/fixtures/v02-existing-kiro-no-state/.kiro/specs/login-timeout-fix/design.md create mode 100644 tests/fixtures/v02-existing-kiro-no-state/.kiro/specs/login-timeout-fix/tasks.md create mode 100644 tests/fixtures/v02-existing-kiro-no-state/.kiro/specs/user-authentication/design.md create mode 100644 tests/fixtures/v02-existing-kiro-no-state/.kiro/specs/user-authentication/requirements.md create mode 100644 tests/fixtures/v02-existing-kiro-no-state/.kiro/specs/user-authentication/tasks.md create mode 100644 tests/fixtures/v02-invalid-ears/.kiro/specs/session-timeout/requirements.md create mode 100644 tests/fixtures/v02-invalid-sidecar/.kiro/specs/broken-state/requirements.md create mode 100644 tests/fixtures/v02-invalid-sidecar/.kiro/specs/legacy-shape/requirements.md create mode 100644 tests/fixtures/v02-invalid-sidecar/.kiro/specs/wrong-shape/requirements.md create mode 100644 tests/fixtures/v02-invalid-sidecar/.specbridge/state/specs/broken-state.json create mode 100644 tests/fixtures/v02-invalid-sidecar/.specbridge/state/specs/legacy-shape.json create mode 100644 tests/fixtures/v02-invalid-sidecar/.specbridge/state/specs/wrong-shape.json create mode 100644 tests/fixtures/v02-manually-edited/.kiro/specs/search-filters/notes.md create mode 100644 tests/fixtures/v02-manually-edited/.kiro/specs/search-filters/requirements.md create mode 100644 tests/fixtures/v02-orphan-sidecar/.kiro/specs/real-spec/requirements.md create mode 100644 tests/fixtures/v02-orphan-sidecar/.specbridge/state/specs/ghost-spec.json create mode 100644 tests/fixtures/v02-placeholder-heavy/.kiro/specs/notification-preferences/design.md create mode 100644 tests/fixtures/v02-placeholder-heavy/.kiro/specs/notification-preferences/requirements.md create mode 100644 tests/fixtures/v02-placeholder-heavy/.kiro/specs/notification-preferences/tasks.md create mode 100644 tests/fixtures/v02-placeholder-heavy/.specbridge/state/specs/notification-preferences.json create mode 100644 tests/fixtures/v02-quick-spec/.kiro/specs/healthcheck-endpoint/design.md create mode 100644 tests/fixtures/v02-quick-spec/.kiro/specs/healthcheck-endpoint/requirements.md create mode 100644 tests/fixtures/v02-quick-spec/.kiro/specs/healthcheck-endpoint/tasks.md create mode 100644 tests/fixtures/v02-quick-spec/.specbridge/state/specs/healthcheck-endpoint.json create mode 100644 tests/fixtures/v02-requirements-first/.kiro/specs/notification-preferences/design.md create mode 100644 tests/fixtures/v02-requirements-first/.kiro/specs/notification-preferences/requirements.md create mode 100644 tests/fixtures/v02-requirements-first/.kiro/specs/notification-preferences/tasks.md create mode 100644 tests/fixtures/v02-requirements-first/.specbridge/state/specs/notification-preferences.json create mode 100644 tests/fixtures/v02-stale-requirements-approval/.kiro/specs/payment-retry/design.md create mode 100644 tests/fixtures/v02-stale-requirements-approval/.kiro/specs/payment-retry/requirements.md create mode 100644 tests/fixtures/v02-stale-requirements-approval/.kiro/specs/payment-retry/tasks.md create mode 100644 tests/fixtures/v02-stale-requirements-approval/.specbridge/state/specs/payment-retry.json create mode 100644 tests/fixtures/v02-valid-ears/.kiro/specs/session-timeout/requirements.md create mode 100644 tests/workflow/analysis.test.ts create mode 100644 tests/workflow/approval.test.ts create mode 100644 tests/workflow/spec-creation.test.ts create mode 100644 tests/workflow/stale-approval.test.ts create mode 100644 tests/workflow/state.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..9a6453a --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,39 @@ +# Changelog + +## 0.2.0 + +- Offline Kiro-compatible spec creation: `spec new` renders plain-Markdown + templates for feature and bugfix specs — no model, no API key, no network. +- Requirements-first, design-first, quick, and bugfix workflows with an + explicit state machine and per-stage approval gates. +- Deterministic spec analysis: `spec analyze` reports structural and + consistency problems (placeholders, missing criteria, malformed EARS, + vague wording, task-plan gaps) with error/warning/info levels and + `--strict` mode. Same bytes, same findings, every time. +- Approval state and document hashing: `spec approve` records the SHA-256 of + the exact approved file bytes in versioned sidecar state + (`.specbridge/state/specs/.json`, schema 1.0.0). Approved Markdown + files are never rewritten. +- Stale approval detection: `spec status`, `spec list`, and `doctor` report + approved files that changed after approval and invalidate dependent + approvals in memory; re-approving repairs the hash and cascades honestly. +- Approval revocation: `spec approve --revoke` clears a stage and every + approval that depended on it, keeping all files. +- Existing Kiro workspace support: specs without SpecBridge state stay fully + usable (reported as `unmanaged`); the first successful approval initializes + sidecar state with `origin: existing-kiro-workspace`. +- `spec status` (new), plus extended `spec list` (mode/status/approval + health), `spec show` (`--state`, `--analysis`, `--status`), and `doctor` + (sidecar validation, orphan and stale state detection). +- No model or API key required for any v0.2 command; `.kiro` files carry no + SpecBridge metadata and the byte-identical no-op round trip is unchanged. + +## 0.1.0 + +- Read-only Kiro compatibility: workspace detection, steering discovery, + spec discovery and classification, tolerant Markdown parsers. +- `doctor`, `steering list/show`, `spec list/show/context`, `compat check`. +- Line-preserving document model with a byte-identical no-op round-trip + guarantee and a surgical checkbox patcher. +- Deterministic drift-check library primitives, runner interfaces with an + offline mock runner, terminal/JSON/HTML report helpers. diff --git a/README.md b/README.md index 8a3c56b..15d4208 100644 --- a/README.md +++ b/README.md @@ -137,22 +137,68 @@ Result: OK — workspace is ready for SpecBridge ## CLI -Working today (v0.1 — read-only, fully offline, no API key): +Working today (fully offline, no model, no API key): | Command | What it does | | --- | --- | -| `specbridge doctor` | Workspace health + compatibility report (never modifies files) | +| `specbridge doctor` | Workspace health, compatibility, and sidecar-state report | | `specbridge steering list` | List steering files with inclusion modes | | `specbridge steering show ` | Print a steering file | -| `specbridge spec list` | List specs with type, files, task progress, sidecar state | -| `specbridge spec show ` | Spec summary; `--file`, `--raw`, `--json` | +| `specbridge spec new ` | **v0.2** — create a Kiro-compatible spec from offline templates | +| `specbridge spec analyze ` | **v0.2** — deterministic structural/consistency analysis | +| `specbridge spec approve --stage ` | **v0.2** — record (or `--revoke`) a stage approval with a byte-exact hash | +| `specbridge spec status ` | **v0.2** — workflow status, stage approvals, stale detection | +| `specbridge spec list` | List specs with type, mode, files, progress, approval health | +| `specbridge spec show ` | Spec summary; `--file`, `--raw`, `--state`, `--analysis`, `--status`, `--json` | | `specbridge spec context ` | Agent-ready context (`--format json`, `--target claude-code`) | | `specbridge compat check [name]` | Prove the byte-identical no-op round trip | -Planned commands (`spec new/analyze/approve/run/sync/verify/export`) are -registered, marked "(planned)" in `--help`, and exit with an honest error — -see the [roadmap](docs/roadmap.md). Every command supports `--help` with -examples. +Planned commands (`spec run/sync/verify/export`) are registered, marked +"(planned)" in `--help`, and exit with an honest error — see the +[roadmap](docs/roadmap.md). Every command supports `--help` with examples. + +## Spec authoring and approval (v0.2) + +Create specs, gate them through explicit approvals, and detect when an +approved document changes — all offline, no LLM anywhere: + +```sh +specbridge doctor + +specbridge spec new notification-preferences \ + --mode requirements-first \ + --description "Allow users to choose email and push notification preferences." + +specbridge spec analyze notification-preferences --stage requirements + +specbridge spec approve notification-preferences --stage requirements + +specbridge spec status notification-preferences +``` + +How it fits together: + +- **Templates, not generation.** `spec new` renders plain-Markdown templates + (feature: requirements-first / design-first / quick; bugfix: report + fix + design + plan). Generated placeholders like `` are machine- + recognizable, so a fresh template cannot be approved by accident — + `spec analyze` reports them as errors until you write real content. +- **Approval is recorded, never inferred.** `spec approve` runs the + deterministic analyzer (errors block, warnings do not), then stores the + SHA-256 of the exact file bytes plus a timestamp in + `.specbridge/state/specs/.json`. The Markdown file is never touched. +- **Stale approvals are caught.** Change one byte of an approved file and + `spec status` / `spec list` / `doctor` report `STALE_APPROVAL`, including + which dependent approvals are now invalid. Read-only commands never + rewrite state; re-approving is the explicit repair. +- **Existing Kiro projects just work.** Specs without SpecBridge state are + reported as `unmanaged` and stay fully usable; the first successful + approval initializes sidecar state (`origin: existing-kiro-workspace`). + +Details: [spec authoring](docs/spec-authoring.md) · +[deterministic analysis](docs/spec-analysis.md) · +[approval workflow](docs/approval-workflow.md) · +[sidecar state](docs/sidecar-state.md). ## Compatibility guarantees @@ -195,13 +241,15 @@ guessing when none exists. ([examples/requirements-first-project](examples/requirements-first-project)) - **Design-first** — design → requirements → tasks ([examples/design-first-project](examples/design-first-project)) -- **Quick** — all files generated in one step +- **Quick** — all files generated in one step, approvals in any order ([examples/quick-spec-project](examples/quick-spec-project)) - **Bugfix** — `bugfix.md` (Current/Expected/Unchanged Behavior…) + design + tasks ([examples/bugfix-spec-project](examples/bugfix-spec-project)) -`specbridge spec new` (template mode, offline; runner mode optional) arrives -in Phase E. +All four are created offline by `specbridge spec new` (since v0.2) and gated +by `spec approve` — see [docs/approval-workflow.md](docs/approval-workflow.md). +Runner-assisted content generation is a separate, later phase and will always +be opt-in. ## Claude Code integration @@ -269,35 +317,45 @@ Configuration lives in `.specbridge/config.json` trusted project configuration, never from model output. - Logs never include secrets or environment variables. -## Limitations (v0.1) +## Limitations (v0.2) -- Read-only: spec creation, approval, task execution, sync, and drift - verification CLI commands are not implemented yet (they fail honestly). -- Files that are not valid UTF-8 are read best-effort and never edited. +- Task execution, sync, drift-verification CLI, and export are not + implemented yet (they fail honestly; the drift library primitives exist). +- `spec new` renders offline templates only — no model writes content in + v0.2, by design. Runner-assisted generation is a future opt-in. +- Analysis is deterministic and structural; it cannot judge whether + requirements are *good*, only whether they are well-formed and complete. - Workflow order cannot be inferred without sidecar state (reported as - `unknown` — by design). + `unknown` — by design); the first approval of an existing Kiro spec infers + it only when unambiguous. +- Files that are not valid UTF-8 are read best-effort and never edited. - The GitHub Action is a preview and needs specbridge installed in the workflow. - Setext (`===` underline) headings are not recognized as section boundaries; the bytes are preserved regardless. ## Roadmap -Phase 1 (this release): read-only compatibility, doctor, listing, context, -round-trip proof. Next: spec workflow (E), runner adapters (F), task -execution with evidence (G), sync + drift verification (H), GitHub Action -(I), Claude Code skill polish (J), optional MCP server (K). +v0.1: read-only compatibility, doctor, listing, context, round-trip proof. +v0.2 (this release): offline spec authoring, deterministic analysis, +hash-based approvals, stale-approval detection. Next: runner adapters (F), +task execution with evidence (G), sync + drift verification (H), GitHub +Action (I), Claude Code skill polish (J), optional MCP server (K). Full detail: [docs/roadmap.md](docs/roadmap.md). ## Documentation [Architecture](docs/architecture.md) · [Kiro compatibility](docs/kiro-compatibility.md) · +[Spec authoring](docs/spec-authoring.md) · +[Spec analysis](docs/spec-analysis.md) · +[Approval workflow](docs/approval-workflow.md) · [Sidecar state](docs/sidecar-state.md) · [Spec drift](docs/spec-drift.md) · [Runner adapters](docs/runner-adapters.md) · [Claude Code integration](docs/claude-code-integration.md) · [Migration from Kiro](docs/migration-from-kiro.md) (spoiler: there is none) · -[Roadmap](docs/roadmap.md) +[Roadmap](docs/roadmap.md) · +[Changelog](CHANGELOG.md) ## License and trademarks diff --git a/docs/approval-workflow.md b/docs/approval-workflow.md new file mode 100644 index 0000000..cc38a4c --- /dev/null +++ b/docs/approval-workflow.md @@ -0,0 +1,95 @@ +# Approval workflow (`spec approve`, `spec status`) + +SpecBridge treats approval as an explicit, recorded decision — never an +inference. A stage is approved only when sidecar state says so; a file +existing (or looking finished) proves nothing. + +```sh +specbridge spec approve notification-preferences --stage requirements +specbridge spec approve notification-preferences --stage design +specbridge spec approve login-timeout-fix --stage bugfix +specbridge spec approve notification-preferences --stage requirements --revoke +specbridge spec status notification-preferences +``` + +## Workflow state machines + +Statuses advance as stages are approved. `spec status` shows the effective +status; `STALE_APPROVAL` appears when a recorded approval no longer holds. + +| Workflow | Statuses | +| --- | --- | +| Feature, requirements-first | `REQUIREMENTS_DRAFT → REQUIREMENTS_APPROVED → DESIGN_DRAFT → DESIGN_APPROVED → TASKS_DRAFT → READY_FOR_IMPLEMENTATION` | +| Feature, design-first | `DESIGN_DRAFT → DESIGN_APPROVED → REQUIREMENTS_DRAFT → REQUIREMENTS_APPROVED → TASKS_DRAFT → READY_FOR_IMPLEMENTATION` | +| Quick | `READY_FOR_REVIEW → READY_FOR_IMPLEMENTATION` | +| Bugfix | `BUGFIX_DRAFT → BUGFIX_APPROVED → DESIGN_DRAFT → DESIGN_APPROVED → TASKS_DRAFT → READY_FOR_IMPLEMENTATION` | + +In practice a `_APPROVED` status is transient: approving a stage +immediately unblocks the next one, whose draft status takes over. The +`_APPROVED` values remain valid stored states (hand-edited or reconstructed +state can express them). + +## Prerequisites + +| Workflow | Rule | +| --- | --- | +| requirements-first | requirements → design → tasks, strictly in order | +| design-first | design → requirements → tasks, strictly in order | +| quick | requirements and design in either order; tasks needs both | +| bugfix | bugfix → design → tasks (design-first bugfixes start with the fix design) | + +An approval is blocked (exit `1`) when a prerequisite is unapproved **or** +approved-but-stale, when deterministic analysis of the stage reports errors +(unresolved placeholders included), or — for tasks — until every earlier +stage holds. Warnings never block. Usage mistakes (unknown spec, stage that +does not exist for the spec type) exit `2`. + +## What approval records + +Approval hashes the **exact file bytes** (SHA-256 — CRLF vs LF, BOMs, and +trailing newlines all count) and stores hash + timestamp in +`.specbridge/state/specs/.json`. The Markdown file itself is never +rewritten, reformatted, or annotated. + +## Stale approvals + +Every read of a managed spec (`spec status`, `spec list`, `spec show`, +`doctor`, and the gates inside `spec approve`/`spec analyze`) re-hashes the +approved files: + +- a changed file → effective status `modified-after-approval` +- approvals that depended on it → effectively stale too +- the overall effective status → `STALE_APPROVAL` + +Read-only commands only *report* this — they never rewrite state. The repair +is an explicit re-approval: + +```sh +specbridge spec approve --stage requirements +``` + +Re-approving a stage whose content changed also invalidates dependent +approvals persistently (they were made against different content) and says +so. Re-approving identical bytes keeps them. + +## Revocation + +`--revoke` sets the stage back to draft, clears its hash and timestamp, and +invalidates every approval that depended on it. Files are never deleted or +modified; the command reports exactly which approvals were invalidated. + +## Existing Kiro projects (unmanaged specs) + +Specs created by Kiro have no SpecBridge state. They list, show, and analyze +normally; `spec status` reports `Approval state: unmanaged` with the +suggested first command. The first **successful** approval initializes +sidecar state: + +- spec type is inferred from the files (`bugfix.md` → bugfix), +- workflow mode is inferred only when unambiguous: approving the document + stage first → requirements-first (the default in the absence of contrary + evidence); approving design first → design-first; approving tasks first is + refused with guidance, +- `origin` is recorded as `existing-kiro-workspace`. + +A blocked approval initializes nothing — failed commands do not write. diff --git a/docs/architecture.md b/docs/architecture.md index 1d43776..9fb8e53 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -9,17 +9,18 @@ instead of duplicating logic. | Package | Responsibility | | --- | --- | -| `@specbridge/core` | Shared types, errors, workspace detection, path-safety guards, atomic writes, sidecar state (`.specbridge/`) | +| `@specbridge/core` | Shared types, errors, workspace detection, path-safety guards, atomic writes, hashing, versioned sidecar state (`.specbridge/`) | | `@specbridge/compat-kiro` | Everything `.kiro`: line-preserving Markdown model, steering loader, spec discovery/classification, tolerant parsers, round-trip writer, workspace analysis, agent-context assembly | +| `@specbridge/workflow` | v0.2 authoring and approval engine: spec-name validation, offline templates, atomic spec creation, deterministic analyzers, workflow state machine, approval hashing + stale detection, sidecar audits | | `@specbridge/drift` | Deterministic drift primitives: git-diff parsing, impact areas, requirement/task coverage, evidence storage, report assembly | -| `@specbridge/runners` | Model/agent adapters behind one `AgentRunner` interface (mock implemented; CLI runners detection-only in v0.1) | +| `@specbridge/runners` | Model/agent adapters behind one `AgentRunner` interface (mock implemented; CLI runners detection-only) | | `@specbridge/reporting` | Terminal formatting, JSON report envelope, self-contained HTML rendering | | `specbridge` (packages/cli) | Commander-based CLI wiring the above together | Dependency direction (arrows = "may import"): ``` -cli ──▶ compat-kiro ──▶ core +cli ──▶ workflow ──▶ compat-kiro ──▶ core cli ──▶ reporting ──▶ core drift ─▶ compat-kiro, core (cli wires drift in Phase H) runners ─▶ core (cli wires runners in Phase F) @@ -40,12 +41,16 @@ runners ─▶ core (cli wires runners in Phase F) `error`) and the bytes are preserved. A file with zero recognized structure is still a valid file. 4. **Deterministic by default.** Default commands are offline and produce - deterministic output (no timestamps or random ids in v0.1 reports), which - keeps them testable and CI-friendly. Model invocation is always explicit + deterministic output; analysis of the same bytes always yields the same + findings, and the only nondeterminism in sidecar state is the timestamp + (behind an injectable clock in tests). Model invocation is always explicit and never required. 5. **Honest stubs.** Documented-but-unimplemented commands and runners exist, are labeled "(planned)", and exit with `NOT_IMPLEMENTED` errors. Nothing pretends to work. +6. **Approval is recorded, never inferred.** A stage is approved only when + sidecar state holds a hash of the exact approved bytes; read paths + recompute staleness in memory and never rewrite state. ## Data flow of a typical command diff --git a/docs/kiro-compatibility.md b/docs/kiro-compatibility.md index 1a4a6c4..96a507d 100644 --- a/docs/kiro-compatibility.md +++ b/docs/kiro-compatibility.md @@ -73,6 +73,20 @@ Strategy. British spellings and common variants match. from files** — the layout is identical. SpecBridge reads it from sidecar state when present and reports `unknown` otherwise. It never guesses. +## What SpecBridge writes (v0.2) + +`spec new` is the only v0.2 command that writes into `.kiro`, and only to +create a **new** spec directory. Generated files are plain Markdown with LF +endings and a trailing newline — no front matter, no HTML comments, no +SpecBridge metadata of any kind — so Kiro opens them like any hand-written +spec. Existing spec directories are never overwritten (no `--force` exists), +and creation is atomic: a failure leaves no partial directory. + +Approvals (`spec approve`) write **only** to `.specbridge/`; the approved +Markdown file is hashed byte-exactly and left untouched. `doctor` actively +scans for SpecBridge metadata inside `.kiro` files and reports any hit as an +error. + ## The round-trip guarantee For every file SpecBridge can decode as UTF-8: diff --git a/docs/roadmap.md b/docs/roadmap.md index 80143a9..41948bc 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -11,9 +11,9 @@ implemented unless marked ✅ and covered by tests. | B — Read-only Kiro compatibility | workspace detection, steering, discovery, classification, tolerant parsers, `doctor`, `steering list/show`, `spec list/show/context` | ✅ v0.1 | | C — Round-trip safety | line-preserving model, no-op byte identity, surgical checkbox patcher, golden tests | ✅ v0.1 | | D — Docs & release readiness | README, compatibility docs, CI (3 OS × Node 20/22), examples, smoke tests | ✅ v0.1 | -| E — Spec workflow | `spec new` (offline templates + optional runner mode), `spec analyze`, `spec approve`, sidecar approvals | 🚧 planned | -| F — Runner adapters | real `claude-code` / `codex` generation; config plumbing (interface + mock + detection ship in v0.1) | 🚧 planned | -| G — Task execution | `spec run`, run records under `.specbridge/runs/`, evidence-gated checkbox completion | 🚧 planned | +| E — Spec workflow | `spec new` (offline templates), `spec analyze` (deterministic), `spec approve` (hash-based sidecar approvals, stale detection, revocation), `spec status` | ✅ v0.2 (runner-assisted generation moves to Phase F) | +| F — Runner adapters | real `claude-code` / `codex` generation; config plumbing (interface + mock + detection ship in v0.1) | 🚧 planned — v0.3 candidate | +| G — Task execution | `spec run`, run records under `.specbridge/runs/`, evidence-gated checkbox completion | 🚧 planned — v0.3 candidate | | H — Sync & drift verification | `spec sync`, `spec verify` CLI over the existing `@specbridge/drift` primitives, terminal/JSON/HTML reports, quality-gate exit codes | 🚧 planned (library primitives ✅ in v0.1) | | I — GitHub Action | drift gates on PRs, Markdown summaries, report artifacts (read-only preview action ships in v0.1) | 🚧 planned | | J — Claude Code skill | polish the shipped skill as commands land | 🚧 iterating (v0.1 skill covers read-only workflows) | @@ -21,10 +21,18 @@ implemented unless marked ✅ and covered by tests. ## Command availability -| Command | v0.1 | +| Command | Status | | --- | --- | -| `doctor`, `steering list/show`, `spec list/show/context`, `compat check` | ✅ implemented | -| `spec new/analyze/approve/run/sync/verify/export` | ❌ registered as "(planned)", exit 2 with an honest message | +| `doctor`, `steering list/show`, `spec list/show/context`, `compat check` | ✅ v0.1 (extended in v0.2 with workflow status and sidecar audits) | +| `spec new`, `spec analyze`, `spec approve`, `spec status` | ✅ v0.2 — fully offline, no model, no API key | +| `spec run/sync/verify/export` | ❌ registered as "(planned)", exit 2 with an honest message | + +## v0.3 candidates + +- Runner-assisted content generation for `spec new` (Phase F) — explicitly + opt-in; offline templates remain the default. +- Task execution with evidence records (Phase G). +- `spec verify` CLI over the drift primitives (Phase H). ## Sequencing rule diff --git a/docs/sidecar-state.md b/docs/sidecar-state.md index 773a158..9ce8efb 100644 --- a/docs/sidecar-state.md +++ b/docs/sidecar-state.md @@ -13,7 +13,8 @@ delete `.specbridge/`, and your Kiro project is exactly as it was. ├── config.json # runner configuration (no secrets, ever) ├── state/ │ └── specs/ -│ └── .json # workflow, status, approvals, impact areas +│ └── .json # workflow mode, stage approvals, hashes +├── tmp/ # spec-creation staging (removed after use) ├── runs/ # per-run records (Phase G) ├── evidence/ │ └── /.json # task evidence records (Phase G/H) @@ -21,41 +22,67 @@ delete `.specbridge/`, and your Kiro project is exactly as it was. └── cache/ # disposable ``` -Only `config.json` and `state/` exist in v0.1 workflows; the rest is created -by later-phase commands. Nothing here is required — a workspace without -`.specbridge/` is fully supported. +Only `config.json`, `state/`, and the transient `tmp/` exist in v0.2 +workflows; the rest is created by later-phase commands. Nothing here is +required — a workspace without `.specbridge/` is fully supported. -## Spec state schema +## Spec state schema (1.0.0) -`.specbridge/state/specs/.json` (validated with zod, unknown fields -preserved for forward compatibility): +`.specbridge/state/specs/.json` is versioned and validated with zod; +unknown fields written by newer 1.x versions survive a read-modify-write. ```json { + "schemaVersion": "1.0.0", "specName": "notification-preferences", "specType": "feature", "workflowMode": "requirements-first", - "status": "DESIGN_APPROVED", - "approvals": { - "requirements": { "approved": true, "approvedAt": "2026-07-01T10:00:00Z" }, - "design": { "approved": true, "approvedAt": "2026-07-02T09:30:00Z" } - }, - "declaredImpactAreas": ["src/notifications/**", "tests/notifications/**"], - "verificationCommands": ["npm test"] + "origin": "created-by-specbridge", + "status": "DESIGN_DRAFT", + "createdAt": "2026-07-01T09:00:00.000Z", + "updatedAt": "2026-07-01T10:00:00.000Z", + "stages": { + "requirements": { + "status": "approved", + "file": ".kiro/specs/notification-preferences/requirements.md", + "approvedAt": "2026-07-01T10:00:00.000Z", + "approvedHash": "a8571bce929fcce33ddf4ff6292e712a3efe1b267c4285cfabe0758d4c607317" + }, + "design": { + "status": "draft", + "file": ".kiro/specs/notification-preferences/design.md", + "approvedAt": null, + "approvedHash": null + }, + "tasks": { + "status": "blocked", + "file": ".kiro/specs/notification-preferences/tasks.md", + "approvedAt": null, + "approvedHash": null + } + } } ``` - `workflowMode`: `requirements-first` | `design-first` | `quick`. The file layout cannot express this; without sidecar state SpecBridge reports `unknown`. -- `status`: `DRAFT` → `REQUIREMENTS_APPROVED` → `DESIGN_APPROVED` → - `READY_FOR_EXECUTION` → `IN_PROGRESS` → `COMPLETE`. -- `declaredImpactAreas` / `verificationCommands`: consumed by drift - verification (Phase H). Verification commands come from this trusted file - or explicit user input — never from model output. - -Invalid or corrupt state files degrade to warnings; the `.kiro` files always -win. +- `origin`: `created-by-specbridge`, or `existing-kiro-workspace` when the + state was initialized by the first approval of a pre-existing Kiro spec. +- `status`: the workflow status derived from stage approvals — see + [approval-workflow.md](approval-workflow.md) for the per-mode state + machines. +- `stages`: one entry per approvable stage, in workflow order. Bugfix specs + replace `requirements` with `bugfix`. `approvedHash` is the SHA-256 of the + exact approved file bytes; `blocked`/`draft` stages carry nulls. Approval + is only ever read from here — never inferred from file existence. +- Stage `file` paths are workspace-relative with forward slashes and are + guarded against traversal when resolved. + +Invalid, legacy (pre-1.0.0), or corrupt state files degrade to warnings and +the spec is treated as unmanaged; the `.kiro` files always win. Read-only +commands never rewrite state — stale approvals are recomputed in memory and +repaired only by an explicit re-approval. ## Evidence records diff --git a/docs/spec-analysis.md b/docs/spec-analysis.md new file mode 100644 index 0000000..af08ae7 --- /dev/null +++ b/docs/spec-analysis.md @@ -0,0 +1,101 @@ +# Deterministic spec analysis (`spec analyze`) + +`specbridge spec analyze ` inspects a spec for structural and +consistency problems. The analyzer is deterministic and fully offline: the +same bytes always produce the same findings. No model is involved and none +is required. + +```sh +specbridge spec analyze notification-preferences # all stages +specbridge spec analyze notification-preferences --stage requirements +specbridge spec analyze login-timeout-fix --stage bugfix --json +specbridge spec analyze notification-preferences --strict +``` + +## Levels and exit codes + +| Level | Meaning | +| --- | --- | +| `error` | Blocks approval of that stage | +| `warning` | Reported; never blocks (unless `--strict`) | +| `info` | Context only | + +Exit codes: `0` no errors · `1` errors found (with `--strict`, warnings too) +· `2` invalid command, configuration, or runtime failure. + +## Stage awareness + +When sidecar state exists, strictness follows the workflow: + +- **Active stages** (draft or approved): placeholders are errors, a missing + file is an error. +- **Blocked stages** (waiting on an unapproved prerequisite): placeholders + are warnings and a missing file is informational — a generated "pending" + stub is expected there. + +Unmanaged specs (no sidecar state) are analyzed at full strictness, but +nothing is assumed about approvals. + +## What the analyzers detect + +**Requirements** — missing/empty file, missing title or introduction, no +recognized requirements, requirements without acceptance criteria, duplicate +requirement or criterion identifiers, unresolved placeholders, malformed +EARS patterns, criteria without a testable form, vague wording (`support`, +`handle`, `work correctly`, `as appropriate`, …), no error/exceptional +behavior anywhere, missing Out of Scope section, missing non-functional +requirements. Heading matching is tolerant — `Overview` counts as an +introduction, `Non-Goals` counts as out-of-scope — and hand-written +documents are not rejected for differing from generated templates. + +**Bugfix** — missing Current/Expected/Unchanged Behavior, missing +reproduction, evidence, or regression-risk discussion, current and expected +behavior that are word-for-word identical, placeholder-only sections. + +**Design** — missing overview, architecture/approach, components, +interfaces, failure handling, security considerations, testing strategy, or +risks/trade-offs (all warnings — trivial specs are not forced to have every +section), unresolved placeholders, and real design content written while the +prerequisite stage is still unapproved. Bugfix specs are checked against fix +-design sections (root cause, proposed fix, regression protection, +validation) instead. + +**Tasks** — missing/empty file, no recognizable checkbox tasks, malformed +checkboxes, duplicate task numbers, tasks led by vague verbs, no +implementation/test/validation task, references to requirement ids that do +not exist, tasks already checked off before the plan was approved, and +parents marked complete while required children are open. The analyzer +never modifies checkboxes. + +## EARS support + +The analyzer recognizes EARS-style acceptance criteria: + +``` +WHEN , THE SYSTEM SHALL . +IF , THEN THE SYSTEM SHALL . +WHILE , THE SYSTEM SHALL . +WHERE , THE SYSTEM SHALL . +THE SYSTEM SHALL . +``` + +EARS is encouraged, not required. A plain criterion is fine when it contains +a testable modal (`shall`/`must`/`should`/`will`); only a criterion that +*starts* an EARS pattern without finishing it (`WHEN the user logs in.`) is +flagged as malformed, and a criterion with no testable form at all gets a +warning. + +## Placeholder detection + +Four precise families are recognized (outside code fences): + +1. angle-bracket tokens: ``, `` (HTML tag names are + excluded), +2. `TBD` / `TODO`, +3. instruction lines ending in "here": `Add edge cases here.`, + `Describe the correct behavior here.`, +4. the exact pending-stage lines from generated templates. + +A document whose entire body is placeholders is reported as +placeholder-only. Detection is deliberately conservative — a false positive +would block approval of a legitimate document. diff --git a/docs/spec-authoring.md b/docs/spec-authoring.md new file mode 100644 index 0000000..4353edf --- /dev/null +++ b/docs/spec-authoring.md @@ -0,0 +1,90 @@ +# Spec authoring (`spec new`) + +`specbridge spec new ` creates a Kiro-compatible spec from offline +Markdown templates. There is no model involved: templates render +deterministically from the name, title, and description you provide, and the +result is plain Markdown that Kiro can open unchanged. + +```sh +specbridge spec new notification-preferences +specbridge spec new notification-preferences \ + --type feature \ + --mode requirements-first \ + --title "Notification Preferences" +specbridge spec new cache-fallback \ + --type bugfix \ + --description "Fix stale cache fallback after upstream timeout" +specbridge spec new payment-retry \ + --mode quick \ + --from-file feature-description.md +``` + +## Spec names + +Names become directory names under `.kiro/specs/`, so they are validated +strictly: lowercase letters and digits, single hyphens between words, no +spaces, underscores, path separators, `..`, absolute paths, leading/trailing +or doubled hyphens, and no Windows reserved device names. + +Valid: `notification-preferences`, `auth-v2`, `payment-retry`. +Invalid: `NotificationPreferences`, `notification_preferences`, +`../notification`, `-payment`, `payment--retry`. + +## Types and modes + +| `--type` | `--mode` | First document | Files created | +| --- | --- | --- | --- | +| `feature` (default) | `requirements-first` (default) | requirements.md | full requirements template + pending design/tasks stubs | +| `feature` | `design-first` | design.md | full design template + pending requirements/tasks stubs | +| `feature` | `quick` | all three | meaningful starter content in all three files | +| `bugfix` | any | bugfix.md | bugfix report + fix design + bugfix plan | + +Bugfix specs use `bugfix.md` instead of `requirements.md` and render the +same three files in every mode — the mode only changes the approval order +enforced through sidecar state (`design-first` starts with the fix design). + +Quick mode generates everything in one step and starts at +`READY_FOR_REVIEW`. Nothing is auto-approved: the approval gates below still +apply, you simply may approve requirements and design in either order. + +## Descriptions + +The initial description comes from exactly one of: + +1. `--description ` +2. `--from-file ` — a UTF-8 text file **inside the workspace** + (directories, files over 1 MB, and non-UTF-8 files are rejected; + non-English content is preserved byte-for-byte) +3. neither — a recognizable placeholder is rendered instead + +Passing both `--description` and `--from-file` is an error. + +## Placeholders are deliberate + +Generated templates contain machine-recognizable placeholders +(``, `Add edge cases here.`, `TBD`, …). `spec analyze` reports them +and `spec approve` refuses to approve a stage that still contains them — +a template is a starting point, not an approvable document. + +## Atomicity + +Creation is all-or-nothing. Files are rendered into a temporary directory +under `.specbridge/tmp/` and renamed into `.kiro/specs/` in a single +step; sidecar state is written afterwards. If anything fails — including the +state write — the partially created spec directory is removed and no +temporary files survive. An existing spec is never overwritten (there is no +`--force`), and the error lists the files that are already there. + +## Dry runs + +`--dry-run` prints the target directory, the files and sidecar state that +would be created, and the full rendered Markdown — without writing anything. +Combined with `--json` the output is machine-readable and, apart from +timestamps, fully deterministic. + +## What goes where + +- `.kiro/specs//*.md` — plain Markdown, no front matter, no tool + metadata, LF line endings. Kiro-compatible by construction. +- `.specbridge/state/specs/.json` — workflow mode, stage statuses, + approvals. See [sidecar-state.md](sidecar-state.md). diff --git a/examples/design-first-project/.specbridge/state/specs/export-pipeline.json b/examples/design-first-project/.specbridge/state/specs/export-pipeline.json index ffe4087..d7a3b4f 100644 --- a/examples/design-first-project/.specbridge/state/specs/export-pipeline.json +++ b/examples/design-first-project/.specbridge/state/specs/export-pipeline.json @@ -1,16 +1,30 @@ { + "schemaVersion": "1.0.0", "specName": "export-pipeline", "specType": "feature", "workflowMode": "design-first", - "status": "DESIGN_APPROVED", - "approvals": { + "origin": "created-by-specbridge", + "status": "TASKS_DRAFT", + "createdAt": "2026-06-20T14:00:00.000Z", + "updatedAt": "2026-06-22T11:00:00.000Z", + "stages": { "design": { - "approved": true, - "approvedAt": "2026-06-20T15:00:00Z" + "status": "approved", + "file": ".kiro/specs/export-pipeline/design.md", + "approvedAt": "2026-06-20T15:00:00.000Z", + "approvedHash": "c563c19be7f6f2de7676d64c7f4c485968e931b30a45cd40cd2ed794988d0888" }, "requirements": { - "approved": true, - "approvedAt": "2026-06-22T11:00:00Z" + "status": "approved", + "file": ".kiro/specs/export-pipeline/requirements.md", + "approvedAt": "2026-06-22T11:00:00.000Z", + "approvedHash": "ca15c1c46bbb0b8ea4bff4640268a90bb3c6d1a4d3aad306166cbf16de3c8c06" + }, + "tasks": { + "status": "draft", + "file": ".kiro/specs/export-pipeline/tasks.md", + "approvedAt": null, + "approvedHash": null } } } diff --git a/examples/design-first-project/README.md b/examples/design-first-project/README.md index eb0f769..0e8c06b 100644 --- a/examples/design-first-project/README.md +++ b/examples/design-first-project/README.md @@ -8,5 +8,5 @@ than guessing. ```sh cd examples/design-first-project -node ../../packages/cli/dist/index.js spec list # WORKFLOW column reads design-first +node ../../packages/cli/dist/index.js spec list # MODE column reads design-first ``` diff --git a/examples/quick-spec-project/.specbridge/state/specs/healthcheck-endpoint.json b/examples/quick-spec-project/.specbridge/state/specs/healthcheck-endpoint.json index 8d881e3..01f6af9 100644 --- a/examples/quick-spec-project/.specbridge/state/specs/healthcheck-endpoint.json +++ b/examples/quick-spec-project/.specbridge/state/specs/healthcheck-endpoint.json @@ -1,7 +1,30 @@ { + "schemaVersion": "1.0.0", "specName": "healthcheck-endpoint", "specType": "feature", "workflowMode": "quick", - "status": "IN_PROGRESS", - "createdAt": "2026-07-05T08:00:00Z" + "origin": "created-by-specbridge", + "status": "READY_FOR_REVIEW", + "createdAt": "2026-07-05T08:00:00.000Z", + "updatedAt": "2026-07-05T08:00:00.000Z", + "stages": { + "requirements": { + "status": "draft", + "file": ".kiro/specs/healthcheck-endpoint/requirements.md", + "approvedAt": null, + "approvedHash": null + }, + "design": { + "status": "draft", + "file": ".kiro/specs/healthcheck-endpoint/design.md", + "approvedAt": null, + "approvedHash": null + }, + "tasks": { + "status": "blocked", + "file": ".kiro/specs/healthcheck-endpoint/tasks.md", + "approvedAt": null, + "approvedHash": null + } + } } diff --git a/examples/quick-spec-project/README.md b/examples/quick-spec-project/README.md index ade00f1..31ed2b2 100644 --- a/examples/quick-spec-project/README.md +++ b/examples/quick-spec-project/README.md @@ -7,5 +7,5 @@ the documents were not individually reviewed stage-by-stage. ```sh cd examples/quick-spec-project -node ../../packages/cli/dist/index.js spec list # WORKFLOW column reads quick +node ../../packages/cli/dist/index.js spec list # MODE column reads quick ``` diff --git a/examples/requirements-first-project/.specbridge/state/specs/notification-preferences.json b/examples/requirements-first-project/.specbridge/state/specs/notification-preferences.json index 096badb..e29e2a9 100644 --- a/examples/requirements-first-project/.specbridge/state/specs/notification-preferences.json +++ b/examples/requirements-first-project/.specbridge/state/specs/notification-preferences.json @@ -1,23 +1,30 @@ { + "schemaVersion": "1.0.0", "specName": "notification-preferences", "specType": "feature", "workflowMode": "requirements-first", - "status": "DESIGN_APPROVED", - "approvals": { + "origin": "created-by-specbridge", + "status": "DESIGN_DRAFT", + "createdAt": "2026-07-01T09:00:00.000Z", + "updatedAt": "2026-07-01T10:00:00.000Z", + "stages": { "requirements": { - "approved": true, - "approvedAt": "2026-07-01T10:00:00Z" + "status": "approved", + "file": ".kiro/specs/notification-preferences/requirements.md", + "approvedAt": "2026-07-01T10:00:00.000Z", + "approvedHash": "7fa1e23db7ca25d0f1d745e1bf99fc4fea185606b15fa2231e09f2c0cbeb29a0" }, "design": { - "approved": true, - "approvedAt": "2026-07-02T09:30:00Z" + "status": "draft", + "file": ".kiro/specs/notification-preferences/design.md", + "approvedAt": null, + "approvedHash": null + }, + "tasks": { + "status": "blocked", + "file": ".kiro/specs/notification-preferences/tasks.md", + "approvedAt": null, + "approvedHash": null } - }, - "declaredImpactAreas": [ - "src/notifications/**", - "tests/notifications/**" - ], - "verificationCommands": [ - "npm test" - ] + } } diff --git a/examples/requirements-first-project/README.md b/examples/requirements-first-project/README.md index 5dc88b8..167afbb 100644 --- a/examples/requirements-first-project/README.md +++ b/examples/requirements-first-project/README.md @@ -8,6 +8,6 @@ The `.kiro` files carry no SpecBridge metadata. ```sh cd examples/requirements-first-project -node ../../packages/cli/dist/index.js spec list # WORKFLOW column reads requirements-first +node ../../packages/cli/dist/index.js spec list # MODE column reads requirements-first node ../../packages/cli/dist/index.js spec show notification-preferences ``` diff --git a/integrations/claude-code/skills/specbridge/SKILL.md b/integrations/claude-code/skills/specbridge/SKILL.md index 0137342..04eb419 100644 --- a/integrations/claude-code/skills/specbridge/SKILL.md +++ b/integrations/claude-code/skills/specbridge/SKILL.md @@ -56,8 +56,13 @@ Reference guides in this skill: ## Command status (be honest with the user) Available now: `doctor`, `steering list/show`, `spec list/show/context`, -`compat check`. The commands `spec new/analyze/approve/run/sync/verify/ -export` are planned and currently exit with a not-implemented message — if -the user asks for them, do the equivalent manually following the reference -guides, and say that is what you are doing. Never claim a planned command -ran. +`compat check`, and — since v0.2 — `spec new`, `spec analyze`, +`spec approve` (with `--revoke`), and `spec status`. Prefer these commands +over doing the equivalent by hand: `spec new` creates Kiro-compatible specs +offline, `spec analyze` gates content deterministically, and `spec approve` +records stage approvals (with byte-exact hashes) in `.specbridge/`. + +The commands `spec run/sync/verify/export` are still planned and exit with a +not-implemented message — if the user asks for them, do the equivalent +manually following the reference guides, and say that is what you are doing. +Never claim a planned command ran. diff --git a/integrations/claude-code/skills/specbridge/references/requirements-workflow.md b/integrations/claude-code/skills/specbridge/references/requirements-workflow.md index 489f46f..c792f67 100644 --- a/integrations/claude-code/skills/specbridge/references/requirements-workflow.md +++ b/integrations/claude-code/skills/specbridge/references/requirements-workflow.md @@ -1,8 +1,11 @@ # Requirements workflow -Use when creating or revising `requirements.md` for a spec (until -`specbridge spec new` ships, create the file by hand at -`.kiro/specs//requirements.md`). +Use when creating or revising `requirements.md` for a spec. Start new specs +with `specbridge spec new ` (offline templates); it creates +`.kiro/specs//requirements.md` with the structure below, ready to fill +in. Check your edits with `specbridge spec analyze --stage +requirements`, and record the user's sign-off with +`specbridge spec approve --stage requirements`. ## Structure to follow diff --git a/integrations/mcp-server/README.md b/integrations/mcp-server/README.md index db02925..4fff071 100644 --- a/integrations/mcp-server/README.md +++ b/integrations/mcp-server/README.md @@ -23,8 +23,9 @@ verifier are stable (Phase K). | `read_steering` | `steering show` | | `list_specs` | `spec list` | | `read_spec` | `spec show --json` | -| `create_spec` | `spec new` (Phase E) | -| `analyze_spec` | `spec analyze` (Phase E) | +| `create_spec` | `spec new` (CLI ✅ since v0.2) | +| `analyze_spec` | `spec analyze` (CLI ✅ since v0.2) | +| `approve_stage` | `spec approve` / `spec status` (CLI ✅ since v0.2) | | `get_next_tasks` | next-open-tasks from the tasks parser | | `record_task_evidence` | evidence store (Phase G) | | `sync_tasks` | `spec sync` (Phase H) | diff --git a/package.json b/package.json index ee435f6..d052174 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "specbridge-monorepo", - "version": "0.1.0", + "version": "0.2.0", "private": true, "description": "An open, model-agnostic spec runtime for existing Kiro projects.", "license": "MIT", diff --git a/packages/cli/package.json b/packages/cli/package.json index 264eb97..12f4356 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,17 +1,25 @@ { "name": "specbridge", - "version": "0.1.0", + "version": "0.2.0", "description": "An open, model-agnostic spec runtime for existing Kiro projects. No conversion, no duplicated specs, no lock-in.", "license": "MIT", "type": "module", "bin": { "specbridge": "dist/index.js" }, - "files": ["dist"], + "files": [ + "dist" + ], "engines": { "node": ">=20.0.0" }, - "keywords": ["kiro", "specs", "spec-driven-development", "cli", "ai-agents"], + "keywords": [ + "kiro", + "specs", + "spec-driven-development", + "cli", + "ai-agents" + ], "scripts": { "build": "tsup", "clean": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\"", @@ -21,6 +29,7 @@ "@specbridge/compat-kiro": "workspace:*", "@specbridge/core": "workspace:*", "@specbridge/reporting": "workspace:*", + "@specbridge/workflow": "workspace:*", "commander": "^12.1.0", "picocolors": "^1.1.0" }, diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 7669e25..abfd4d7 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -13,6 +13,7 @@ import { registerSpecContextCommand } from './commands/spec-context.js'; import { registerSpecNewCommand } from './commands/spec-new.js'; import { registerSpecAnalyzeCommand } from './commands/spec-analyze.js'; import { registerSpecApproveCommand } from './commands/spec-approve.js'; +import { registerSpecStatusCommand } from './commands/spec-status.js'; import { registerSpecSyncCommand } from './commands/spec-sync.js'; import { registerSpecRunCommand } from './commands/spec-run.js'; import { registerSpecVerifyCommand } from './commands/spec-verify.js'; @@ -60,6 +61,7 @@ honest error; nothing pretends to work before it does.`, registerSpecNewCommand(spec, runtime); registerSpecAnalyzeCommand(spec, runtime); registerSpecApproveCommand(spec, runtime); + registerSpecStatusCommand(spec, runtime); registerSpecRunCommand(spec, runtime); registerSpecSyncCommand(spec, runtime); registerSpecVerifyCommand(spec, runtime); diff --git a/packages/cli/src/commands/doctor.ts b/packages/cli/src/commands/doctor.ts index d6088c0..6830895 100644 --- a/packages/cli/src/commands/doctor.ts +++ b/packages/cli/src/commands/doctor.ts @@ -3,6 +3,8 @@ import type { Command } from 'commander'; import { CLI_BIN, PRODUCT_NAME, hasErrors } from '@specbridge/core'; import type { WorkspaceAnalysis } from '@specbridge/compat-kiro'; import { analyzeWorkspace } from '@specbridge/compat-kiro'; +import type { SidecarAudit } from '@specbridge/workflow'; +import { auditSidecarState } from '@specbridge/workflow'; import { addLine, createJsonReport, @@ -38,7 +40,45 @@ function describeProgress(analysis: WorkspaceAnalysis['specs'][number]): string return `${p.completed}/${p.total} tasks${optional}`; } -function printReport(runtime: CliRuntime, analysis: WorkspaceAnalysis): void { +function printSidecarSection(runtime: CliRuntime, audit: SidecarAudit): void { + runtime.out(sectionTitle('Sidecar state (.specbridge)')); + if (!audit.stateDirExists) { + runtime.out(infoLine('No workflow state yet', '(created by "spec new" or the first "spec approve")')); + runtime.out(); + return; + } + const healthy = audit.entries.filter( + (entry) => entry.health === 'ok' && entry.hasSpecFolder, + ).length; + if (healthy > 0) { + runtime.out(okLine(`${healthy} spec state file${healthy === 1 ? '' : 's'} valid and in sync`)); + } + for (const specName of audit.staleSpecs) { + runtime.out( + warnLine(`${specName}: an approved file changed after approval`, `(repair: ${CLI_BIN} spec approve ${specName} --stage )`), + ); + } + for (const specName of audit.invalidStates.filter((name) => !audit.orphanStates.includes(name))) { + runtime.out(warnLine(`${specName}: sidecar state is invalid and was ignored`)); + } + for (const specName of audit.orphanStates) { + runtime.out(warnLine(`${specName}: state file has no matching .kiro/specs/${specName}/ directory`)); + } + if (audit.unmanagedSpecs.length > 0) { + runtime.out( + infoLine( + `${audit.unmanagedSpecs.length} spec${audit.unmanagedSpecs.length === 1 ? '' : 's'} without workflow state (unmanaged): ${audit.unmanagedSpecs.join(', ')}`, + '(normal for Kiro-only projects)', + ), + ); + } + if (audit.unknownEntries.length > 0) { + runtime.out(infoLine(`Ignored non-state entries in state dir: ${audit.unknownEntries.join(', ')}`)); + } + runtime.out(); +} + +function printReport(runtime: CliRuntime, analysis: WorkspaceAnalysis, audit: SidecarAudit): void { const { workspace } = analysis; runtime.out(reportTitle(`${PRODUCT_NAME} Doctor`)); runtime.out(); @@ -112,6 +152,8 @@ function printReport(runtime: CliRuntime, analysis: WorkspaceAnalysis): void { } runtime.out(); + printSidecarSection(runtime, audit); + runtime.out(sectionTitle('Line endings')); const le = analysis.lineEndings; const parts: string[] = []; @@ -148,6 +190,7 @@ function printReport(runtime: CliRuntime, analysis: WorkspaceAnalysis): void { const allDiagnostics = [ ...analysis.diagnostics, ...analysis.specs.flatMap((spec) => spec.diagnostics), + ...audit.diagnostics, ]; const visible = allDiagnostics.filter((d) => d.severity !== 'info'); if (visible.length > 0) { @@ -169,7 +212,7 @@ function printReport(runtime: CliRuntime, analysis: WorkspaceAnalysis): void { } } -function toJson(analysis: WorkspaceAnalysis): unknown { +function toJson(analysis: WorkspaceAnalysis, audit: SidecarAudit): unknown { return createJsonReport('specbridge.doctor/1', `${CLI_BIN} ${VERSION}`, { workspace: { rootDir: analysis.workspace.rootDir, @@ -198,10 +241,25 @@ function toJson(analysis: WorkspaceAnalysis): unknown { roundTripSafe: spec.roundTrip.every((check) => check.identical), diagnostics: spec.diagnostics, })), + sidecar: { + stateDir: audit.stateDir, + stateDirExists: audit.stateDirExists, + states: audit.entries.map((entry) => ({ + specName: entry.specName, + statePath: entry.statePath, + hasSpecFolder: entry.hasSpecFolder, + health: entry.health, + effectiveStatus: entry.effectiveStatus ?? null, + })), + orphanStates: audit.orphanStates, + unmanagedSpecs: audit.unmanagedSpecs, + staleSpecs: audit.staleSpecs, + invalidStates: audit.invalidStates, + }, lineEndings: analysis.lineEndings, roundTripSafe: analysis.roundTripSafe, healthy: analysis.healthy, - diagnostics: analysis.diagnostics, + diagnostics: [...analysis.diagnostics, ...audit.diagnostics], }); } @@ -247,11 +305,16 @@ Examples: } const analysis = analyzeWorkspace(workspace); + const audit = auditSidecarState( + workspace, + analysis.specs.map((spec) => spec.folder), + ); if (options.json === true) { - runtime.outRaw(serializeJsonReport(toJson(analysis))); + runtime.outRaw(serializeJsonReport(toJson(analysis, audit))); } else { - printReport(runtime, analysis); + printReport(runtime, analysis, audit); } - runtime.exitCode = analysis.healthy && analysis.roundTripSafe ? 0 : 1; + const auditHealthy = !hasErrors(audit.diagnostics); + runtime.exitCode = analysis.healthy && analysis.roundTripSafe && auditHealthy ? 0 : 1; }); } diff --git a/packages/cli/src/commands/spec-analyze.ts b/packages/cli/src/commands/spec-analyze.ts index a678a01..30fa051 100644 --- a/packages/cli/src/commands/spec-analyze.ts +++ b/packages/cli/src/commands/spec-analyze.ts @@ -1,14 +1,157 @@ import type { Command } from 'commander'; +import type { StageName } from '@specbridge/core'; +import { CLI_BIN, STAGE_NAMES, SpecBridgeError } from '@specbridge/core'; +import { analyzeSpec, requireSpec } from '@specbridge/compat-kiro'; +import { readSpecState } from '@specbridge/core'; +import type { WorkflowEvaluation } from '@specbridge/workflow'; +import { + analyzeSpecWorkflow, + evaluateWorkflow, + isStageApplicable, + applicableStages, +} from '@specbridge/workflow'; +import { + createJsonReport, + dim, + okLine, + reportTitle, + sectionTitle, + serializeJsonReport, + severityLine, +} from '@specbridge/reporting'; import type { CliRuntime } from '../context.js'; -import { registerPlannedCommand } from '../context.js'; +import { relPath } from '../context.js'; +import { VERSION } from '../version.js'; + +/** + * `specbridge spec analyze ` — deterministic, offline spec analysis. + * No model is involved: the same bytes always produce the same findings. + * Exit codes: 0 no errors, 1 errors (or warnings with --strict), 2 usage. + */ + +const STAGE_CHOICES = [...STAGE_NAMES, 'all'] as const; + +interface SpecAnalyzeOptions { + stage: string; + json?: boolean; + strict?: boolean; +} -/** Planned: Phase E (spec creation and approval workflow). */ export function registerSpecAnalyzeCommand(spec: Command, runtime: CliRuntime): void { - registerPlannedCommand(spec, runtime, { - name: 'analyze', - args: '', - summary: 'Analyze a spec for gaps and inconsistencies before approval', - phase: 'the spec-workflow phase (Phase E)', - workaround: 'use "spec show " — it already reports structure, progress, and diagnostics.', - }); + spec + .command('analyze ') + .description('Analyze a spec for structural and consistency problems (deterministic, offline)') + .option('--stage ', `stage to analyze: ${STAGE_CHOICES.join(' | ')}`, 'all') + .option('--strict', 'treat warnings as failures (exit 1)') + .option('--json', 'output a machine-readable JSON report') + .addHelpText( + 'after', + ` +Findings come in three levels: error (blocks approval), warning (reported, +never blocks unless --strict), and info. Placeholders left over from +generated templates are errors for active stages and warnings for stages +still blocked behind an unapproved prerequisite. + +Exit codes: 0 no errors · 1 errors found (or warnings with --strict) · 2 usage/runtime error. + +Examples: + ${CLI_BIN} spec analyze notification-preferences + ${CLI_BIN} spec analyze notification-preferences --stage requirements + ${CLI_BIN} spec analyze login-timeout-fix --stage bugfix --json + ${CLI_BIN} spec analyze notification-preferences --strict`, + ) + .action((name: string, options: SpecAnalyzeOptions) => { + if (!STAGE_CHOICES.includes(options.stage as (typeof STAGE_CHOICES)[number])) { + throw new SpecBridgeError( + 'INVALID_ARGUMENT', + `Unknown --stage "${options.stage}". Valid stages: ${STAGE_CHOICES.join(', ')}.`, + ); + } + + const workspace = runtime.workspace(); + const folder = requireSpec(workspace, name); + const spec = analyzeSpec(workspace, folder); + + const stateRead = readSpecState(workspace, folder.name); + let evaluation: WorkflowEvaluation | undefined; + if (stateRead.state !== undefined) { + evaluation = evaluateWorkflow(workspace, stateRead.state); + } + + let stages: StageName[] | undefined; + if (options.stage !== 'all') { + const stage = options.stage as StageName; + const specType = stateRead.state?.specType ?? (spec.classification.type === 'bugfix' ? 'bugfix' : 'feature'); + if (!isStageApplicable(specType, stage)) { + throw new SpecBridgeError( + 'INVALID_ARGUMENT', + `Stage "${stage}" does not apply to a ${specType} spec. Applicable stages: ${applicableStages(specType).join(', ')}.`, + ); + } + stages = [stage]; + } + + const result = analyzeSpecWorkflow(spec, evaluation, stages); + const failed = result.hasErrors || (options.strict === true && result.warningCount > 0); + + if (options.json === true) { + runtime.outRaw( + serializeJsonReport( + createJsonReport('specbridge.spec-analyze/1', `${CLI_BIN} ${VERSION}`, { + specName: result.specName, + strict: options.strict === true, + managed: evaluation !== undefined, + stages: result.stages.map((stage) => ({ + stage: stage.stage, + fileName: stage.fileName, + fileExists: stage.fileExists, + diagnostics: stage.diagnostics, + })), + errorCount: result.errorCount, + warningCount: result.warningCount, + failed, + }), + ), + ); + runtime.exitCode = failed ? 1 : 0; + return; + } + + runtime.out(reportTitle(`Analysis: ${folder.name}`)); + if (stateRead.diagnostics.length > 0) { + for (const diagnostic of stateRead.diagnostics) { + runtime.out(severityLine(diagnostic.severity, diagnostic.message)); + } + } + if (evaluation === undefined) { + runtime.out(dim(' Approval state: unmanaged (no sidecar state) — analyzing all present stages at full strictness.')); + } + runtime.out(); + + for (const stage of result.stages) { + runtime.out(sectionTitle(`${stage.stage} (${stage.fileName})`)); + if (!stage.fileExists && stage.diagnostics.length === 0) { + runtime.out(dim(' not present')); + } else if (stage.diagnostics.length === 0) { + runtime.out(okLine('no findings')); + } else { + for (const diagnostic of stage.diagnostics) { + const location = + diagnostic.file !== undefined + ? ` [${relPath(workspace, diagnostic.file)}${diagnostic.line !== undefined ? `:${diagnostic.line}` : ''}]` + : ''; + runtime.out(severityLine(diagnostic.severity, `${diagnostic.message}${location}`)); + } + } + runtime.out(); + } + + const summary = `${result.errorCount} error${result.errorCount === 1 ? '' : 's'}, ${result.warningCount} warning${result.warningCount === 1 ? '' : 's'}`; + if (failed) { + runtime.out(`Result: ${reportTitle('FAIL')} — ${summary}${options.strict === true && !result.hasErrors ? ' (strict mode)' : ''}`); + } else { + runtime.out(`Result: ${reportTitle('OK')} — ${summary}`); + } + runtime.exitCode = failed ? 1 : 0; + }); } diff --git a/packages/cli/src/commands/spec-approve.ts b/packages/cli/src/commands/spec-approve.ts index a70d266..9a6bc21 100644 --- a/packages/cli/src/commands/spec-approve.ts +++ b/packages/cli/src/commands/spec-approve.ts @@ -1,13 +1,212 @@ import type { Command } from 'commander'; +import type { StageName } from '@specbridge/core'; +import { CLI_BIN, STAGE_NAMES, SpecBridgeError } from '@specbridge/core'; +import { analyzeSpec, requireSpec } from '@specbridge/compat-kiro'; +import type { ApprovalResult } from '@specbridge/workflow'; +import { approveStage } from '@specbridge/workflow'; +import { + createJsonReport, + dim, + okLine, + reportTitle, + serializeJsonReport, + severityLine, + warnLine, +} from '@specbridge/reporting'; import type { CliRuntime } from '../context.js'; -import { registerPlannedCommand } from '../context.js'; +import { relPath } from '../context.js'; +import { VERSION } from '../version.js'; -/** Planned: Phase E. Approvals will live in .specbridge/state, never in .kiro files. */ -export function registerSpecApproveCommand(spec: Command, runtime: CliRuntime): void { - registerPlannedCommand(spec, runtime, { - name: 'approve', - args: '', - summary: 'Record requirements/design approval in sidecar state (.specbridge/state)', - phase: 'the spec-workflow phase (Phase E)', +/** + * `specbridge spec approve --stage ` — record (or revoke) a + * stage approval in sidecar state. The approved Markdown file is hashed + * byte-exactly and never modified. Deterministic analysis gates approval: + * errors block, warnings do not. + */ + +interface SpecApproveOptions { + stage?: string; + revoke?: boolean; + json?: boolean; +} + +function resultToJson(specName: string, result: ApprovalResult): unknown { + const base = { specName }; + if (result.ok && result.action === 'approved') { + return createJsonReport('specbridge.spec-approve/1', `${CLI_BIN} ${VERSION}`, { + ...base, + action: 'approved', + stage: result.stage, + hash: result.hash, + reapproved: result.reapproved, + invalidated: result.invalidated, + initialized: result.initialized, + status: result.state.status, + statePath: result.statePath, + warnings: result.analysis.diagnostics.filter((d) => d.severity === 'warning'), + diagnostics: result.diagnostics, + }); + } + if (result.ok) { + return createJsonReport('specbridge.spec-approve/1', `${CLI_BIN} ${VERSION}`, { + ...base, + action: 'revoked', + stage: result.stage, + invalidated: result.invalidated, + status: result.state.status, + statePath: result.statePath, + diagnostics: result.diagnostics, + }); + } + return createJsonReport('specbridge.spec-approve/1', `${CLI_BIN} ${VERSION}`, { + ...base, + action: 'blocked', + reason: result.reason, + message: result.message, + missingPrerequisites: result.missingPrerequisites ?? [], + stalePrerequisites: result.stalePrerequisites ?? [], + analysis: + result.analysis !== undefined + ? { + errorCount: result.analysis.errorCount, + warningCount: result.analysis.warningCount, + diagnostics: result.analysis.diagnostics, + } + : null, + diagnostics: result.diagnostics, }); } + +export function registerSpecApproveCommand(spec: Command, runtime: CliRuntime): void { + spec + .command('approve ') + .description('Approve (or revoke) a workflow stage; approvals live in .specbridge, never in .kiro') + .requiredOption('--stage ', `stage to approve: ${STAGE_NAMES.join(' | ')}`) + .option('--revoke', 'revoke the stage approval (dependent approvals are invalidated too)') + .option('--json', 'output a machine-readable JSON report') + .addHelpText( + 'after', + ` +Approval records the SHA-256 of the exact file bytes plus a timestamp in +.specbridge/state/specs/.json. The Markdown file itself is never +rewritten. If an approved file changes later, the approval is reported as +stale; re-approving updates the hash (and invalidates dependent approvals, +because they were made against different content). + +Prerequisites by workflow: + requirements-first requirements -> design -> tasks + design-first design -> requirements -> tasks + quick requirements + design in any order, then tasks + bugfix bugfix -> design -> tasks + +For an existing Kiro spec without SpecBridge state, the first successful +approval initializes the sidecar state (origin: existing-kiro-workspace). + +Exit codes: 0 approved/revoked · 1 blocked (prerequisites or analysis errors) · 2 usage error. + +Examples: + ${CLI_BIN} spec approve notification-preferences --stage requirements + ${CLI_BIN} spec approve notification-preferences --stage design + ${CLI_BIN} spec approve login-timeout-fix --stage bugfix + ${CLI_BIN} spec approve notification-preferences --stage requirements --revoke`, + ) + .action((name: string, options: SpecApproveOptions) => { + const stage = options.stage as StageName | undefined; + if (stage === undefined || !STAGE_NAMES.includes(stage)) { + throw new SpecBridgeError( + 'INVALID_ARGUMENT', + `Unknown --stage "${options.stage ?? ''}". Valid stages: ${STAGE_NAMES.join(', ')}.`, + ); + } + + const workspace = runtime.workspace(); + const folder = requireSpec(workspace, name); + const spec = analyzeSpec(workspace, folder); + + const result = approveStage( + workspace, + spec, + { stage, ...(options.revoke === true ? { revoke: true } : {}) }, + { clock: () => runtime.now() }, + ); + + if (options.json === true) { + runtime.outRaw(serializeJsonReport(resultToJson(folder.name, result))); + runtime.exitCode = result.ok ? 0 : result.failure === 'usage' ? 2 : 1; + return; + } + + if (!result.ok) { + runtime.err(result.message); + if (result.reason === 'prerequisites-unmet') { + const nextStage = result.missingPrerequisites?.[0] ?? result.stalePrerequisites?.[0]; + if (nextStage !== undefined) { + runtime.err(''); + runtime.err('Run:'); + runtime.err(` ${CLI_BIN} spec analyze ${folder.name} --stage ${nextStage}`); + runtime.err(` ${CLI_BIN} spec approve ${folder.name} --stage ${nextStage}`); + } + } + if (result.reason === 'analysis-errors' && result.analysis !== undefined) { + runtime.err(''); + for (const diagnostic of result.analysis.diagnostics.filter((d) => d.severity === 'error')) { + const location = + diagnostic.file !== undefined + ? ` [${relPath(workspace, diagnostic.file)}${diagnostic.line !== undefined ? `:${diagnostic.line}` : ''}]` + : ''; + runtime.err(` ${diagnostic.severity === 'error' ? '✗' : '!'} ${diagnostic.message}${location}`); + } + runtime.err(''); + runtime.err(dim(`Full report: ${CLI_BIN} spec analyze ${folder.name} --stage ${stage}`)); + } + runtime.exitCode = result.failure === 'usage' ? 2 : 1; + return; + } + + if (result.action === 'revoked') { + runtime.out(reportTitle(`Revoked: ${folder.name} — ${result.stage}`)); + runtime.out(); + runtime.out(okLine(`${result.stage} approval revoked (files were not touched)`)); + for (const invalidated of result.invalidated) { + runtime.out(warnLine(`${invalidated} approval invalidated (it depended on ${result.stage})`)); + } + runtime.out(); + runtime.out(` Status: ${result.state.status}`); + runtime.out(dim(` State: ${relPath(workspace, result.statePath)}`)); + return; + } + + runtime.out(reportTitle(`Approved: ${folder.name} — ${result.stage}`)); + runtime.out(); + if (result.initialized) { + runtime.out(okLine('Sidecar state initialized for this existing Kiro spec', '(origin: existing-kiro-workspace)')); + } + runtime.out( + okLine( + `${result.stage} ${result.reapproved ? 're-approved' : 'approved'}`, + `(sha256 ${result.hash.slice(0, 12)}…)`, + ), + ); + for (const invalidated of result.invalidated) { + runtime.out( + warnLine( + `${invalidated} approval invalidated — ${result.stage} changed since it was approved; re-approve it`, + ), + ); + } + const warnings = result.analysis.diagnostics.filter((d) => d.severity === 'warning'); + for (const warning of warnings) { + runtime.out(severityLine('warning', warning.message)); + } + if (warnings.length > 0) { + runtime.out(dim(' (warnings never block approval; fix them when convenient)')); + } + runtime.out(); + runtime.out(` Status: ${result.state.status}`); + runtime.out(dim(` State: ${relPath(workspace, result.statePath)}`)); + if (result.state.status !== 'READY_FOR_IMPLEMENTATION') { + runtime.out(); + runtime.out(dim(` Next: ${CLI_BIN} spec status ${folder.name}`)); + } + }); +} diff --git a/packages/cli/src/commands/spec-list.ts b/packages/cli/src/commands/spec-list.ts index 8bbf8c6..51e0005 100644 --- a/packages/cli/src/commands/spec-list.ts +++ b/packages/cli/src/commands/spec-list.ts @@ -10,29 +10,38 @@ import { serializeJsonReport, } from '@specbridge/reporting'; import type { CliRuntime } from '../context.js'; +import { loadWorkflowView } from '../workflow-view.js'; import { VERSION } from '../version.js'; export function registerSpecListCommand(spec: Command, runtime: CliRuntime): void { spec .command('list') - .description('List specs in .kiro/specs with type, files, progress, and status') + .description('List specs with type, workflow mode, files, progress, and approval health') .option('--json', 'output JSON') .addHelpText( 'after', ` +STATUS shows the effective workflow status: the recorded status, or +STALE_APPROVAL when an approved file changed after approval, or "unmanaged" +for specs without SpecBridge sidecar state (normal for Kiro-only projects). + Examples: ${CLI_BIN} spec list ${CLI_BIN} spec list --json`, ) .action((options: { json?: boolean }) => { const workspace = runtime.workspace(); - const analyses = discoverSpecs(workspace).map((folder) => analyzeSpec(workspace, folder)); + const entries = discoverSpecs(workspace).map((folder) => { + const analysis = analyzeSpec(workspace, folder); + const view = loadWorkflowView(workspace, folder.name); + return { analysis, view }; + }); if (options.json === true) { runtime.outRaw( serializeJsonReport( createJsonReport('specbridge.spec-list/1', `${CLI_BIN} ${VERSION}`, { - specs: analyses.map((analysis) => ({ + specs: entries.map(({ analysis, view }) => ({ name: analysis.folder.name, dir: analysis.folder.dir, type: analysis.classification.type, @@ -40,7 +49,12 @@ Examples: completeness: analysis.classification.completeness, files: analysis.folder.files.map((f) => ({ fileName: f.fileName, kind: f.kind })), taskProgress: analysis.taskProgress, - sidecarStatus: analysis.state?.status ?? null, + managed: view.evaluation !== undefined, + approvalHealth: view.health, + workflowStatus: view.evaluation?.storedStatus ?? null, + effectiveStatus: view.displayStatus, + staleStages: view.evaluation?.staleStages ?? [], + sidecarStatus: view.stateRead.state?.status ?? null, diagnostics: analysis.diagnostics, })), }), @@ -49,21 +63,24 @@ Examples: return; } - if (analyses.length === 0) { + if (entries.length === 0) { runtime.out(infoLine('No specs found under .kiro/specs.')); - runtime.out(dim(` Create one in Kiro, or add .kiro/specs//requirements.md by hand.`)); + runtime.out(dim(` Create one with "${CLI_BIN} spec new ", in Kiro, or by hand.`)); return; } - runtime.out(reportTitle(`Specs (${analyses.length})`)); + runtime.out(reportTitle(`Specs (${entries.length})`)); runtime.out(); - const rows: string[][] = [['', 'NAME', 'TYPE', 'WORKFLOW', 'FILES', 'TASKS', 'STATE']]; - for (const analysis of analyses) { + const rows: string[][] = [['', 'NAME', 'TYPE', 'MODE', 'FILES', 'TASKS', 'STATUS']]; + for (const { analysis, view } of entries) { + const stale = view.health === 'stale'; const marker = hasErrors(analysis.diagnostics) ? '✗' - : analysis.classification.completeness === 'complete' - ? '✓' - : '!'; + : stale + ? '!' + : analysis.classification.completeness === 'complete' + ? '✓' + : '!'; const files = analysis.classification.presentKinds.length > 0 ? analysis.classification.presentKinds.join(', ') @@ -73,18 +90,23 @@ Examples: analysis.tasks !== undefined ? `${p.completed}/${p.total}${p.optionalTotal > 0 ? `+${p.optionalTotal}o` : ''}` : '—'; + const mode = view.stateRead.state?.workflowMode ?? analysis.classification.workflowMode; rows.push([ marker, analysis.folder.name, analysis.classification.type, - analysis.classification.workflowMode, + mode, files, tasksCell, - analysis.state?.status ?? '—', + view.displayStatus, ]); } for (const line of renderColumns(rows)) runtime.out(line); runtime.out(); - runtime.out(dim(` ✓ complete ! partial/empty ✗ has errors — details: ${CLI_BIN} spec show `)); + runtime.out( + dim( + ` ✓ complete ! partial or stale approval ✗ has errors — details: ${CLI_BIN} spec status `, + ), + ); }); } diff --git a/packages/cli/src/commands/spec-new.ts b/packages/cli/src/commands/spec-new.ts index baf4c9b..1e4d266 100644 --- a/packages/cli/src/commands/spec-new.ts +++ b/packages/cli/src/commands/spec-new.ts @@ -1,15 +1,168 @@ import type { Command } from 'commander'; +import type { ConcreteSpecType, ConcreteWorkflowMode } from '@specbridge/core'; +import { CLI_BIN, SpecBridgeError } from '@specbridge/core'; +import type { SpecCreationPlan } from '@specbridge/workflow'; +import { createSpec, planSpecCreation } from '@specbridge/workflow'; +import { + createJsonReport, + dim, + okLine, + reportTitle, + sectionTitle, + serializeJsonReport, +} from '@specbridge/reporting'; import type { CliRuntime } from '../context.js'; -import { registerPlannedCommand } from '../context.js'; +import { relPath } from '../context.js'; +import { VERSION } from '../version.js'; -/** Planned: Phase E (spec creation and approval workflow). */ -export function registerSpecNewCommand(spec: Command, runtime: CliRuntime): void { - registerPlannedCommand(spec, runtime, { - name: 'new', - args: '', - summary: - 'Create a new spec (--type feature|bugfix, --mode requirements-first|design-first|quick; offline template mode or runner mode)', - phase: 'the spec-workflow phase (Phase E)', - workaround: 'create .kiro/specs//requirements.md by hand or in Kiro; SpecBridge reads it immediately.', +/** + * `specbridge spec new ` — create a Kiro-compatible spec from offline + * templates. No model, no network, no API key. Creation is atomic: a failure + * leaves no partial spec directory behind. + */ + +const SPEC_TYPES: ConcreteSpecType[] = ['feature', 'bugfix']; +const WORKFLOW_MODES: ConcreteWorkflowMode[] = ['requirements-first', 'design-first', 'quick']; + +interface SpecNewOptions { + type: string; + mode: string; + title?: string; + description?: string; + fromFile?: string; + dryRun?: boolean; + json?: boolean; +} + +function planToJson(plan: SpecCreationPlan, dryRun: boolean): unknown { + return createJsonReport('specbridge.spec-new/1', `${CLI_BIN} ${VERSION}`, { + dryRun, + created: !dryRun, + specName: plan.specName, + specType: plan.specType, + workflowMode: plan.mode, + title: plan.title, + dir: plan.dir, + files: plan.files.map((file) => ({ + fileName: file.fileName, + stage: file.stage, + bytes: Buffer.byteLength(file.content, 'utf8'), + content: file.content, + })), + state: plan.state, + statePath: plan.statePath, }); } + +function printPlanSummary(runtime: CliRuntime, plan: SpecCreationPlan, dryRun: boolean): void { + const workspace = runtime.workspace(); + runtime.out(reportTitle(dryRun ? `Dry run — nothing was written` : `Created spec: ${plan.specName}`)); + runtime.out(); + runtime.out(` Name: ${plan.specName}`); + runtime.out(` Type: ${plan.specType}`); + runtime.out(` Mode: ${plan.mode}`); + runtime.out(` Title: ${plan.title}`); + runtime.out(` Dir: ${relPath(workspace, plan.dir)}`); + runtime.out(); + runtime.out(sectionTitle(dryRun ? 'Files that would be created' : 'Files created')); + for (const file of plan.files) { + runtime.out(okLine(`${relPath(workspace, plan.dir)}/${file.fileName}`, `(${Buffer.byteLength(file.content, 'utf8')} B)`)); + } + runtime.out(okLine(relPath(workspace, plan.statePath), '(sidecar workflow state)')); + runtime.out(); + + if (dryRun) { + runtime.out(sectionTitle('Rendered content')); + for (const file of plan.files) { + runtime.out(dim(`--- ${file.fileName} ---`)); + runtime.outRaw(file.content); + } + runtime.out(dim(`--- sidecar state (${relPath(workspace, plan.statePath)}) ---`)); + runtime.outRaw(`${JSON.stringify(plan.state, null, 2)}\n`); + return; + } + + runtime.out(sectionTitle('Next steps')); + const firstStage = plan.state.specType === 'bugfix' ? 'bugfix' : plan.mode === 'design-first' ? 'design' : 'requirements'; + runtime.out(` 1. Replace the template placeholders in ${firstStage}.md with real content.`); + runtime.out(` 2. ${CLI_BIN} spec analyze ${plan.specName} --stage ${firstStage}`); + runtime.out(` 3. ${CLI_BIN} spec approve ${plan.specName} --stage ${firstStage}`); + runtime.out(` 4. ${CLI_BIN} spec status ${plan.specName}`); +} + +export function registerSpecNewCommand(spec: Command, runtime: CliRuntime): void { + spec + .command('new ') + .description('Create a new Kiro-compatible spec from offline templates (no model required)') + .option('--type ', `spec type: ${SPEC_TYPES.join(' | ')}`, 'feature') + .option( + '--mode ', + `workflow mode: ${WORKFLOW_MODES.join(' | ')}`, + 'requirements-first', + ) + .option('--title ', 'human-readable title (default: derived from the spec name)') + .option('--description ', 'initial description inserted into the first document') + .option('--from-file ', 'read the description from a UTF-8 file inside the workspace') + .option('--dry-run', 'print everything that would be created without writing any file') + .option('--json', 'output a machine-readable JSON report') + .addHelpText( + 'after', + ` +The spec is created under .kiro/specs// and stays fully Kiro-compatible: +no front matter, no tool metadata. Workflow state (approvals) lives in +.specbridge/state/specs/.json. + +Spec names use lowercase words separated by single hyphens: notification-preferences, +auth-v2, payment-retry. + +Examples: + ${CLI_BIN} spec new notification-preferences + ${CLI_BIN} spec new notification-preferences --mode requirements-first --title "Notification Preferences" + ${CLI_BIN} spec new cache-fallback --type bugfix --description "Fix stale cache fallback after upstream timeout" + ${CLI_BIN} spec new payment-retry --mode quick --from-file feature-description.md + ${CLI_BIN} spec new payment-retry --dry-run`, + ) + .action((name: string, options: SpecNewOptions) => { + if (!SPEC_TYPES.includes(options.type as ConcreteSpecType)) { + throw new SpecBridgeError( + 'INVALID_ARGUMENT', + `Unknown --type "${options.type}". Valid types: ${SPEC_TYPES.join(', ')}.`, + ); + } + if (!WORKFLOW_MODES.includes(options.mode as ConcreteWorkflowMode)) { + throw new SpecBridgeError( + 'INVALID_ARGUMENT', + `Unknown --mode "${options.mode}". Valid modes: ${WORKFLOW_MODES.join(', ')}.`, + ); + } + + const workspace = runtime.workspace(); + const request = { + name, + specType: options.type as ConcreteSpecType, + mode: options.mode as ConcreteWorkflowMode, + ...(options.title !== undefined ? { title: options.title } : {}), + ...(options.description !== undefined ? { description: options.description } : {}), + ...(options.fromFile !== undefined ? { fromFile: options.fromFile } : {}), + cwd: runtime.cwd, + }; + const clock = (): Date => runtime.now(); + + if (options.dryRun === true) { + const plan = planSpecCreation(workspace, request, clock); + if (options.json === true) { + runtime.outRaw(serializeJsonReport(planToJson(plan, true))); + } else { + printPlanSummary(runtime, plan, true); + } + return; + } + + const result = createSpec(workspace, request, clock); + if (options.json === true) { + runtime.outRaw(serializeJsonReport(planToJson(result.plan, false))); + return; + } + printPlanSummary(runtime, result.plan, false); + }); +} diff --git a/packages/cli/src/commands/spec-show.ts b/packages/cli/src/commands/spec-show.ts index 2a05392..1843314 100644 --- a/packages/cli/src/commands/spec-show.ts +++ b/packages/cli/src/commands/spec-show.ts @@ -1,7 +1,8 @@ import type { Command } from 'commander'; -import { CLI_BIN, SpecBridgeError } from '@specbridge/core'; +import { CLI_BIN, SpecBridgeError, stateStageNames, stateStage } from '@specbridge/core'; import type { SpecAnalysis } from '@specbridge/compat-kiro'; import { analyzeSpec, requireSpec } from '@specbridge/compat-kiro'; +import { analyzeSpecWorkflow } from '@specbridge/workflow'; import { createJsonReport, dim, @@ -16,6 +17,8 @@ import { } from '@specbridge/reporting'; import type { CliRuntime } from '../context.js'; import { formatBytes, relPath } from '../context.js'; +import type { SpecWorkflowView } from '../workflow-view.js'; +import { loadWorkflowView } from '../workflow-view.js'; import { VERSION } from '../version.js'; const FILE_KINDS = ['requirements', 'design', 'tasks', 'bugfix'] as const; @@ -51,7 +54,7 @@ function describeDocument(analysis: SpecAnalysis, kind: FileKind): string { } } -function printSummary(runtime: CliRuntime, analysis: SpecAnalysis): void { +function printSummary(runtime: CliRuntime, analysis: SpecAnalysis, view: SpecWorkflowView): void { const workspace = runtime.workspace(); const { classification, folder } = analysis; runtime.out(reportTitle(`Spec: ${folder.name}`)); @@ -77,17 +80,20 @@ function printSummary(runtime: CliRuntime, analysis: SpecAnalysis): void { runtime.out(); runtime.out(sectionTitle('Sidecar state')); - if (analysis.state !== undefined) { - const approvals: string[] = []; - const recorded = analysis.state.approvals; - if (recorded?.requirements?.approved === true) approvals.push('requirements ✓'); - if (recorded?.design?.approved === true) approvals.push('design ✓'); - if (recorded?.tasks?.approved === true) approvals.push('tasks ✓'); + const state = analysis.state; + if (state !== undefined) { + const approvals = stateStageNames(state) + .filter((stage) => stateStage(state, stage)?.status === 'approved') + .map((stage) => `${stage} ✓`); + const stale = view.health === 'stale' ? ' — STALE_APPROVAL (an approved file changed)' : ''; runtime.out( okLine( - `${analysis.state.status} (${analysis.state.workflowMode})${approvals.length > 0 ? ` — ${approvals.join(', ')}` : ''}`, + `${view.displayStatus} (${state.workflowMode})${approvals.length > 0 ? ` — ${approvals.join(', ')}` : ''}${stale}`, ), ); + runtime.out(dim(` Details: ${CLI_BIN} spec status ${folder.name}`)); + } else if (view.health === 'invalid') { + runtime.out(warnLine('invalid sidecar state (ignored) — see diagnostics below')); } else { runtime.out(infoLine('none (this spec has only ever been used by Kiro — that is fine)')); } @@ -125,7 +131,7 @@ function printSummary(runtime: CliRuntime, analysis: SpecAnalysis): void { } } -function toJson(analysis: SpecAnalysis): unknown { +function toJson(analysis: SpecAnalysis, view: SpecWorkflowView): unknown { return createJsonReport('specbridge.spec-show/1', `${CLI_BIN} ${VERSION}`, { name: analysis.folder.name, dir: analysis.folder.dir, @@ -133,6 +139,8 @@ function toJson(analysis: SpecAnalysis): unknown { files: analysis.folder.files, extraDirs: analysis.folder.extraDirs, sidecarState: analysis.state ?? null, + approvalHealth: view.health, + effectiveStatus: view.displayStatus, requirements: analysis.requirements ?? null, design: analysis.design ?? null, tasks: @@ -166,6 +174,9 @@ export function registerSpecShowCommand(spec: Command, runtime: CliRuntime): voi .description('Show a spec summary, one of its files, or the full parsed model') .option('--file ', `print one file's content (${FILE_KINDS.join(', ')})`) .option('--raw', 'print raw file content without any summary framing') + .option('--state', 'print the sidecar workflow state (JSON) for this spec') + .option('--analysis', 'print deterministic analysis findings for this spec') + .option('--status', 'print a one-line workflow status for this spec') .option('--json', 'output the full parsed model as JSON') .addHelpText( 'after', @@ -174,15 +185,70 @@ Examples: ${CLI_BIN} spec show user-authentication ${CLI_BIN} spec show user-authentication --file tasks ${CLI_BIN} spec show user-authentication --file requirements --raw + ${CLI_BIN} spec show user-authentication --state + ${CLI_BIN} spec show user-authentication --analysis ${CLI_BIN} spec show login-timeout-fix --json`, ) - .action((name: string, options: { file?: string; raw?: boolean; json?: boolean }) => { + .action( + ( + name: string, + options: { + file?: string; + raw?: boolean; + json?: boolean; + state?: boolean; + analysis?: boolean; + status?: boolean; + }, + ) => { const workspace = runtime.workspace(); const folder = requireSpec(workspace, name); const analysis = analyzeSpec(workspace, folder); + const view = loadWorkflowView(workspace, folder.name); if (options.json === true) { - runtime.outRaw(serializeJsonReport(toJson(analysis))); + runtime.outRaw(serializeJsonReport(toJson(analysis, view))); + return; + } + + if (options.state === true) { + for (const diagnostic of view.stateRead.diagnostics) { + runtime.out(severityLine(diagnostic.severity, diagnostic.message)); + } + if (view.stateRead.state !== undefined) { + runtime.outRaw(`${JSON.stringify(view.stateRead.state, null, 2)}\n`); + } else if (!view.stateRead.exists) { + runtime.out(infoLine(`No sidecar state (approval state: unmanaged). Path: ${relPath(workspace, view.stateRead.path)}`)); + } + return; + } + + if (options.status === true) { + const mode = view.stateRead.state?.workflowMode ?? analysis.classification.workflowMode; + runtime.out( + `${folder.name} ${analysis.classification.type} ${mode} ${view.displayStatus}`, + ); + runtime.out(dim(` Details: ${CLI_BIN} spec status ${folder.name}`)); + return; + } + + if (options.analysis === true) { + const result = analyzeSpecWorkflow(analysis, view.evaluation); + if (result.diagnostics.length === 0) { + runtime.out(okLine('no findings')); + } else { + for (const diagnostic of result.diagnostics) { + const location = + diagnostic.file !== undefined + ? ` [${relPath(workspace, diagnostic.file)}${diagnostic.line !== undefined ? `:${diagnostic.line}` : ''}]` + : ''; + runtime.out(severityLine(diagnostic.severity, `${diagnostic.message}${location}`)); + } + } + runtime.out(); + runtime.out( + dim(` ${result.errorCount} errors, ${result.warningCount} warnings — full report: ${CLI_BIN} spec analyze ${folder.name}`), + ); return; } @@ -220,6 +286,7 @@ Examples: return; } - printSummary(runtime, analysis); - }); + printSummary(runtime, analysis, view); + }, + ); } diff --git a/packages/cli/src/commands/spec-status.ts b/packages/cli/src/commands/spec-status.ts new file mode 100644 index 0000000..ec66530 --- /dev/null +++ b/packages/cli/src/commands/spec-status.ts @@ -0,0 +1,214 @@ +import type { Command } from 'commander'; +import type { StageName } from '@specbridge/core'; +import { CLI_BIN } from '@specbridge/core'; +import { analyzeSpec, requireSpec, specFile } from '@specbridge/compat-kiro'; +import type { StageEvaluation, WorkflowEvaluation } from '@specbridge/workflow'; +import { analyzeSpecWorkflow, documentStageFor } from '@specbridge/workflow'; +import { + activeLine, + blockedLine, + createJsonReport, + dim, + infoLine, + okLine, + reportTitle, + sectionTitle, + serializeJsonReport, + severityLine, + warnLine, +} from '@specbridge/reporting'; +import type { CliRuntime } from '../context.js'; +import { relPath } from '../context.js'; +import { loadWorkflowView } from '../workflow-view.js'; +import { VERSION } from '../version.js'; + +/** + * `specbridge spec status ` — workflow status, per-stage approval + * health, and analysis findings for one spec. Read-only: stale approvals are + * reported, never silently repaired. + */ + +interface SpecStatusOptions { + json?: boolean; + verbose?: boolean; +} + +function describeStage( + runtime: CliRuntime, + evaluation: WorkflowEvaluation, + stage: StageEvaluation, + verbose: boolean, +): void { + const title = stage.stage.charAt(0).toUpperCase() + stage.stage.slice(1); + runtime.out(`${title}`); + switch (stage.effective) { + case 'approved': + runtime.out(okLine('Approved')); + if (stage.stored.approvedAt !== null) { + runtime.out(dim(` Approved at: ${stage.stored.approvedAt}`)); + } + runtime.out(dim(' Content unchanged since approval')); + if (verbose && stage.stored.approvedHash !== null) { + runtime.out(dim(` Approved hash: ${stage.stored.approvedHash}`)); + } + break; + case 'modified-after-approval': + runtime.out(warnLine('Modified after approval')); + runtime.out(dim(` Approved hash: ${stage.stored.approvedHash ?? '(none)'}`)); + runtime.out(dim(` Current hash: ${stage.currentHash ?? '(file missing or unreadable)'}`)); + runtime.out(dim(` Re-approve with: ${CLI_BIN} spec approve --stage ${stage.stage}`)); + break; + case 'stale-prerequisite': + runtime.out(warnLine('Approval is stale (an earlier stage changed after this was approved)')); + if (stage.stored.approvedAt !== null) { + runtime.out(dim(` Originally approved at: ${stage.stored.approvedAt}`)); + } + break; + case 'draft': { + runtime.out(activeLine('Draft')); + const prerequisites = stage.prerequisites; + if (prerequisites.length > 0) { + runtime.out(dim(' Prerequisites satisfied')); + } + break; + } + case 'blocked': { + runtime.out(blockedLine('Blocked')); + const unapproved = stage.prerequisites.filter( + (p) => evaluation.stages.find((s) => s.stage === p)?.effective !== 'approved', + ); + if (unapproved.length > 0) { + runtime.out(dim(` Requires ${unapproved.join(' and ')} approval`)); + } + break; + } + } + runtime.out(); +} + +export function registerSpecStatusCommand(spec: Command, runtime: CliRuntime): void { + spec + .command('status ') + .description('Show workflow status, stage approvals, and approval health for a spec') + .option('--verbose', 'include full hashes and info-level diagnostics') + .option('--json', 'output a machine-readable JSON report') + .addHelpText( + 'after', + ` +Approval state comes only from .specbridge/state — a stage is never treated +as approved just because its file exists. Specs without sidecar state are +reported as unmanaged (normal for existing Kiro projects). + +Examples: + ${CLI_BIN} spec status notification-preferences + ${CLI_BIN} spec status notification-preferences --json + ${CLI_BIN} spec status notification-preferences --verbose`, + ) + .action((name: string, options: SpecStatusOptions) => { + const workspace = runtime.workspace(); + const folder = requireSpec(workspace, name); + const spec = analyzeSpec(workspace, folder); + const view = loadWorkflowView(workspace, folder.name); + const analysis = analyzeSpecWorkflow(spec, view.evaluation); + + if (options.json === true) { + runtime.outRaw( + serializeJsonReport( + createJsonReport('specbridge.spec-status/1', `${CLI_BIN} ${VERSION}`, { + specName: folder.name, + specType: view.stateRead.state?.specType ?? spec.classification.type, + workflowMode: view.stateRead.state?.workflowMode ?? spec.classification.workflowMode, + origin: view.stateRead.state?.origin ?? null, + managed: view.evaluation !== undefined, + approvalHealth: view.health, + status: view.evaluation?.storedStatus ?? null, + effectiveStatus: view.displayStatus, + stages: + view.evaluation?.stages.map((stage) => ({ + stage: stage.stage, + status: stage.stored.status, + effective: stage.effective, + file: stage.stored.file, + fileExists: stage.fileExists, + approvedAt: stage.stored.approvedAt, + approvedHash: stage.stored.approvedHash, + currentHash: stage.currentHash ?? null, + prerequisites: stage.prerequisites, + })) ?? null, + files: folder.files.map((f) => ({ fileName: f.fileName, kind: f.kind })), + analysis: { + errorCount: analysis.errorCount, + warningCount: analysis.warningCount, + diagnostics: analysis.diagnostics, + }, + stateDiagnostics: view.stateRead.diagnostics, + }), + ), + ); + return; + } + + runtime.out(reportTitle(`Spec: ${folder.name}`)); + runtime.out(`Type: ${view.stateRead.state?.specType ?? spec.classification.type}`); + runtime.out(`Mode: ${view.stateRead.state?.workflowMode ?? spec.classification.workflowMode}`); + runtime.out(`Status: ${view.displayStatus}`); + if (view.stateRead.state?.origin === 'existing-kiro-workspace') { + runtime.out(dim('Origin: initialized from an existing Kiro workspace')); + } + runtime.out(); + + for (const diagnostic of view.stateRead.diagnostics) { + runtime.out(severityLine(diagnostic.severity, diagnostic.message)); + } + + if (view.evaluation === undefined) { + runtime.out('Approval state: unmanaged'); + runtime.out( + dim( + ' This spec has no SpecBridge sidecar state — normal for a spec created by Kiro.\n' + + ' Files stay untouched either way. To start managing approvals, run:', + ), + ); + const firstStage = documentStageFor(spec.classification.type === 'bugfix' ? 'bugfix' : 'feature'); + runtime.out(dim(` ${CLI_BIN} spec approve ${folder.name} --stage ${firstStage}`)); + runtime.out(); + } else { + runtime.out(sectionTitle('Stages')); + runtime.out(); + for (const stage of view.evaluation.stages) { + describeStage(runtime, view.evaluation, stage, options.verbose === true); + } + } + + runtime.out(sectionTitle('Files')); + const expected: StageName[] = + spec.classification.type === 'bugfix' + ? ['bugfix', 'design', 'tasks'] + : ['requirements', 'design', 'tasks']; + for (const kind of expected) { + const file = specFile(folder, kind); + if (file !== undefined) { + runtime.out(okLine(file.fileName)); + } else { + runtime.out(infoLine(`${kind}.md not present`)); + } + } + runtime.out(); + + runtime.out(sectionTitle('Diagnostics')); + const visible = analysis.diagnostics.filter( + (d) => options.verbose === true || d.severity !== 'info', + ); + if (visible.length === 0) { + runtime.out(okLine('none')); + } else { + for (const diagnostic of visible) { + const location = + diagnostic.file !== undefined + ? ` [${relPath(workspace, diagnostic.file)}${diagnostic.line !== undefined ? `:${diagnostic.line}` : ''}]` + : ''; + runtime.out(severityLine(diagnostic.severity, `${diagnostic.message}${location}`)); + } + } + }); +} diff --git a/packages/cli/src/context.ts b/packages/cli/src/context.ts index 1103001..dc7e09e 100644 --- a/packages/cli/src/context.ts +++ b/packages/cli/src/context.ts @@ -12,6 +12,8 @@ export interface CliIo { /** Write exact text (no newline appended). */ outRaw: (text: string) => void; err: (line: string) => void; + /** Clock used for every timestamp a command records (injectable in tests). */ + now: () => Date; } export function defaultIo(): CliIo { @@ -20,6 +22,7 @@ export function defaultIo(): CliIo { out: (line) => process.stdout.write(`${line}\n`), outRaw: (text) => process.stdout.write(text), err: (line) => process.stderr.write(`${line}\n`), + now: () => new Date(), }; } @@ -53,6 +56,10 @@ export class CliRuntime { return resolveWorkspace(this.cwd); } + now(): Date { + return this.io.now(); + } + out(line = ''): void { this.io.out(line); } diff --git a/packages/cli/src/version.ts b/packages/cli/src/version.ts index 15718b4..98e419b 100644 --- a/packages/cli/src/version.ts +++ b/packages/cli/src/version.ts @@ -2,4 +2,4 @@ * Single source of the CLI version string. Keep in sync with * packages/cli/package.json when releasing (checked by scripts/smoke.mjs). */ -export const VERSION = '0.1.0'; +export const VERSION = '0.2.0'; diff --git a/packages/cli/src/workflow-view.ts b/packages/cli/src/workflow-view.ts new file mode 100644 index 0000000..8ffc7a3 --- /dev/null +++ b/packages/cli/src/workflow-view.ts @@ -0,0 +1,31 @@ +import type { ApprovalHealth, SpecStateReadResult, WorkspaceInfo } from '@specbridge/core'; +import { readSpecState } from '@specbridge/core'; +import type { WorkflowEvaluation } from '@specbridge/workflow'; +import { evaluateWorkflow } from '@specbridge/workflow'; + +/** + * One read of a spec's workflow state, shared by status/list/show/doctor. + * Never writes: stale approvals are computed in memory only. + */ +export interface SpecWorkflowView { + stateRead: SpecStateReadResult; + evaluation?: WorkflowEvaluation; + health: ApprovalHealth; + /** Workflow status for display: a WorkflowStatus, STALE_APPROVAL, unmanaged, or invalid. */ + displayStatus: string; +} + +export function loadWorkflowView(workspace: WorkspaceInfo, specName: string): SpecWorkflowView { + const stateRead = readSpecState(workspace, specName); + if (stateRead.state === undefined) { + const health: ApprovalHealth = stateRead.exists ? 'invalid' : 'unmanaged'; + return { stateRead, health, displayStatus: health }; + } + const evaluation = evaluateWorkflow(workspace, stateRead.state); + return { + stateRead, + evaluation, + health: evaluation.health, + displayStatus: evaluation.effectiveStatus, + }; +} diff --git a/packages/compat-kiro/package.json b/packages/compat-kiro/package.json index e974d37..87f18e7 100644 --- a/packages/compat-kiro/package.json +++ b/packages/compat-kiro/package.json @@ -1,6 +1,6 @@ { "name": "@specbridge/compat-kiro", - "version": "0.1.0", + "version": "0.2.0", "description": "Read and round-trip-safely edit existing .kiro steering and spec files without migration.", "license": "MIT", "type": "module", @@ -12,7 +12,9 @@ "import": "./dist/index.js" } }, - "files": ["dist"], + "files": [ + "dist" + ], "engines": { "node": ">=20.0.0" }, diff --git a/packages/compat-kiro/src/diagnostics.ts b/packages/compat-kiro/src/diagnostics.ts index cce2ebc..f92412f 100644 --- a/packages/compat-kiro/src/diagnostics.ts +++ b/packages/compat-kiro/src/diagnostics.ts @@ -1,4 +1,4 @@ -import type { Diagnostic, SpecState, TaskProgress, WorkspaceInfo } from '@specbridge/core'; +import type { Diagnostic, SpecWorkflowState, TaskProgress, WorkspaceInfo } from '@specbridge/core'; import { EMPTY_TASK_PROGRESS, hasErrors, readSpecState } from '@specbridge/core'; import { extractFrontMatter } from './steering-loader.js'; import type { SteeringFileInfo } from './steering-loader.js'; @@ -28,7 +28,7 @@ import { checkNoopRoundTrip } from './roundtrip-writer.js'; export interface SpecAnalysis { folder: SpecFolder; classification: SpecClassification; - state?: SpecState; + state?: SpecWorkflowState; documents: Partial>; requirements?: RequirementsModel; design?: DesignModel; diff --git a/packages/compat-kiro/src/spec-classifier.ts b/packages/compat-kiro/src/spec-classifier.ts index 68a40f0..edd02e5 100644 --- a/packages/compat-kiro/src/spec-classifier.ts +++ b/packages/compat-kiro/src/spec-classifier.ts @@ -2,8 +2,8 @@ import type { Diagnostic, SpecCompleteness, SpecFileKind, - SpecState, SpecType, + SpecWorkflowState, WorkflowMode, } from '@specbridge/core'; import type { SpecFolder } from './spec-discovery.js'; @@ -30,7 +30,7 @@ export interface SpecClassification { const FEATURE_REQUIRED: SpecFileKind[] = ['requirements', 'design', 'tasks']; const BUGFIX_REQUIRED: SpecFileKind[] = ['bugfix', 'design', 'tasks']; -export function classifySpec(folder: SpecFolder, state?: SpecState): SpecClassification { +export function classifySpec(folder: SpecFolder, state?: SpecWorkflowState): SpecClassification { const diagnostics: Diagnostic[] = []; const presentKinds: SpecFileKind[] = [...new Set(folder.files.map((f) => f.kind))].filter( (kind): kind is SpecFileKind => kind !== 'other', diff --git a/packages/core/package.json b/packages/core/package.json index 41375e3..5008202 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@specbridge/core", - "version": "0.1.0", + "version": "0.2.0", "description": "Core types, errors, workspace detection, and sidecar state for SpecBridge.", "license": "MIT", "type": "module", @@ -12,7 +12,9 @@ "import": "./dist/index.js" } }, - "files": ["dist"], + "files": [ + "dist" + ], "engines": { "node": ">=20.0.0" }, diff --git a/packages/core/src/errors.ts b/packages/core/src/errors.ts index a27be8b..19bec2f 100644 --- a/packages/core/src/errors.ts +++ b/packages/core/src/errors.ts @@ -1,6 +1,7 @@ export type SpecBridgeErrorCode = | 'WORKSPACE_NOT_FOUND' | 'SPEC_NOT_FOUND' + | 'SPEC_ALREADY_EXISTS' | 'STEERING_NOT_FOUND' | 'SPEC_FILE_NOT_FOUND' | 'INVALID_ARGUMENT' diff --git a/packages/core/src/hash.ts b/packages/core/src/hash.ts new file mode 100644 index 0000000..25c1d5d --- /dev/null +++ b/packages/core/src/hash.ts @@ -0,0 +1,31 @@ +import { createHash } from 'node:crypto'; +import { readFileSync } from 'node:fs'; +import { ioError } from './errors.js'; + +/** + * Approval hashing. Hashes are always computed over the exact file bytes — + * never over decoded or normalized text — so CRLF/LF, BOMs, and trailing + * newlines all participate. A one-byte change is a different document. + */ + +export function sha256Hex(data: string | Buffer): string { + return createHash('sha256').update(data).digest('hex'); +} + +/** SHA-256 of a file's exact bytes. Throws `IO_ERROR` when unreadable. */ +export function sha256File(filePath: string): string { + try { + return sha256Hex(readFileSync(filePath)); + } catch (cause) { + throw ioError('hash', filePath, cause); + } +} + +/** SHA-256 of a file's exact bytes, or `undefined` when the file is missing/unreadable. */ +export function trySha256File(filePath: string): string | undefined { + try { + return sha256Hex(readFileSync(filePath)); + } catch { + return undefined; + } +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 64ee412..8a2ee57 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -2,4 +2,5 @@ export * from './types.js'; export * from './errors.js'; export * from './result.js'; export * from './workspace.js'; +export * from './hash.js'; export * from './spec-state.js'; diff --git a/packages/core/src/spec-state.ts b/packages/core/src/spec-state.ts index 7603835..deb62b2 100644 --- a/packages/core/src/spec-state.ts +++ b/packages/core/src/spec-state.ts @@ -1,56 +1,119 @@ import { existsSync, readFileSync } from 'node:fs'; import path from 'node:path'; import { z } from 'zod'; -import type { Diagnostic } from './types.js'; +import type { Diagnostic, StageName } from './types.js'; +import { WORKFLOW_STATUS_VALUES } from './types.js'; import type { WorkspaceInfo } from './workspace.js'; import { assertInsideWorkspace, writeFileAtomic } from './workspace.js'; /** - * Sidecar state lives in `.specbridge/state/specs/.json`. + * Sidecar workflow state lives in `.specbridge/state/specs/.json`. * * It records everything SpecBridge knows about a spec that the `.kiro` - * Markdown files do not express (workflow mode, approvals, verification - * configuration). `.kiro` files are never used to store this data. + * Markdown files do not express: workflow mode, stage approvals, approval + * hashes, and timestamps. `.kiro` files are never used to store this data, + * and approval is never inferred from file existence — only from here. + * + * The schema is versioned. Readers accept any 1.x file; unknown extra + * properties written by newer 1.x versions survive a read-modify-write. */ -export const approvalSchema = z.object({ - approved: z.boolean(), - approvedAt: z.string().optional(), - approvedBy: z.string().optional(), +export const SPEC_STATE_SCHEMA_VERSION = '1.0.0'; + +const SHA256_HEX = /^[0-9a-f]{64}$/; + +export const stageApprovalSchema = z.object({ + status: z.enum(['blocked', 'draft', 'approved']), + /** Workspace-relative path with forward slashes, e.g. `.kiro/specs/x/design.md`. */ + file: z.string().min(1), + /** ISO-8601 timestamp of the recorded approval, or null when not approved. */ + approvedAt: z.string().datetime({ offset: true }).nullable(), + /** SHA-256 (hex) of the exact approved file bytes, or null when not approved. */ + approvedHash: z.string().regex(SHA256_HEX, 'must be a lowercase sha256 hex digest').nullable(), }); -export const specStatusValues = [ - 'DRAFT', - 'REQUIREMENTS_APPROVED', - 'DESIGN_APPROVED', - 'READY_FOR_EXECUTION', - 'IN_PROGRESS', - 'COMPLETE', -] as const; +export type StageApproval = z.infer; -export const specStateSchema = z +const stagesSchema = z .object({ + requirements: stageApprovalSchema.optional(), + bugfix: stageApprovalSchema.optional(), + design: stageApprovalSchema, + tasks: stageApprovalSchema, + }) + .passthrough(); + +export const specWorkflowStateSchema = z + .object({ + schemaVersion: z.string().regex(/^\d+\.\d+\.\d+$/), specName: z.string().min(1), specType: z.enum(['feature', 'bugfix']), workflowMode: z.enum(['requirements-first', 'design-first', 'quick']), - status: z.enum(specStatusValues), - approvals: z - .object({ - requirements: approvalSchema.optional(), - design: approvalSchema.optional(), - tasks: approvalSchema.optional(), - }) - .optional(), - declaredImpactAreas: z.array(z.string()).optional(), - verificationCommands: z.array(z.string()).optional(), - createdAt: z.string().optional(), - updatedAt: z.string().optional(), + origin: z + .enum(['created-by-specbridge', 'existing-kiro-workspace']) + .default('created-by-specbridge'), + status: z.enum(WORKFLOW_STATUS_VALUES), + createdAt: z.string().datetime({ offset: true }), + updatedAt: z.string().datetime({ offset: true }), + stages: stagesSchema, }) - // Unknown fields written by newer SpecBridge versions must survive. - .passthrough(); + // Unknown top-level fields written by newer 1.x SpecBridge versions must survive. + .passthrough() + .superRefine((state, ctx) => { + const documentStage = state.specType === 'bugfix' ? 'bugfix' : 'requirements'; + const wrongStage = state.specType === 'bugfix' ? 'requirements' : 'bugfix'; + if (state.stages[documentStage] === undefined) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['stages', documentStage], + message: `a ${state.specType} spec must have a "${documentStage}" stage`, + }); + } + if (state.stages[wrongStage] !== undefined) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['stages', wrongStage], + message: `a ${state.specType} spec must not have a "${wrongStage}" stage`, + }); + } + for (const [name, stage] of Object.entries(state.stages)) { + if (stage === undefined || typeof stage !== 'object') continue; + const approval = stage as StageApproval; + const approved = approval.status === 'approved'; + if (approved && (approval.approvedAt === null || approval.approvedHash === null)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['stages', name], + message: 'an approved stage must record approvedAt and approvedHash', + }); + } + if (!approved && (approval.approvedAt !== null || approval.approvedHash !== null)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['stages', name], + message: 'a stage that is not approved must have null approvedAt and approvedHash', + }); + } + } + }); + +export type SpecWorkflowState = z.infer; + +/** Stages recorded in a state file, preserving the stored (workflow) order. */ +export function stateStageNames(state: SpecWorkflowState): StageName[] { + const known: StageName[] = []; + for (const key of Object.keys(state.stages)) { + if (key === 'requirements' || key === 'bugfix' || key === 'design' || key === 'tasks') { + known.push(key); + } + } + return known; +} -export type SpecState = z.infer; -export type SpecStatus = (typeof specStatusValues)[number]; +export function stateStage(state: SpecWorkflowState, stage: StageName): StageApproval | undefined { + const value = (state.stages as Record)[stage]; + return value === undefined ? undefined : (value as StageApproval); +} export const specbridgeConfigSchema = z .object({ @@ -64,7 +127,7 @@ export type SpecbridgeConfig = z.infer; export interface SpecStateReadResult { path: string; exists: boolean; - state?: SpecState; + state?: SpecWorkflowState; diagnostics: Diagnostic[]; } @@ -76,6 +139,40 @@ export function specStatePath(workspace: WorkspaceInfo, specName: string): strin ); } +/** Directory that holds one JSON state file per spec. */ +export function specStateDir(workspace: WorkspaceInfo): string { + return path.join(workspace.sidecarDir, 'state', 'specs'); +} + +function invalidStateDiagnostic(statePath: string, parsed: unknown, issues: string): Diagnostic { + const record = typeof parsed === 'object' && parsed !== null ? (parsed as Record) : {}; + if (record['schemaVersion'] === undefined && record['specName'] !== undefined) { + return { + severity: 'warning', + code: 'SIDECAR_STATE_LEGACY', + message: + 'Sidecar state file predates the versioned 1.0.0 schema; ignoring it. ' + + 'Re-approve the stages you trust to regenerate it (the .kiro files are unaffected).', + file: statePath, + }; + } + const version = record['schemaVersion']; + if (typeof version === 'string' && !version.startsWith('1.')) { + return { + severity: 'warning', + code: 'SIDECAR_STATE_UNSUPPORTED_VERSION', + message: `Sidecar state schema version ${version} is not supported by this SpecBridge version; ignoring it.`, + file: statePath, + }; + } + return { + severity: 'warning', + code: 'SIDECAR_STATE_INVALID_SHAPE', + message: `Sidecar state file does not match the expected schema; ignoring it. (${issues})`, + file: statePath, + }; +} + /** Read sidecar state. Missing or invalid state never throws — it degrades to diagnostics. */ export function readSpecState(workspace: WorkspaceInfo, specName: string): SpecStateReadResult { const statePath = specStatePath(workspace, specName); @@ -119,18 +216,27 @@ export function readSpecState(workspace: WorkspaceInfo, specName: string): SpecS }; } - const result = specStateSchema.safeParse(parsed); + const result = specWorkflowStateSchema.safeParse(parsed); if (!result.success) { + const issues = result.error.issues + .map((i) => `${i.path.join('.')}: ${i.message}`) + .join('; '); + return { + path: statePath, + exists: true, + diagnostics: [invalidStateDiagnostic(statePath, parsed, issues)], + }; + } + + if (result.data.specName !== specName) { return { path: statePath, exists: true, diagnostics: [ { severity: 'warning', - code: 'SIDECAR_STATE_INVALID_SHAPE', - message: `Sidecar state file does not match the expected schema; ignoring it. (${result.error.issues - .map((i) => `${i.path.join('.')}: ${i.message}`) - .join('; ')})`, + code: 'SIDECAR_STATE_NAME_MISMATCH', + message: `Sidecar state file records specName "${result.data.specName}" but is stored as ${specName}.json; ignoring it.`, file: statePath, }, ], @@ -141,7 +247,7 @@ export function readSpecState(workspace: WorkspaceInfo, specName: string): SpecS } /** Persist sidecar state atomically. Creates `.specbridge/state/specs/` on demand. */ -export function writeSpecState(workspace: WorkspaceInfo, state: SpecState): string { +export function writeSpecState(workspace: WorkspaceInfo, state: SpecWorkflowState): string { const statePath = specStatePath(workspace, state.specName); writeFileAtomic(statePath, `${JSON.stringify(state, null, 2)}\n`); return statePath; diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 53a4509..ba6b11c 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -39,6 +39,56 @@ export type SpecType = 'feature' | 'bugfix' | 'unknown'; export type WorkflowMode = 'requirements-first' | 'design-first' | 'quick' | 'unknown'; +/** Spec type as stored in sidecar state — never `unknown` once managed. */ +export type ConcreteSpecType = 'feature' | 'bugfix'; + +/** Workflow mode as stored in sidecar state — never `unknown` once managed. */ +export type ConcreteWorkflowMode = 'requirements-first' | 'design-first' | 'quick'; + +/** + * Approvable workflow stages. Feature specs use requirements/design/tasks; + * bugfix specs replace the requirements stage with bugfix. + */ +export const STAGE_NAMES = ['requirements', 'bugfix', 'design', 'tasks'] as const; +export type StageName = (typeof STAGE_NAMES)[number]; + +/** + * Stored per-stage status. `blocked` means prerequisite stages are not + * approved yet; `draft` means the stage is editable and approvable. + * Approval is only ever recorded here — never inferred from file existence. + */ +export type StageStatus = 'blocked' | 'draft' | 'approved'; + +/** How the sidecar state for a spec came to exist. */ +export type SpecOrigin = 'created-by-specbridge' | 'existing-kiro-workspace'; + +/** + * Workflow status values stored in sidecar state. Which values a spec can + * reach depends on its type and workflow mode (see @specbridge/workflow). + */ +export const WORKFLOW_STATUS_VALUES = [ + 'REQUIREMENTS_DRAFT', + 'REQUIREMENTS_APPROVED', + 'BUGFIX_DRAFT', + 'BUGFIX_APPROVED', + 'DESIGN_DRAFT', + 'DESIGN_APPROVED', + 'TASKS_DRAFT', + 'READY_FOR_REVIEW', + 'READY_FOR_IMPLEMENTATION', +] as const; +export type WorkflowStatus = (typeof WORKFLOW_STATUS_VALUES)[number]; + +/** + * Overall approval health of a spec, computed at read time: + * - `ok` — every recorded approval still matches the file bytes + * - `stale` — an approved file changed after approval (or a dependent + * approval was invalidated by that change) + * - `unmanaged` — the spec has no sidecar state (existing Kiro workspace) + * - `invalid` — sidecar state exists but could not be used + */ +export type ApprovalHealth = 'ok' | 'stale' | 'unmanaged' | 'invalid'; + export type SpecCompleteness = 'complete' | 'partial' | 'empty'; export interface TaskProgress { diff --git a/packages/core/src/workspace.ts b/packages/core/src/workspace.ts index d980a51..d001112 100644 --- a/packages/core/src/workspace.ts +++ b/packages/core/src/workspace.ts @@ -1,4 +1,14 @@ -import { existsSync, mkdirSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs'; +import { + closeSync, + existsSync, + fsyncSync, + mkdirSync, + openSync, + renameSync, + rmSync, + statSync, + writeSync, +} from 'node:fs'; import path from 'node:path'; import { SpecBridgeError, ioError } from './errors.js'; import { @@ -107,8 +117,9 @@ export function assertInsideWorkspace(rootDir: string, target: string): string { } /** - * Atomic file write: write to a temp sibling, then rename over the target. - * Guarantees readers never observe a half-written file. + * Atomic file write: write to a temp sibling, fsync, then rename over the + * target. Guarantees readers never observe a half-written file and the temp + * file never survives a failure. */ export function writeFileAtomic(filePath: string, data: string | Buffer): void { const dir = path.dirname(filePath); @@ -118,7 +129,13 @@ export function writeFileAtomic(filePath: string, data: string | Buffer): void { ); try { mkdirSync(dir, { recursive: true }); - writeFileSync(tempPath, data); + const fd = openSync(tempPath, 'w'); + try { + writeSync(fd, typeof data === 'string' ? Buffer.from(data, 'utf8') : data); + fsyncSync(fd); + } finally { + closeSync(fd); + } renameSync(tempPath, filePath); } catch (cause) { rmSync(tempPath, { force: true }); diff --git a/packages/drift/package.json b/packages/drift/package.json index 38d1b17..ca89cac 100644 --- a/packages/drift/package.json +++ b/packages/drift/package.json @@ -1,6 +1,6 @@ { "name": "@specbridge/drift", - "version": "0.1.0", + "version": "0.2.0", "description": "Deterministic spec-to-code drift primitives for SpecBridge (no LLM required).", "license": "MIT", "type": "module", @@ -12,7 +12,9 @@ "import": "./dist/index.js" } }, - "files": ["dist"], + "files": [ + "dist" + ], "engines": { "node": ">=20.0.0" }, diff --git a/packages/reporting/package.json b/packages/reporting/package.json index 250430f..ea0e03a 100644 --- a/packages/reporting/package.json +++ b/packages/reporting/package.json @@ -1,6 +1,6 @@ { "name": "@specbridge/reporting", - "version": "0.1.0", + "version": "0.2.0", "description": "Terminal, JSON, and self-contained HTML report rendering for SpecBridge.", "license": "MIT", "type": "module", @@ -12,7 +12,9 @@ "import": "./dist/index.js" } }, - "files": ["dist"], + "files": [ + "dist" + ], "engines": { "node": ">=20.0.0" }, diff --git a/packages/reporting/src/terminal-report.ts b/packages/reporting/src/terminal-report.ts index 51ea96d..0b5d079 100644 --- a/packages/reporting/src/terminal-report.ts +++ b/packages/reporting/src/terminal-report.ts @@ -12,8 +12,18 @@ export const sym = { fail: '✗', info: '·', add: '+', + active: '●', + blocked: '○', } as const; +export function activeLine(message: string, detail?: string): string { + return ` ${pc.cyan(sym.active)} ${message}${detail !== undefined ? ` ${pc.dim(detail)}` : ''}`; +} + +export function blockedLine(message: string, detail?: string): string { + return ` ${pc.dim(sym.blocked)} ${message}${detail !== undefined ? ` ${pc.dim(detail)}` : ''}`; +} + export function okLine(message: string, detail?: string): string { return ` ${pc.green(sym.ok)} ${message}${detail !== undefined ? ` ${pc.dim(detail)}` : ''}`; } diff --git a/packages/runners/package.json b/packages/runners/package.json index 223cea7..4fd974f 100644 --- a/packages/runners/package.json +++ b/packages/runners/package.json @@ -1,6 +1,6 @@ { "name": "@specbridge/runners", - "version": "0.1.0", + "version": "0.2.0", "description": "Model- and agent-agnostic runner adapters for SpecBridge (mock, Claude Code, Codex, stubs).", "license": "MIT", "type": "module", @@ -12,7 +12,9 @@ "import": "./dist/index.js" } }, - "files": ["dist"], + "files": [ + "dist" + ], "engines": { "node": ">=20.0.0" }, diff --git a/packages/workflow/package.json b/packages/workflow/package.json new file mode 100644 index 0000000..20dbc95 --- /dev/null +++ b/packages/workflow/package.json @@ -0,0 +1,34 @@ +{ + "name": "@specbridge/workflow", + "version": "0.2.0", + "description": "Offline spec authoring and approval workflow for SpecBridge: templates, deterministic analysis, stage approvals, and stale-approval detection.", + "license": "MIT", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist" + ], + "engines": { + "node": ">=20.0.0" + }, + "scripts": { + "build": "tsup", + "clean": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\"", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@specbridge/compat-kiro": "workspace:*", + "@specbridge/core": "workspace:*" + }, + "devDependencies": { + "tsup": "^8.3.0", + "typescript": "^5.6.0" + } +} diff --git a/packages/workflow/src/analyzers.ts b/packages/workflow/src/analyzers.ts new file mode 100644 index 0000000..62edccc --- /dev/null +++ b/packages/workflow/src/analyzers.ts @@ -0,0 +1,800 @@ +import type { Diagnostic, DiagnosticSeverity, StageName, StageStatus } from '@specbridge/core'; +import { hasErrors } from '@specbridge/core'; +import type { MarkdownDocument, SpecAnalysis, TaskItem } from '@specbridge/compat-kiro'; +import { classifyEars, findVaguePhrases, looksTestable, taskStartsWithVagueVerb } from './ears.js'; +import { scanPlaceholders } from './placeholders.js'; + +/** + * Deterministic, offline spec analysis. + * + * Every check runs on the tolerant parse models from @specbridge/compat-kiro + * plus the raw line-preserving document. No model, no network, no + * randomness: the same bytes always produce the same findings. + * + * Severity contract (enforced by `spec approve`): + * - `error` — blocks approval of the analyzed stage + * - `warning` — reported, never blocks (unless the user passes --strict) + * - `info` — context only + */ + +export interface StageAnalysisOptions { + /** + * Severity of placeholder findings. Placeholders are errors for the stage + * being approved (or any active stage) and warnings for stages that are + * still blocked behind an unapproved prerequisite. + */ + placeholderSeverity: 'error' | 'warning'; + /** Severity when the stage file does not exist (info for blocked stages). */ + missingFileSeverity: DiagnosticSeverity; + /** Stored stage status, when the spec is managed. Enables state-aware checks. */ + stageStatus?: StageStatus; + /** + * False when a prerequisite stage is not approved yet. Used only for the + * "design written before its prerequisite was approved" advisory. + */ + prerequisitesApproved?: boolean; +} + +export interface StageAnalysis { + stage: StageName; + fileName: string; + filePath?: string; + fileExists: boolean; + diagnostics: Diagnostic[]; +} + +export interface SpecAnalysisResult { + specName: string; + stages: StageAnalysis[]; + diagnostics: Diagnostic[]; + errorCount: number; + warningCount: number; + hasErrors: boolean; +} + +function diag( + severity: DiagnosticSeverity, + code: string, + message: string, + file?: string, + line?: number, +): Diagnostic { + return { + severity, + code, + message, + ...(file !== undefined ? { file } : {}), + ...(line !== undefined ? { line } : {}), + }; +} + +function isDocumentEmpty(document: MarkdownDocument): boolean { + return document.lines.every((line) => line.text.trim().length === 0); +} + +function pushPlaceholderDiagnostics( + document: MarkdownDocument, + code: string, + options: StageAnalysisOptions, + diagnostics: Diagnostic[], +): void { + const scan = scanPlaceholders(document); + for (const hit of scan.hits) { + diagnostics.push( + diag( + options.placeholderSeverity, + code, + `Placeholder content: ${hit.text}`, + document.filePath, + hit.line + 1, + ), + ); + } + if (scan.placeholderOnly) { + diagnostics.push( + diag( + options.placeholderSeverity, + `${code}_ONLY`, + 'The document consists entirely of template placeholders; replace them with real content.', + document.filePath, + ), + ); + } +} + +function sectionText(document: MarkdownDocument, startLine: number, endLine: number): string { + return document + .getText(startLine + 1, endLine) + .replace(/\s+/g, ' ') + .trim(); +} + +function hasSectionMatching(document: MarkdownDocument, pattern: RegExp): boolean { + return document.headings().some((heading) => pattern.test(heading.text)); +} + +// --------------------------------------------------------------------------- +// Requirements +// --------------------------------------------------------------------------- + +const ERROR_BEHAVIOR = /\b(if|error|errors|fail|fails|failure|invalid|unavailable|timeout|times out|reject|rejects|denied|missing|exception)\b/i; +const OUT_OF_SCOPE_SECTION = /out of scope|non-goals|exclusions|not in scope/i; +const NFR_SECTION = /non-functional|quality attributes|\bnfr\b/i; +const INTRO_SECTION = /^(introduction|overview|summary)$/i; + +export function analyzeRequirementsStage( + spec: SpecAnalysis, + options: StageAnalysisOptions, +): StageAnalysis { + const document = spec.documents.requirements; + const diagnostics: Diagnostic[] = []; + const filePath = document?.filePath; + + if (document === undefined || spec.requirements === undefined) { + diagnostics.push( + diag( + options.missingFileSeverity, + 'REQUIREMENTS_MISSING', + 'requirements.md does not exist. Create it (or run "spec new" for a template in a fresh spec).', + spec.folder.dir, + ), + ); + return { stage: 'requirements', fileName: 'requirements.md', fileExists: false, diagnostics }; + } + + if (isDocumentEmpty(document)) { + diagnostics.push( + diag('error', 'REQUIREMENTS_EMPTY', 'requirements.md is empty.', filePath), + ); + return { + stage: 'requirements', + fileName: 'requirements.md', + ...(filePath !== undefined ? { filePath } : {}), + fileExists: true, + diagnostics, + }; + } + + const model = spec.requirements; + pushPlaceholderDiagnostics(document, 'REQUIREMENTS_PLACEHOLDER', options, diagnostics); + + if (model.title === undefined) { + diagnostics.push( + diag('warning', 'REQUIREMENTS_NO_TITLE', 'No top-level "# ..." title found.', filePath), + ); + } + const hasIntroduction = + model.introduction !== undefined || + document.sections().some((s) => s.heading.level <= 2 && INTRO_SECTION.test(s.heading.text.trim())); + if (!hasIntroduction) { + diagnostics.push( + diag( + 'warning', + 'REQUIREMENTS_NO_INTRODUCTION', + 'No Introduction/Overview/Summary section found.', + filePath, + ), + ); + } + + if (model.requirements.length === 0) { + diagnostics.push( + diag( + 'error', + 'REQUIREMENTS_NONE', + 'No requirements recognized. Add at least one "### Requirement 1: " block with acceptance criteria.', + filePath, + ), + ); + } + + // Duplicate requirement ids make task references ambiguous — error. + const seenIds = new Map<string, number>(); + for (const requirement of model.requirements) { + const previous = seenIds.get(requirement.id); + if (previous !== undefined) { + diagnostics.push( + diag( + 'error', + 'REQUIREMENTS_DUPLICATE_ID', + `Requirement id ${requirement.id} appears more than once (also on line ${previous}).`, + filePath, + requirement.headingLine + 1, + ), + ); + } else { + seenIds.set(requirement.id, requirement.headingLine + 1); + } + } + + let anyUserStory = false; + let anyErrorBehavior = false; + + for (const requirement of model.requirements) { + if (requirement.userStory !== undefined && requirement.userStory.length > 0) { + anyUserStory = true; + } + + if (requirement.criteria.length === 0) { + diagnostics.push( + diag( + 'error', + 'REQUIREMENTS_NO_CRITERIA', + `Requirement ${requirement.id} has no acceptance criteria.`, + filePath, + requirement.headingLine + 1, + ), + ); + continue; + } + + const seenCriteria = new Map<string, number>(); + for (const criterion of requirement.criteria) { + const previous = seenCriteria.get(criterion.number); + if (previous !== undefined) { + diagnostics.push( + diag( + 'error', + 'REQUIREMENTS_DUPLICATE_CRITERION', + `Acceptance criterion ${criterion.id} is numbered more than once (also on line ${previous}).`, + filePath, + criterion.line + 1, + ), + ); + } else { + seenCriteria.set(criterion.number, criterion.line + 1); + } + + if (criterion.text.trim().length === 0) { + diagnostics.push( + diag( + 'error', + 'REQUIREMENTS_EMPTY_CRITERION', + `Acceptance criterion ${criterion.id} has no text.`, + filePath, + criterion.line + 1, + ), + ); + continue; + } + + if (ERROR_BEHAVIOR.test(criterion.text)) anyErrorBehavior = true; + + const ears = classifyEars(criterion.text); + if (ears === 'ears-malformed') { + diagnostics.push( + diag( + 'warning', + 'REQUIREMENTS_EARS_MALFORMED', + `Acceptance criterion ${criterion.id} starts an EARS pattern (WHEN/IF/WHILE/WHERE) but has no "SHALL <behavior>" clause.`, + filePath, + criterion.line + 1, + ), + ); + } else if (ears === 'plain' && !looksTestable(criterion.text)) { + diagnostics.push( + diag( + 'warning', + 'REQUIREMENTS_CRITERION_NOT_TESTABLE', + `Acceptance criterion ${criterion.id} is not written in a recognizable testable form (consider "WHEN <condition>, THE SYSTEM SHALL <behavior>").`, + filePath, + criterion.line + 1, + ), + ); + } + + const vague = findVaguePhrases(criterion.text); + if (vague.length > 0) { + diagnostics.push( + diag( + 'warning', + 'REQUIREMENTS_VAGUE_CRITERION', + `Acceptance criterion ${criterion.id} uses vague wording (${vague.join(', ')}); state observable behavior instead.`, + filePath, + criterion.line + 1, + ), + ); + } + } + } + + if (model.requirements.length > 0 && !anyUserStory) { + diagnostics.push( + diag( + 'warning', + 'REQUIREMENTS_NO_USER_STORIES', + 'No user stories found (expected "**User Story:** As a <role>, I want <capability>, so that <benefit>.").', + filePath, + ), + ); + } + if (model.requirements.length > 0 && !anyErrorBehavior) { + diagnostics.push( + diag( + 'warning', + 'REQUIREMENTS_NO_ERROR_BEHAVIOR', + 'No acceptance criterion covers error or exceptional behavior (e.g. "IF <error condition>, THEN THE SYSTEM SHALL <safe behavior>").', + filePath, + ), + ); + } + if (!hasSectionMatching(document, OUT_OF_SCOPE_SECTION)) { + diagnostics.push( + diag( + 'warning', + 'REQUIREMENTS_NO_OUT_OF_SCOPE', + 'No "Out of Scope" (or Non-Goals) section found; explicitly excluded behavior prevents scope creep.', + filePath, + ), + ); + } + if (!hasSectionMatching(document, NFR_SECTION)) { + diagnostics.push( + diag( + 'warning', + 'REQUIREMENTS_NO_NFR', + 'No non-functional requirements section found (performance, security, reliability, observability, compatibility).', + filePath, + ), + ); + } + + // Parser-level detail worth surfacing: unnumbered criteria note. + for (const parserDiagnostic of model.diagnostics) { + if (parserDiagnostic.code === 'REQUIREMENTS_UNNUMBERED_CRITERIA') { + diagnostics.push(parserDiagnostic); + } + } + + return { + stage: 'requirements', + fileName: 'requirements.md', + ...(filePath !== undefined ? { filePath } : {}), + fileExists: true, + diagnostics, + }; +} + +// --------------------------------------------------------------------------- +// Bugfix +// --------------------------------------------------------------------------- + +export function analyzeBugfixStage( + spec: SpecAnalysis, + options: StageAnalysisOptions, +): StageAnalysis { + const document = spec.documents.bugfix; + const diagnostics: Diagnostic[] = []; + const filePath = document?.filePath; + + if (document === undefined || spec.bugfix === undefined) { + diagnostics.push( + diag( + options.missingFileSeverity, + 'BUGFIX_MISSING', + 'bugfix.md does not exist. Create it (or run "spec new --type bugfix" for a template in a fresh spec).', + spec.folder.dir, + ), + ); + return { stage: 'bugfix', fileName: 'bugfix.md', fileExists: false, diagnostics }; + } + + if (isDocumentEmpty(document)) { + diagnostics.push(diag('error', 'BUGFIX_EMPTY', 'bugfix.md is empty.', filePath)); + return { + stage: 'bugfix', + fileName: 'bugfix.md', + ...(filePath !== undefined ? { filePath } : {}), + fileExists: true, + diagnostics, + }; + } + + const model = spec.bugfix; + pushPlaceholderDiagnostics(document, 'BUGFIX_PLACEHOLDER', options, diagnostics); + + const current = model.concepts['current-behavior']; + const expected = model.concepts['expected-behavior']; + + if (current === undefined) { + diagnostics.push( + diag( + 'error', + 'BUGFIX_NO_CURRENT_BEHAVIOR', + 'No "Current Behavior" section found; describe the observed incorrect behavior.', + filePath, + ), + ); + } + if (expected === undefined) { + diagnostics.push( + diag( + 'error', + 'BUGFIX_NO_EXPECTED_BEHAVIOR', + 'No "Expected Behavior" section found; describe the correct behavior.', + filePath, + ), + ); + } + if (model.concepts['unchanged-behavior'] === undefined) { + diagnostics.push( + diag( + 'warning', + 'BUGFIX_NO_UNCHANGED_BEHAVIOR', + 'No "Unchanged Behavior" section found; list behavior that must remain unchanged to bound the fix.', + filePath, + ), + ); + } + if (model.concepts.reproduction === undefined) { + diagnostics.push( + diag( + 'warning', + 'BUGFIX_NO_REPRODUCTION', + 'No "Reproduction" section found; deterministic reproduction steps make the fix verifiable.', + filePath, + ), + ); + } + if (model.concepts.evidence === undefined) { + diagnostics.push( + diag( + 'warning', + 'BUGFIX_NO_EVIDENCE', + 'No "Evidence" section found (logs, error messages, failing tests, source locations).', + filePath, + ), + ); + } + const hasRegressionDiscussion = + model.concepts['regression-protection'] !== undefined || + hasSectionMatching(document, /regression/i); + if (!hasRegressionDiscussion) { + diagnostics.push( + diag( + 'warning', + 'BUGFIX_NO_REGRESSION_RISKS', + 'No regression risk discussion found; identify behavior that could regress.', + filePath, + ), + ); + } + + if (current !== undefined && expected !== undefined) { + const currentText = sectionText(document, current.startLine, current.endLine).toLowerCase(); + const expectedText = sectionText(document, expected.startLine, expected.endLine).toLowerCase(); + if (currentText.length > 0 && currentText === expectedText) { + diagnostics.push( + diag( + 'error', + 'BUGFIX_BEHAVIOR_IDENTICAL', + 'Current Behavior and Expected Behavior are identical; a bugfix must describe what changes.', + filePath, + current.startLine + 1, + ), + ); + } + } + + return { + stage: 'bugfix', + fileName: 'bugfix.md', + ...(filePath !== undefined ? { filePath } : {}), + fileExists: true, + diagnostics, + }; +} + +// --------------------------------------------------------------------------- +// Design +// --------------------------------------------------------------------------- + +interface SectionCheck { + code: string; + pattern: RegExp; + message: string; +} + +const FEATURE_DESIGN_CHECKS: SectionCheck[] = [ + { code: 'DESIGN_NO_OVERVIEW', pattern: /overview|introduction|summary/i, message: 'No Overview section found.' }, + { code: 'DESIGN_NO_ARCHITECTURE', pattern: /architecture|approach|implementation|how it works/i, message: 'No Architecture (or implementation approach) section found.' }, + { code: 'DESIGN_NO_COMPONENTS', pattern: /component|module|service|structure/i, message: 'No Components section found.' }, + { code: 'DESIGN_NO_INTERFACES', pattern: /interface|api|contract|component/i, message: 'No interface discussion found (interfaces, APIs, or contracts).' }, + { code: 'DESIGN_NO_FAILURE_HANDLING', pattern: /failure|error[- ]handling|resilience|fault/i, message: 'No Failure Handling section found.' }, + { code: 'DESIGN_NO_SECURITY', pattern: /security|threat|auth/i, message: 'No Security Considerations section found.' }, + { code: 'DESIGN_NO_TESTING_STRATEGY', pattern: /testing|test strategy|test plan|validation/i, message: 'No Testing Strategy section found.' }, + { code: 'DESIGN_NO_RISKS', pattern: /risk|trade-?off|alternatives/i, message: 'No Risks and Trade-offs (or Alternatives Considered) section found.' }, +]; + +const BUGFIX_DESIGN_CHECKS: SectionCheck[] = [ + { code: 'DESIGN_NO_ROOT_CAUSE', pattern: /root cause/i, message: 'No Root Cause section found.' }, + { code: 'DESIGN_NO_PROPOSED_FIX', pattern: /proposed fix|fix approach|solution/i, message: 'No Proposed Fix section found.' }, + { code: 'DESIGN_NO_COMPONENTS', pattern: /component|affected/i, message: 'No Affected Components section found.' }, + { code: 'DESIGN_NO_FAILURE_HANDLING', pattern: /failure|error[- ]handling/i, message: 'No Failure Handling section found.' }, + { code: 'DESIGN_NO_REGRESSION_PROTECTION', pattern: /regression/i, message: 'No Regression Protection section found.' }, + { code: 'DESIGN_NO_TESTING_STRATEGY', pattern: /validation|testing|verification/i, message: 'No Validation Strategy section found.' }, +]; + +export function analyzeDesignStage( + spec: SpecAnalysis, + options: StageAnalysisOptions, +): StageAnalysis { + const document = spec.documents.design; + const diagnostics: Diagnostic[] = []; + const filePath = document?.filePath; + + if (document === undefined || spec.design === undefined) { + diagnostics.push( + diag( + options.missingFileSeverity, + 'DESIGN_MISSING', + 'design.md does not exist yet.', + spec.folder.dir, + ), + ); + return { stage: 'design', fileName: 'design.md', fileExists: false, diagnostics }; + } + + if (isDocumentEmpty(document)) { + diagnostics.push(diag('error', 'DESIGN_EMPTY', 'design.md is empty.', filePath)); + return { + stage: 'design', + fileName: 'design.md', + ...(filePath !== undefined ? { filePath } : {}), + fileExists: true, + diagnostics, + }; + } + + pushPlaceholderDiagnostics(document, 'DESIGN_PLACEHOLDER', options, diagnostics); + + const scan = scanPlaceholders(document); + const isPendingStub = scan.placeholderOnly; + + // Section checks only make sense once the document has real content; + // a generated "pending" stub already reports placeholder findings above. + if (!isPendingStub) { + const checks = spec.classification.type === 'bugfix' ? BUGFIX_DESIGN_CHECKS : FEATURE_DESIGN_CHECKS; + for (const check of checks) { + if (!hasSectionMatching(document, check.pattern)) { + diagnostics.push(diag('warning', check.code, check.message, filePath)); + } + } + + if (options.prerequisitesApproved === false) { + diagnostics.push( + diag( + 'warning', + 'DESIGN_BEFORE_PREREQUISITE_APPROVAL', + 'design.md already has real content, but its prerequisite stage is not approved yet. Approve the earlier stage first so the design has a stable base.', + filePath, + ), + ); + } + } + + return { + stage: 'design', + fileName: 'design.md', + ...(filePath !== undefined ? { filePath } : {}), + fileExists: true, + diagnostics, + }; +} + +// --------------------------------------------------------------------------- +// Tasks +// --------------------------------------------------------------------------- + +const IMPLEMENTATION_TASK = /\b(implement|build|create|add|write|develop|code|refactor|fix|update|remove|migrate|wire|integrate)\b/i; +const TEST_TASK = /\b(test|tests|tested|testing|regression)\b/i; +const VALIDATION_TASK = /\b(verify|validate|validation|confirm|check|run)\b/i; + +function collectRequirementIds(spec: SpecAnalysis): Set<string> | undefined { + const model = spec.requirements; + if (model === undefined || model.requirements.length === 0) return undefined; + const ids = new Set<string>(); + for (const requirement of model.requirements) { + ids.add(requirement.id); + for (const criterion of requirement.criteria) { + ids.add(criterion.id); + } + } + return ids; +} + +function walkTasks(tasks: TaskItem[], visit: (task: TaskItem) => void): void { + for (const task of tasks) { + visit(task); + walkTasks(task.children, visit); + } +} + +export function analyzeTasksStage( + spec: SpecAnalysis, + options: StageAnalysisOptions, +): StageAnalysis { + const document = spec.documents.tasks; + const diagnostics: Diagnostic[] = []; + const filePath = document?.filePath; + + if (document === undefined || spec.tasks === undefined) { + diagnostics.push( + diag( + options.missingFileSeverity, + 'TASKS_MISSING', + 'tasks.md does not exist yet.', + spec.folder.dir, + ), + ); + return { stage: 'tasks', fileName: 'tasks.md', fileExists: false, diagnostics }; + } + + if (isDocumentEmpty(document)) { + diagnostics.push(diag('error', 'TASKS_EMPTY', 'tasks.md is empty.', filePath)); + return { + stage: 'tasks', + fileName: 'tasks.md', + ...(filePath !== undefined ? { filePath } : {}), + fileExists: true, + diagnostics, + }; + } + + const model = spec.tasks; + pushPlaceholderDiagnostics(document, 'TASKS_PLACEHOLDER', options, diagnostics); + + // The tolerant parser already reports malformed checkboxes, duplicate + // numbers, and unknown checkbox states; its findings are part of the + // stage analysis verbatim. + diagnostics.push(...model.diagnostics); + + if (model.allTasks.length === 0) { + diagnostics.push( + diag( + 'error', + 'TASKS_NONE', + 'No Markdown checkbox tasks recognized (expected "- [ ] 1. Task title" lines).', + filePath, + ), + ); + return { + stage: 'tasks', + fileName: 'tasks.md', + ...(filePath !== undefined ? { filePath } : {}), + fileExists: true, + diagnostics, + }; + } + + let anyImplementation = false; + let anyTest = false; + let anyValidation = false; + let completedCount = 0; + + walkTasks(model.tasks, (task) => { + if (IMPLEMENTATION_TASK.test(task.title)) anyImplementation = true; + if (TEST_TASK.test(task.title)) anyTest = true; + if (VALIDATION_TASK.test(task.title)) anyValidation = true; + if (task.state === 'done') completedCount += 1; + + const vagueVerb = taskStartsWithVagueVerb(task.title); + if (vagueVerb !== undefined) { + diagnostics.push( + diag( + 'warning', + 'TASKS_VAGUE_TASK', + `Task ${task.id} starts with the vague verb "${vagueVerb}"; describe a concrete, verifiable action.`, + filePath, + task.line + 1, + ), + ); + } + + if (task.state === 'done' && task.children.some((c) => !c.optional && c.state !== 'done')) { + diagnostics.push( + diag( + 'warning', + 'TASKS_PARENT_COMPLETE_CHILD_OPEN', + `Task ${task.id} is marked complete but has incomplete required sub-tasks.`, + filePath, + task.line + 1, + ), + ); + } + }); + + if (!anyImplementation) { + diagnostics.push( + diag('warning', 'TASKS_NO_IMPLEMENTATION', 'No implementation task found.', filePath), + ); + } + if (!anyTest) { + diagnostics.push( + diag('warning', 'TASKS_NO_TEST_TASK', 'No test task found; add automated tests for the acceptance criteria.', filePath), + ); + } + if (!anyValidation) { + diagnostics.push( + diag('warning', 'TASKS_NO_VALIDATION_TASK', 'No verification/validation task found.', filePath), + ); + } + + const knownIds = collectRequirementIds(spec); + if (knownIds !== undefined) { + walkTasks(model.tasks, (task) => { + for (const ref of task.requirementRefs) { + if (!knownIds.has(ref)) { + diagnostics.push( + diag( + 'warning', + 'TASKS_UNKNOWN_REQUIREMENT_REF', + `Task ${task.id} references requirement "${ref}", which does not exist in requirements.md.`, + filePath, + task.line + 1, + ), + ); + } + } + }); + } + + if ( + completedCount > 0 && + options.stageStatus !== undefined && + options.stageStatus !== 'approved' + ) { + diagnostics.push( + diag( + 'warning', + 'TASKS_COMPLETED_BEFORE_APPROVAL', + `${completedCount} task${completedCount === 1 ? ' is' : 's are'} already marked complete, but the task plan is not approved yet.`, + filePath, + ), + ); + } + + return { + stage: 'tasks', + fileName: 'tasks.md', + ...(filePath !== undefined ? { filePath } : {}), + fileExists: true, + diagnostics, + }; +} + +// --------------------------------------------------------------------------- +// Orchestration +// --------------------------------------------------------------------------- + +export function analyzeSpecStage( + spec: SpecAnalysis, + stage: StageName, + options: StageAnalysisOptions, +): StageAnalysis { + switch (stage) { + case 'requirements': + return analyzeRequirementsStage(spec, options); + case 'bugfix': + return analyzeBugfixStage(spec, options); + case 'design': + return analyzeDesignStage(spec, options); + case 'tasks': + return analyzeTasksStage(spec, options); + } +} + +export function combineStageAnalyses( + specName: string, + stages: StageAnalysis[], +): SpecAnalysisResult { + const diagnostics = stages.flatMap((stage) => stage.diagnostics); + return { + specName, + stages, + diagnostics, + errorCount: diagnostics.filter((d) => d.severity === 'error').length, + warningCount: diagnostics.filter((d) => d.severity === 'warning').length, + hasErrors: hasErrors(diagnostics), + }; +} diff --git a/packages/workflow/src/approval.ts b/packages/workflow/src/approval.ts new file mode 100644 index 0000000..47967d0 --- /dev/null +++ b/packages/workflow/src/approval.ts @@ -0,0 +1,455 @@ +import type { + ConcreteSpecType, + ConcreteWorkflowMode, + Diagnostic, + SpecWorkflowState, + StageApproval, + StageName, + WorkspaceInfo, +} from '@specbridge/core'; +import { + SPEC_STATE_SCHEMA_VERSION, + readSpecState, + sha256File, + stateStage, + writeSpecState, +} from '@specbridge/core'; +import type { SpecAnalysis } from '@specbridge/compat-kiro'; +import type { Clock } from './clock.js'; +import { isoNow, systemClock } from './clock.js'; +import type { SpecAnalysisResult } from './analyzers.js'; +import { analyzeSpecStage, combineStageAnalyses } from './analyzers.js'; +import type { WorkflowEvaluation } from './health.js'; +import { evaluateWorkflow, resolveStageFile } from './health.js'; +import type { WorkflowShape } from './state-machine.js'; +import { + applicableStages, + dependentStages, + deriveWorkflowStatus, + documentStageFor, + initialStages, + isStageApplicable, + recomputeStages, + stagePrerequisites, + workflowShape, +} from './state-machine.js'; + +/** + * Stage approval and revocation. + * + * Approval is the only door to sidecar state changes: + * 1. the stage must exist for the spec type, + * 2. every prerequisite stage must be approved and still match its approved + * file bytes, + * 3. deterministic analysis of the stage must produce no errors, + * 4. then the exact file bytes are hashed and recorded. + * + * The approved Markdown file itself is never rewritten. + */ + +export interface ApprovalRequest { + stage: StageName; + revoke?: boolean; +} + +export interface ApprovalOptions { + clock?: Clock; +} + +export type ApprovalResult = + | { + ok: true; + action: 'approved'; + stage: StageName; + state: SpecWorkflowState; + statePath: string; + hash: string; + /** True when this replaced an earlier approval of the same stage. */ + reapproved: boolean; + /** Later-stage approvals that were invalidated by this (re)approval. */ + invalidated: StageName[]; + /** True when sidecar state was created by this command. */ + initialized: boolean; + analysis: SpecAnalysisResult; + diagnostics: Diagnostic[]; + } + | { + ok: true; + action: 'revoked'; + stage: StageName; + state: SpecWorkflowState; + statePath: string; + /** Later-stage approvals invalidated together with this one. */ + invalidated: StageName[]; + diagnostics: Diagnostic[]; + } + | { + ok: false; + /** `usage` failures exit 2; `gate` failures exit 1. */ + failure: 'usage' | 'gate'; + reason: + | 'stage-not-applicable' + | 'nothing-to-revoke' + | 'initialization-unsupported' + | 'prerequisites-unmet' + | 'analysis-errors'; + message: string; + /** Prerequisites that are not approved at all. */ + missingPrerequisites?: StageName[]; + /** Prerequisites whose approval is stale (file changed after approval). */ + stalePrerequisites?: StageName[]; + analysis?: SpecAnalysisResult; + diagnostics: Diagnostic[]; + }; + +/** Deterministic workflow-mode inference for an unmanaged spec's first approval. */ +export function inferWorkflowForFirstApproval( + specType: ConcreteSpecType, + firstStage: StageName, +): + | { ok: true; mode: ConcreteWorkflowMode; explanation: string } + | { ok: false; message: string } { + const documentStage = documentStageFor(specType); + if (firstStage === documentStage) { + return { + ok: true, + mode: 'requirements-first', + explanation: `Initialized as ${specType === 'bugfix' ? 'a bugfix workflow' : 'requirements-first'} because ${documentStage} is being approved first and no contrary evidence exists.`, + }; + } + if (firstStage === 'design') { + return { + ok: true, + mode: 'design-first', + explanation: 'Initialized as design-first because design is being approved first.', + }; + } + return { + ok: false, + message: + `Cannot start managing this spec by approving "${firstStage}": tasks always require earlier stage approvals. ` + + `Approve "${documentStage}" or "design" first.`, + }; +} + +function buildInitialState( + specName: string, + specType: ConcreteSpecType, + mode: ConcreteWorkflowMode, + origin: SpecWorkflowState['origin'], + clock: Clock, +): SpecWorkflowState { + const shape = workflowShape(specType, mode); + const stages = initialStages(shape, specName); + const now = isoNow(clock); + return { + schemaVersion: SPEC_STATE_SCHEMA_VERSION, + specName, + specType, + workflowMode: mode, + origin, + status: deriveWorkflowStatus(shape, stages), + createdAt: now, + updatedAt: now, + stages: stages as SpecWorkflowState['stages'], + }; +} + +/** Fresh sidecar state for a spec created by `spec new`. */ +export function newSpecState( + specName: string, + specType: ConcreteSpecType, + mode: ConcreteWorkflowMode, + clock: Clock = systemClock, +): SpecWorkflowState { + return buildInitialState(specName, specType, mode, 'created-by-specbridge', clock); +} + +function stageList(stages: StageName[]): string { + return stages.join(', '); +} + +function cloneStages(state: SpecWorkflowState): Record<string, StageApproval> { + const stages: Record<string, StageApproval> = {}; + for (const [name, value] of Object.entries(state.stages)) { + if (value !== undefined && typeof value === 'object') { + stages[name] = { ...(value as StageApproval) }; + } + } + return stages; +} + +function withStages( + state: SpecWorkflowState, + shape: WorkflowShape, + stages: Record<string, StageApproval>, + clock: Clock, +): SpecWorkflowState { + const ordered: Record<string, StageApproval> = {}; + for (const stage of shape.order) { + const value = stages[stage]; + if (value !== undefined) ordered[stage] = value; + } + return { + ...state, + stages: ordered as SpecWorkflowState['stages'], + status: deriveWorkflowStatus(shape, ordered), + updatedAt: isoNow(clock), + }; +} + +/** + * Approve or revoke one stage of a spec. + * + * `spec` must be the current analysis of an existing spec folder. The caller + * is responsible for having resolved the spec (unknown specs are a usage + * error before this point). + */ +export function approveStage( + workspace: WorkspaceInfo, + spec: SpecAnalysis, + request: ApprovalRequest, + options: ApprovalOptions = {}, +): ApprovalResult { + const clock = options.clock ?? systemClock; + const specName = spec.folder.name; + const diagnostics: Diagnostic[] = []; + + // Load state; invalid state degrades to "unmanaged" with a diagnostic. + const stateRead = readSpecState(workspace, specName); + diagnostics.push(...stateRead.diagnostics); + let state = stateRead.state; + let initialized = false; + + if (state === undefined) { + if (request.revoke === true) { + return { + ok: false, + failure: 'usage', + reason: 'nothing-to-revoke', + message: + stateRead.exists + ? `Cannot revoke: the sidecar state for "${specName}" is invalid. Approving a stage will rebuild it.` + : `Cannot revoke: "${specName}" has no sidecar state (approval state: unmanaged). There is nothing to revoke.`, + diagnostics, + }; + } + + // First approval of an existing Kiro spec: initialize sidecar state. + const specType: ConcreteSpecType = spec.classification.type === 'bugfix' ? 'bugfix' : 'feature'; + if (!isStageApplicable(specType, request.stage)) { + return { + ok: false, + failure: 'usage', + reason: 'stage-not-applicable', + message: `Stage "${request.stage}" does not apply to a ${specType} spec. Applicable stages: ${stageList(applicableStages(specType))}.`, + diagnostics, + }; + } + const inference = inferWorkflowForFirstApproval(specType, request.stage); + if (!inference.ok) { + return { + ok: false, + failure: 'gate', + reason: 'initialization-unsupported', + message: inference.message, + diagnostics, + }; + } + state = buildInitialState(specName, specType, inference.mode, 'existing-kiro-workspace', clock); + initialized = true; + diagnostics.push({ + severity: 'info', + code: 'STATE_INITIALIZED', + message: inference.explanation, + }); + } + + const shape = workflowShape(state.specType, state.workflowMode); + + if (!isStageApplicable(state.specType, request.stage)) { + return { + ok: false, + failure: 'usage', + reason: 'stage-not-applicable', + message: `Stage "${request.stage}" does not apply to a ${state.specType} spec. Applicable stages: ${stageList(applicableStages(state.specType))}.`, + diagnostics, + }; + } + + if (request.revoke === true) { + return revoke(workspace, state, shape, request.stage, clock, diagnostics); + } + + // Prerequisites must be approved and still match their approved bytes. + const evaluation: WorkflowEvaluation = evaluateWorkflow(workspace, state); + const prerequisites = stagePrerequisites(shape, request.stage); + const missingPrerequisites: StageName[] = []; + const stalePrerequisites: StageName[] = []; + for (const prerequisite of prerequisites) { + const stageEvaluation = evaluation.stages.find((s) => s.stage === prerequisite); + if (stageEvaluation === undefined) continue; + if (stageEvaluation.stored.status !== 'approved') { + missingPrerequisites.push(prerequisite); + } else if (stageEvaluation.effective !== 'approved') { + stalePrerequisites.push(prerequisite); + } + } + if (missingPrerequisites.length > 0 || stalePrerequisites.length > 0) { + const parts: string[] = []; + if (missingPrerequisites.length > 0) { + parts.push(`${stageList(missingPrerequisites)} ${missingPrerequisites.length === 1 ? 'is' : 'are'} not approved yet`); + } + if (stalePrerequisites.length > 0) { + parts.push(`${stageList(stalePrerequisites)} changed after approval and must be re-approved`); + } + return { + ok: false, + failure: 'gate', + reason: 'prerequisites-unmet', + message: `Cannot approve ${request.stage} for "${specName}": ${parts.join('; ')}.`, + missingPrerequisites, + stalePrerequisites, + diagnostics, + }; + } + + // Deterministic stage analysis gates the approval: errors block, warnings pass. + const stored = stateStage(state, request.stage); + const analysis = combineStageAnalyses(specName, [ + analyzeSpecStage(spec, request.stage, { + placeholderSeverity: 'error', + missingFileSeverity: 'error', + ...(stored !== undefined ? { stageStatus: stored.status } : {}), + prerequisitesApproved: true, + }), + ]); + if (analysis.hasErrors) { + return { + ok: false, + failure: 'gate', + reason: 'analysis-errors', + message: `Cannot approve ${request.stage} for "${specName}": analysis found ${analysis.errorCount} error${analysis.errorCount === 1 ? '' : 's'}. Fix them and re-run the approval.`, + analysis, + diagnostics, + }; + } + + // Hash the exact file bytes of the stage document. + const stages = cloneStages(state); + const target = stages[request.stage]; + if (target === undefined) { + return { + ok: false, + failure: 'usage', + reason: 'stage-not-applicable', + message: `Sidecar state for "${specName}" has no "${request.stage}" stage entry.`, + diagnostics, + }; + } + const filePath = resolveStageFile(workspace, target); + const hash = sha256File(filePath); + + const reapproved = target.status === 'approved'; + const hashChanged = reapproved && target.approvedHash !== hash; + + // A reapproval after content changes means every dependent approval was + // made against different content — invalidate them (files are untouched). + const invalidated: StageName[] = []; + if (!reapproved || hashChanged) { + for (const dependent of dependentStages(shape, request.stage)) { + const dependentStage = stages[dependent]; + if (dependentStage !== undefined && dependentStage.status === 'approved') { + stages[dependent] = { + ...dependentStage, + status: 'draft', + approvedAt: null, + approvedHash: null, + }; + invalidated.push(dependent); + } + } + } + + stages[request.stage] = { + ...target, + status: 'approved', + approvedAt: isoNow(clock), + approvedHash: hash, + }; + + const recomputed = recomputeStages(shape, { + ...state, + stages: stages as SpecWorkflowState['stages'], + }); + const nextState = withStages(state, shape, recomputed, clock); + const statePath = writeSpecState(workspace, nextState); + + return { + ok: true, + action: 'approved', + stage: request.stage, + state: nextState, + statePath, + hash, + reapproved, + invalidated, + initialized, + analysis, + diagnostics, + }; +} + +function revoke( + workspace: WorkspaceInfo, + state: SpecWorkflowState, + shape: WorkflowShape, + stage: StageName, + clock: Clock, + diagnostics: Diagnostic[], +): ApprovalResult { + const stages = cloneStages(state); + const target = stages[stage]; + if (target === undefined || target.status !== 'approved') { + return { + ok: false, + failure: 'usage', + reason: 'nothing-to-revoke', + message: `Cannot revoke ${stage} for "${state.specName}": it is not approved (current status: ${target?.status ?? 'missing'}).`, + diagnostics, + }; + } + + const invalidated: StageName[] = []; + for (const dependent of dependentStages(shape, stage)) { + const dependentStage = stages[dependent]; + if (dependentStage !== undefined && dependentStage.status === 'approved') { + stages[dependent] = { + ...dependentStage, + status: 'draft', + approvedAt: null, + approvedHash: null, + }; + invalidated.push(dependent); + } + } + + stages[stage] = { ...target, status: 'draft', approvedAt: null, approvedHash: null }; + + const recomputed = recomputeStages(shape, { + ...state, + stages: stages as SpecWorkflowState['stages'], + }); + const nextState = withStages(state, shape, recomputed, clock); + const statePath = writeSpecState(workspace, nextState); + + return { + ok: true, + action: 'revoked', + stage, + state: nextState, + statePath, + invalidated, + diagnostics, + }; +} diff --git a/packages/workflow/src/clock.ts b/packages/workflow/src/clock.ts new file mode 100644 index 0000000..c792a76 --- /dev/null +++ b/packages/workflow/src/clock.ts @@ -0,0 +1,11 @@ +/** + * Injectable clock. Everything that stamps a timestamp accepts a `Clock` + * so tests (and dry runs) can be fully deterministic. + */ +export type Clock = () => Date; + +export const systemClock: Clock = () => new Date(); + +export function isoNow(clock: Clock): string { + return clock().toISOString(); +} diff --git a/packages/workflow/src/create-spec.ts b/packages/workflow/src/create-spec.ts new file mode 100644 index 0000000..646f099 --- /dev/null +++ b/packages/workflow/src/create-spec.ts @@ -0,0 +1,302 @@ +import { + existsSync, + mkdirSync, + readFileSync, + readdirSync, + renameSync, + rmSync, + rmdirSync, + statSync, +} from 'node:fs'; +import path from 'node:path'; +import type { + ConcreteSpecType, + ConcreteWorkflowMode, + SpecWorkflowState, + WorkspaceInfo, +} from '@specbridge/core'; +import { + KIRO_DIR_NAME, + KIRO_SPECS_DIR, + SpecBridgeError, + assertInsideWorkspace, + ioError, + specStatePath, + writeFileAtomic, + writeSpecState, +} from '@specbridge/core'; +import type { Clock } from './clock.js'; +import { systemClock } from './clock.js'; +import { newSpecState } from './approval.js'; +import { validateSpecName, titleFromSpecName } from './spec-name.js'; +import type { RenderedSpecFile } from './templates.js'; +import { + DEFAULT_BUGFIX_DESCRIPTION, + DEFAULT_FEATURE_DESCRIPTION, + renderSpecTemplates, +} from './templates.js'; + +/** + * Spec creation. + * + * Creation is planned first (pure, no writes — this powers `--dry-run`), + * then executed atomically: files are written into a temp directory under + * `.specbridge/tmp/` and renamed into `.kiro/specs/<name>` in one step. + * A failure at any point leaves no partial spec directory behind. + */ + +/** Description files larger than this are rejected (configurable per call). */ +export const DEFAULT_MAX_DESCRIPTION_BYTES = 1024 * 1024; + +export interface SpecCreationRequest { + name: string; + specType?: ConcreteSpecType; + mode?: ConcreteWorkflowMode; + title?: string; + description?: string; + /** Path to a UTF-8 Markdown/text file with the description. */ + fromFile?: string; + /** Base directory for resolving a relative `fromFile` (defaults to the workspace root). */ + cwd?: string; + maxDescriptionBytes?: number; +} + +export interface SpecCreationPlan { + specName: string; + specType: ConcreteSpecType; + mode: ConcreteWorkflowMode; + title: string; + description: string; + /** True when the description is the generated placeholder text. */ + descriptionIsPlaceholder: boolean; + /** Absolute target directory: `<root>/.kiro/specs/<name>`. */ + dir: string; + files: RenderedSpecFile[]; + state: SpecWorkflowState; + statePath: string; +} + +export interface SpecCreationResult { + plan: SpecCreationPlan; + /** Absolute paths of the files that were written. */ + writtenFiles: string[]; + statePath: string; +} + +function readDescriptionFile( + workspace: WorkspaceInfo, + fromFile: string, + cwd: string, + maxBytes: number, +): string { + const resolved = path.resolve(cwd, fromFile); + // Description files must live inside the workspace: the CLI never reads + // arbitrary machine paths on behalf of a spec. + assertInsideWorkspace(workspace.rootDir, resolved); + + let stats; + try { + stats = statSync(resolved); + } catch (cause) { + throw ioError('read description file', resolved, cause); + } + if (stats.isDirectory()) { + throw new SpecBridgeError( + 'INVALID_ARGUMENT', + `--from-file points at a directory: ${resolved}. Point it at a UTF-8 text file.`, + ); + } + if (stats.size > maxBytes) { + throw new SpecBridgeError( + 'INVALID_ARGUMENT', + `--from-file is too large (${stats.size} bytes; limit ${maxBytes}). ` + + 'Spec descriptions should be a short problem statement, not a document dump.', + ); + } + + let buffer: Buffer; + try { + buffer = readFileSync(resolved); + } catch (cause) { + throw ioError('read description file', resolved, cause); + } + const text = buffer.toString('utf8'); + if (!Buffer.from(text, 'utf8').equals(buffer)) { + throw new SpecBridgeError( + 'INVALID_ARGUMENT', + `--from-file is not valid UTF-8: ${resolved}. Re-save the file as UTF-8 and retry.`, + ); + } + const description = text.replace(new RegExp('^\\uFEFF'), '').trim(); + if (description.length === 0) { + throw new SpecBridgeError('INVALID_ARGUMENT', `--from-file is empty: ${resolved}.`); + } + return description; +} + +/** + * Validate a creation request and produce the full plan without writing + * anything. Throws `SpecBridgeError` with actionable messages on invalid + * input or an already-existing spec. + */ +export function planSpecCreation( + workspace: WorkspaceInfo, + request: SpecCreationRequest, + clock: Clock = systemClock, +): SpecCreationPlan { + const nameCheck = validateSpecName(request.name); + if (!nameCheck.valid) { + throw new SpecBridgeError( + 'INVALID_ARGUMENT', + `Invalid spec name "${request.name}":\n${nameCheck.problems.map((p) => ` - ${p}`).join('\n')}\n` + + 'Valid examples: notification-preferences, auth-v2, payment-retry.', + ); + } + + const specType = request.specType ?? 'feature'; + const mode = request.mode ?? 'requirements-first'; + + if (request.description !== undefined && request.fromFile !== undefined) { + throw new SpecBridgeError( + 'INVALID_ARGUMENT', + 'Use either --description or --from-file, not both.', + ); + } + + let description = request.description?.trim(); + if (description !== undefined && description.length === 0) { + throw new SpecBridgeError('INVALID_ARGUMENT', '--description must not be empty.'); + } + if (request.fromFile !== undefined) { + description = readDescriptionFile( + workspace, + request.fromFile, + request.cwd ?? workspace.rootDir, + request.maxDescriptionBytes ?? DEFAULT_MAX_DESCRIPTION_BYTES, + ); + } + const descriptionIsPlaceholder = description === undefined; + if (description === undefined) { + description = specType === 'bugfix' ? DEFAULT_BUGFIX_DESCRIPTION : DEFAULT_FEATURE_DESCRIPTION; + } + + const requestedTitle = request.title?.trim(); + const title = + requestedTitle !== undefined && requestedTitle.length > 0 + ? requestedTitle + : titleFromSpecName(request.name); + + const dir = assertInsideWorkspace( + workspace.rootDir, + path.join(workspace.rootDir, KIRO_DIR_NAME, KIRO_SPECS_DIR, request.name), + ); + + if (existsSync(dir)) { + let entries: string[] = []; + try { + entries = readdirSync(dir).sort((a, b) => a.localeCompare(b, 'en')); + } catch { + // A file (not a directory) with the spec name also blocks creation. + } + throw new SpecBridgeError( + 'SPEC_ALREADY_EXISTS', + `Spec "${request.name}" already exists at ${dir}.\n` + + (entries.length > 0 ? `Existing files: ${entries.join(', ')}.\n` : '') + + `SpecBridge never overwrites an existing spec. Inspect it with "spec show ${request.name}", ` + + 'or choose a different name.', + ); + } + + const files = renderSpecTemplates(specType, mode, { title, description }); + const state = newSpecState(request.name, specType, mode, clock); + + return { + specName: request.name, + specType, + mode, + title, + description, + descriptionIsPlaceholder, + dir, + files, + state, + statePath: specStatePath(workspace, request.name), + }; +} + +/** + * Execute a creation plan atomically: + * 1. render every file into `.specbridge/tmp/<unique>/`, + * 2. rename that directory to `.kiro/specs/<name>` (one atomic step), + * 3. write sidecar state. + * + * If the state write fails, the freshly renamed spec directory (which cannot + * contain user edits yet) is removed again — all or nothing. + */ +export function executeSpecCreation( + workspace: WorkspaceInfo, + plan: SpecCreationPlan, +): SpecCreationResult { + const tmpParent = path.join(workspace.sidecarDir, 'tmp'); + const tempDir = path.join( + tmpParent, + `spec-new-${plan.specName}-${process.pid}-${Math.random().toString(36).slice(2, 8)}`, + ); + + const specsDir = path.dirname(plan.dir); + const writtenFiles: string[] = []; + + try { + mkdirSync(tempDir, { recursive: true }); + for (const file of plan.files) { + writeFileAtomic(path.join(tempDir, file.fileName), file.content); + } + + mkdirSync(specsDir, { recursive: true }); + // Re-check just before the rename: another process may have created the + // spec since planning. rename onto an existing directory fails anyway, + // but this produces the friendlier error. + if (existsSync(plan.dir)) { + throw new SpecBridgeError( + 'SPEC_ALREADY_EXISTS', + `Spec "${plan.specName}" already exists at ${plan.dir}; nothing was written.`, + ); + } + try { + renameSync(tempDir, plan.dir); + } catch (cause) { + throw ioError('create spec directory', plan.dir, cause); + } + for (const file of plan.files) { + writtenFiles.push(path.join(plan.dir, file.fileName)); + } + + let statePath: string; + try { + statePath = writeSpecState(workspace, plan.state); + } catch (cause) { + // The spec directory was created by this very call and holds only + // rendered templates; remove it so the workspace is unchanged. + rmSync(plan.dir, { recursive: true, force: true }); + throw cause; + } + + return { plan, writtenFiles, statePath }; + } finally { + rmSync(tempDir, { recursive: true, force: true }); + try { + rmdirSync(tmpParent); + } catch { + // Not empty or already gone — either is fine. + } + } +} + +/** Plan and execute in one step (the non-dry-run `spec new` path). */ +export function createSpec( + workspace: WorkspaceInfo, + request: SpecCreationRequest, + clock: Clock = systemClock, +): SpecCreationResult { + return executeSpecCreation(workspace, planSpecCreation(workspace, request, clock)); +} diff --git a/packages/workflow/src/ears.ts b/packages/workflow/src/ears.ts new file mode 100644 index 0000000..6ff70c8 --- /dev/null +++ b/packages/workflow/src/ears.ts @@ -0,0 +1,95 @@ +/** + * EARS-style acceptance-criterion classification. + * + * Recognized shapes (case-insensitive): + * + * WHEN <condition or event>, THE SYSTEM SHALL <behavior>. + * IF <condition>, THEN THE SYSTEM SHALL <behavior>. + * WHILE <state>, THE SYSTEM SHALL <behavior>. + * WHERE <feature>, THE SYSTEM SHALL <behavior>. + * THE SYSTEM SHALL <behavior>. (ubiquitous requirement) + * + * EARS is encouraged, not required: a criterion that uses none of the + * keywords is `plain` (fine on its own); only a criterion that *starts* an + * EARS pattern without finishing it is `malformed`. + */ + +export type EarsClassification = 'ears' | 'ears-malformed' | 'plain'; + +const EARS_TRIGGER = /^(when|if|while|where)\b/i; +const SHALL = /\bshall\b/i; +/** Modal verbs that make a plain criterion read as a testable statement. */ +const TESTABLE_MODAL = /\b(shall|must|should|will)\b/i; + +export function classifyEars(text: string): EarsClassification { + const trimmed = text.trim(); + if (EARS_TRIGGER.test(trimmed)) { + return SHALL.test(trimmed) ? 'ears' : 'ears-malformed'; + } + if (SHALL.test(trimmed)) return 'ears'; + return 'plain'; +} + +/** True when the criterion contains a modal verb that marks expected behavior. */ +export function looksTestable(text: string): boolean { + return TESTABLE_MODAL.test(text); +} + +/** + * Vague phrasing that hides untestable requirements. Matching is + * word-boundary based and case-insensitive; multiword phrases first. + */ +const VAGUE_PHRASES: readonly string[] = [ + 'work correctly', + 'works correctly', + 'work properly', + 'works properly', + 'work as expected', + 'works as expected', + 'as appropriate', + 'as needed', + 'as necessary', + 'if appropriate', + 'user-friendly', + 'user friendly', + 'and so on', + 'etc', + 'handle', + 'handles', + 'support', + 'supports', + 'properly', + 'appropriately', + 'gracefully', + 'seamlessly', + 'efficiently', + 'robustly', + 'intuitively', + 'intuitive', +]; + +const VAGUE_PATTERN = new RegExp( + `\\b(?:${VAGUE_PHRASES.map((phrase) => phrase.replace(/[-\s]+/g, '[-\\s]+')).join('|')})\\b`, + 'gi', +); + +/** Distinct vague phrases found in the text (lowercased), in order. */ +export function findVaguePhrases(text: string): string[] { + const found: string[] = []; + VAGUE_PATTERN.lastIndex = 0; + for (let match = VAGUE_PATTERN.exec(text); match !== null; match = VAGUE_PATTERN.exec(text)) { + const phrase = match[0].toLowerCase().replace(/\s+/g, ' '); + if (!found.includes(phrase)) found.push(phrase); + } + return found; +} + +/** + * Vague verbs that make a *task* unactionable when used as the leading verb. + */ +const VAGUE_TASK_VERBS = new Set(['support', 'handle', 'manage', 'improve', 'address', 'deal', 'ensure']); + +export function taskStartsWithVagueVerb(title: string): string | undefined { + const first = title.trim().split(/\s+/)[0]?.toLowerCase().replace(/[^a-z]/g, ''); + return first !== undefined && VAGUE_TASK_VERBS.has(first) ? first : undefined; +} diff --git a/packages/workflow/src/health.ts b/packages/workflow/src/health.ts new file mode 100644 index 0000000..518290b --- /dev/null +++ b/packages/workflow/src/health.ts @@ -0,0 +1,180 @@ +import path from 'node:path'; +import type { + Diagnostic, + SpecWorkflowState, + StageApproval, + StageName, + WorkflowStatus, + WorkspaceInfo, +} from '@specbridge/core'; +import { stateStage, trySha256File } from '@specbridge/core'; +import type { WorkflowShape } from './state-machine.js'; +import { + dependentStages, + deriveWorkflowStatus, + stagePrerequisites, + workflowShape, +} from './state-machine.js'; + +/** + * Read-time evaluation of recorded approvals against the current file bytes. + * + * Everything here is computed in memory: read-only commands report stale + * approvals but never rewrite sidecar state. The only way to change state is + * an explicit `spec approve` (or `--revoke`). + */ + +export type EffectiveStageStatus = + | 'blocked' + | 'draft' + | 'approved' + /** The stage was approved, but its file bytes changed afterwards. */ + | 'modified-after-approval' + /** The stage's own approval is intact, but a prerequisite approval went stale. */ + | 'stale-prerequisite'; + +export interface StageEvaluation { + stage: StageName; + stored: StageApproval; + effective: EffectiveStageStatus; + /** Absolute path of the stage file. */ + filePath: string; + fileExists: boolean; + /** Present for approved stages (undefined when the file is unreadable). */ + currentHash?: string; + prerequisites: StageName[]; +} + +export interface WorkflowEvaluation { + state: SpecWorkflowState; + shape: WorkflowShape; + stages: StageEvaluation[]; + /** Status derived from recorded approvals alone. */ + storedStatus: WorkflowStatus; + /** `STALE_APPROVAL` when any recorded approval no longer holds. */ + effectiveStatus: WorkflowStatus | 'STALE_APPROVAL'; + /** Stages whose approved file bytes changed. */ + staleStages: StageName[]; + /** Approved stages invalidated because a prerequisite went stale. */ + invalidatedStages: StageName[]; + health: 'ok' | 'stale'; + diagnostics: Diagnostic[]; +} + +function shortHash(hash: string | null | undefined): string { + return hash === null || hash === undefined ? '(none)' : `${hash.slice(0, 12)}…`; +} + +/** Resolve a stored workspace-relative stage file path, refusing traversal. */ +export function resolveStageFile(workspace: WorkspaceInfo, stage: StageApproval): string { + // Stored paths use forward slashes; normalize for the current platform. + const relative = stage.file.split('/').join(path.sep); + const resolved = path.resolve(workspace.rootDir, relative); + const check = path.relative(workspace.rootDir, resolved); + if (check.startsWith('..') || path.isAbsolute(check)) { + // A hand-edited state file must never make SpecBridge read outside the + // workspace. Point at a path that cannot exist instead of throwing so + // read-only commands stay non-fatal; the hash simply reports as missing. + return path.join(workspace.rootDir, '.specbridge', 'invalid-path', path.basename(stage.file)); + } + return resolved; +} + +export function evaluateWorkflow( + workspace: WorkspaceInfo, + state: SpecWorkflowState, +): WorkflowEvaluation { + const shape = workflowShape(state.specType, state.workflowMode); + const diagnostics: Diagnostic[] = []; + const staleStages: StageName[] = []; + + const evaluations = new Map<StageName, StageEvaluation>(); + for (const stage of shape.order) { + const stored = stateStage(state, stage); + if (stored === undefined) continue; // schema guarantees presence; be safe anyway + const filePath = resolveStageFile(workspace, stored); + const currentHash = trySha256File(filePath); + const fileExists = currentHash !== undefined; + + let effective: EffectiveStageStatus; + if (stored.status === 'approved') { + if (currentHash !== undefined && currentHash === stored.approvedHash) { + effective = 'approved'; + } else { + effective = 'modified-after-approval'; + staleStages.push(stage); + diagnostics.push({ + severity: 'warning', + code: 'APPROVAL_STALE', + message: + currentHash === undefined + ? `${stage} was approved but its file is missing or unreadable (approved hash ${shortHash(stored.approvedHash)}).` + : `${stage} was modified after approval (approved ${shortHash(stored.approvedHash)}, current ${shortHash(currentHash)}). Review the changes and re-approve.`, + file: filePath, + }); + } + } else { + effective = stored.status; + } + + evaluations.set(stage, { + stage, + stored, + effective, + filePath, + fileExists, + ...(stored.status === 'approved' && currentHash !== undefined ? { currentHash } : {}), + prerequisites: stagePrerequisites(shape, stage), + }); + } + + // Propagate staleness: an approved stage whose prerequisite went stale is + // itself no longer trustworthy; a draft stage behind a stale prerequisite + // is effectively blocked. + const invalidatedStages: StageName[] = []; + for (const stale of staleStages) { + for (const dependent of dependentStages(shape, stale)) { + const evaluation = evaluations.get(dependent); + if (evaluation === undefined) continue; + if (evaluation.effective === 'approved') { + evaluation.effective = 'stale-prerequisite'; + invalidatedStages.push(dependent); + diagnostics.push({ + severity: 'warning', + code: 'APPROVAL_DEPENDENT_STALE', + message: `${dependent} approval is now stale because ${stale} changed after it was approved.`, + file: evaluation.filePath, + }); + } else if (evaluation.effective === 'draft') { + evaluation.effective = 'blocked'; + } + } + } + + const stagesRecord: Record<string, StageApproval> = {}; + for (const [name, evaluation] of evaluations) stagesRecord[name] = evaluation.stored; + const storedStatus = deriveWorkflowStatus(shape, stagesRecord); + const hasStale = staleStages.length > 0 || invalidatedStages.length > 0; + + return { + state, + shape, + stages: shape.order + .map((stage) => evaluations.get(stage)) + .filter((s): s is StageEvaluation => s !== undefined), + storedStatus, + effectiveStatus: hasStale ? 'STALE_APPROVAL' : storedStatus, + staleStages, + invalidatedStages, + health: hasStale ? 'stale' : 'ok', + diagnostics, + }; +} + +/** True when the stage's recorded approval still holds against current bytes. */ +export function isEffectivelyApproved( + evaluation: WorkflowEvaluation, + stage: StageName, +): boolean { + return evaluation.stages.find((s) => s.stage === stage)?.effective === 'approved'; +} diff --git a/packages/workflow/src/index.ts b/packages/workflow/src/index.ts new file mode 100644 index 0000000..e144c9d --- /dev/null +++ b/packages/workflow/src/index.ts @@ -0,0 +1,12 @@ +export * from './clock.js'; +export * from './spec-name.js'; +export * from './templates.js'; +export * from './placeholders.js'; +export * from './ears.js'; +export * from './state-machine.js'; +export * from './health.js'; +export * from './analyzers.js'; +export * from './spec-analysis.js'; +export * from './approval.js'; +export * from './create-spec.js'; +export * from './sidecar-audit.js'; diff --git a/packages/workflow/src/placeholders.ts b/packages/workflow/src/placeholders.ts new file mode 100644 index 0000000..7c795f7 --- /dev/null +++ b/packages/workflow/src/placeholders.ts @@ -0,0 +1,123 @@ +import type { MarkdownDocument } from '@specbridge/compat-kiro'; +import { TEMPLATE_PLACEHOLDER_LINES } from './templates.js'; + +/** + * Deterministic placeholder detection. + * + * Generated templates intentionally contain recognizable placeholders; a + * stage must not be approvable while they remain. Detection is precise on + * purpose — a false positive would block approval of a legitimate document: + * + * 1. Angle-bracket tokens such as `<role>` or `<expected behavior>` + * (HTML tag names and autolinks are excluded). + * 2. `TBD` / `TODO` markers. + * 3. Instruction lines that start with a fill-me-in verb and end in + * "here", e.g. "Add edge cases here." or "Describe the correct behavior here.". + * 4. Exact pending-stage lines from the generated templates. + * + * Content inside fenced code blocks is never scanned. + */ + +export interface PlaceholderHit { + /** 0-based line index. */ + line: number; + /** The placeholder token or the trimmed matching line. */ + text: string; +} + +export interface PlaceholderScan { + hits: PlaceholderHit[]; + /** True when the document has body content and all of it is placeholders. */ + placeholderOnly: boolean; + /** Number of body lines considered (non-blank, non-heading, non-structural). */ + bodyLineCount: number; +} + +const ANGLE_TOKEN = /<([a-z][a-z0-9]*(?:[ _-][a-z0-9]+)*)>/g; + +/** Common HTML element names that may legitimately appear in Markdown prose. */ +const HTML_TAGS = new Set([ + 'a', 'b', 'br', 'code', 'dd', 'details', 'div', 'dl', 'dt', 'em', 'hr', 'i', + 'img', 'kbd', 'li', 'ol', 'p', 'pre', 'small', 'span', 'strong', 'sub', + 'summary', 'sup', 'table', 'tbody', 'td', 'th', 'thead', 'tr', 'ul', +]); + +const TBD_TODO = /\b(?:TBD|TODO)\b/i; + +/** + * A line whose content (after list markers and an optional `Label:` or + * `**Label:**` prefix) is an instruction to fill something in. + */ +const INSTRUCTION_PREFIX = /^(?:[-*+][ \t]+|\d+[.)][ \t]+|>[ \t]*)*(?:\*\*[^*]{1,60}\*\*:?[ \t]*|[A-Za-z][A-Za-z /-]{0,40}:[ \t]*)?/; +const INSTRUCTION_LINE = /^(?:add|list|describe|document|identify)\b.*\bhere\b[.!]?$/i; + +const TEMPLATE_LINES = new Set(TEMPLATE_PLACEHOLDER_LINES.map((line) => line.toLowerCase())); + +function stripListPrefix(line: string): string { + const match = INSTRUCTION_PREFIX.exec(line); + return match !== null ? line.slice(match[0].length) : line; +} + +/** Strip checkbox/list/quote markers only (keep any `Label:` text). */ +const STRUCTURAL_PREFIX = /^(?:[-*+][ \t]+(?:\[[^\]]?\]\*?[ \t]*)?|\d+[.)][ \t]+|>[ \t]*)*/; + +function bodyOf(line: string): string { + const match = STRUCTURAL_PREFIX.exec(line); + return (match !== null ? line.slice(match[0].length) : line).trim(); +} + +export function findPlaceholdersInLine(text: string): string[] { + const found: string[] = []; + ANGLE_TOKEN.lastIndex = 0; + for (let match = ANGLE_TOKEN.exec(text); match !== null; match = ANGLE_TOKEN.exec(text)) { + const token = match[1] ?? ''; + if (!HTML_TAGS.has(token)) found.push(`<${token}>`); + } + const tbd = TBD_TODO.exec(text); + if (tbd !== null) found.push(tbd[0]); + + const body = bodyOf(text); + const instruction = stripListPrefix(text.trim()); + if (INSTRUCTION_LINE.test(instruction) || INSTRUCTION_LINE.test(body)) { + found.push(text.trim()); + } else if (TEMPLATE_LINES.has(body.toLowerCase())) { + found.push(text.trim()); + } + return found; +} + +const HEADING_LINE = /^ {0,3}#{1,6}(?:$|[ \t])/; +const TABLE_RULE = /^[ \t]*\|?[ \t:-]*-{3,}[ \t:|-]*$/; +const STATUS_NOTE = /^>[ \t]*status:/i; + +/** Scan a document for placeholder content, ignoring fenced code blocks. */ +export function scanPlaceholders(document: MarkdownDocument): PlaceholderScan { + const mask = document.codeFenceMask(); + const hits: PlaceholderHit[] = []; + let bodyLineCount = 0; + let placeholderLineCount = 0; + + for (let i = 0; i < document.lineCount; i += 1) { + if (mask[i] === true) continue; + const text = document.lineAt(i).text; + const trimmed = text.trim(); + if (trimmed.length === 0) continue; + + const lineHits = findPlaceholdersInLine(text); + for (const hit of lineHits) hits.push({ line: i, text: hit }); + + // Structure does not count as body content when judging "placeholder-only": + // headings, table rules, and the human-readable "> Status:" note. + if (HEADING_LINE.test(text) || TABLE_RULE.test(trimmed) || STATUS_NOTE.test(trimmed)) { + continue; + } + bodyLineCount += 1; + if (lineHits.length > 0) placeholderLineCount += 1; + } + + return { + hits, + placeholderOnly: bodyLineCount > 0 && placeholderLineCount === bodyLineCount, + bodyLineCount, + }; +} diff --git a/packages/workflow/src/sidecar-audit.ts b/packages/workflow/src/sidecar-audit.ts new file mode 100644 index 0000000..438973d --- /dev/null +++ b/packages/workflow/src/sidecar-audit.ts @@ -0,0 +1,117 @@ +import { existsSync, readdirSync } from 'node:fs'; +import type { ApprovalHealth, Diagnostic, WorkspaceInfo } from '@specbridge/core'; +import { readSpecState, specStateDir } from '@specbridge/core'; +import type { SpecFolder } from '@specbridge/compat-kiro'; +import { evaluateWorkflow } from './health.js'; + +/** + * Sidecar state audit for `doctor`: cross-checks `.specbridge/state/specs/` + * against the actual `.kiro/specs/` directories. Report-only — nothing is + * ever deleted or repaired automatically. + */ + +export interface SidecarAuditEntry { + specName: string; + statePath: string; + /** True when a matching `.kiro/specs/<name>/` directory exists. */ + hasSpecFolder: boolean; + health: ApprovalHealth; + /** Present when the state parsed and evaluated. */ + effectiveStatus?: string; + diagnostics: Diagnostic[]; +} + +export interface SidecarAudit { + stateDir: string; + stateDirExists: boolean; + entries: SidecarAuditEntry[]; + /** State files whose spec folder no longer exists. */ + orphanStates: string[]; + /** Spec folders with no sidecar state (normal for Kiro-only projects). */ + unmanagedSpecs: string[]; + /** Managed specs with at least one stale approval. */ + staleSpecs: string[]; + /** State files that could not be used. */ + invalidStates: string[]; + /** Non-state entries found in the state directory (listed, never touched). */ + unknownEntries: string[]; + diagnostics: Diagnostic[]; +} + +export function auditSidecarState(workspace: WorkspaceInfo, folders: SpecFolder[]): SidecarAudit { + const stateDir = specStateDir(workspace); + const stateDirExists = existsSync(stateDir); + const folderNames = new Set(folders.map((folder) => folder.name)); + + const entries: SidecarAuditEntry[] = []; + const orphanStates: string[] = []; + const invalidStates: string[] = []; + const staleSpecs: string[] = []; + const unknownEntries: string[] = []; + const diagnostics: Diagnostic[] = []; + + if (stateDirExists) { + const dirEntries = readdirSync(stateDir, { withFileTypes: true }).sort((a, b) => + a.name.localeCompare(b.name, 'en'), + ); + for (const entry of dirEntries) { + if (!entry.isFile() || !entry.name.endsWith('.json')) { + unknownEntries.push(entry.name); + continue; + } + const specName = entry.name.slice(0, -'.json'.length); + const read = readSpecState(workspace, specName); + const hasSpecFolder = folderNames.has(specName); + const entryDiagnostics: Diagnostic[] = [...read.diagnostics]; + + let health: ApprovalHealth; + let effectiveStatus: string | undefined; + if (read.state === undefined) { + health = 'invalid'; + invalidStates.push(specName); + } else if (!hasSpecFolder) { + health = 'invalid'; + orphanStates.push(specName); + entryDiagnostics.push({ + severity: 'warning', + code: 'SIDECAR_STATE_ORPHAN', + message: `Sidecar state exists for "${specName}" but .kiro/specs/${specName}/ does not. The state file is preserved; delete it manually if the spec is gone for good.`, + file: read.path, + }); + } else { + const evaluation = evaluateWorkflow(workspace, read.state); + health = evaluation.health; + effectiveStatus = evaluation.effectiveStatus; + entryDiagnostics.push(...evaluation.diagnostics); + if (evaluation.health === 'stale') staleSpecs.push(specName); + } + + entries.push({ + specName, + statePath: read.path, + hasSpecFolder, + health, + ...(effectiveStatus !== undefined ? { effectiveStatus } : {}), + diagnostics: entryDiagnostics, + }); + diagnostics.push(...entryDiagnostics); + } + } + + const managedNames = new Set(entries.map((entry) => entry.specName)); + const unmanagedSpecs = folders + .map((folder) => folder.name) + .filter((name) => !managedNames.has(name)); + + return { + stateDir, + stateDirExists, + entries, + orphanStates, + unmanagedSpecs, + staleSpecs, + invalidStates, + unknownEntries, + diagnostics, + }; +} diff --git a/packages/workflow/src/spec-analysis.ts b/packages/workflow/src/spec-analysis.ts new file mode 100644 index 0000000..b0d31e5 --- /dev/null +++ b/packages/workflow/src/spec-analysis.ts @@ -0,0 +1,64 @@ +import type { StageName, StageStatus } from '@specbridge/core'; +import type { SpecAnalysis } from '@specbridge/compat-kiro'; +import type { SpecAnalysisResult, StageAnalysisOptions } from './analyzers.js'; +import { analyzeSpecStage, combineStageAnalyses } from './analyzers.js'; +import type { WorkflowEvaluation } from './health.js'; +import { isEffectivelyApproved } from './health.js'; +import { applicableStages } from './state-machine.js'; + +/** + * Workflow-aware analysis wiring: decides, per stage, how strict the + * analyzer should be based on the recorded workflow state. + * + * - Active (draft/approved) stages: placeholders are errors, a missing file + * is an error. + * - Blocked stages: placeholders are warnings, a missing file is info — + * a "pending" stub behind an unapproved prerequisite is expected. + * - Unmanaged specs (no sidecar state): every present stage is analyzed at + * full strictness, but nothing is assumed about approvals. + */ + +export function stagesToAnalyze( + spec: SpecAnalysis, + evaluation: WorkflowEvaluation | undefined, +): StageName[] { + if (evaluation !== undefined) { + return evaluation.stages.map((stage) => stage.stage); + } + return applicableStages(spec.classification.type === 'bugfix' ? 'bugfix' : 'feature'); +} + +export function stageAnalysisOptions( + evaluation: WorkflowEvaluation | undefined, + stage: StageName, +): StageAnalysisOptions { + if (evaluation === undefined) { + // Unmanaged: analyze at full strictness; skip state-dependent advisories. + return { placeholderSeverity: 'error', missingFileSeverity: 'error', prerequisitesApproved: true }; + } + const stageEvaluation = evaluation.stages.find((s) => s.stage === stage); + const stored: StageStatus = stageEvaluation?.stored.status ?? 'draft'; + const blocked = stored === 'blocked'; + const prerequisitesApproved = (stageEvaluation?.prerequisites ?? []).every((prerequisite) => + isEffectivelyApproved(evaluation, prerequisite), + ); + return { + placeholderSeverity: blocked ? 'warning' : 'error', + missingFileSeverity: blocked ? 'info' : 'error', + stageStatus: stored, + prerequisitesApproved, + }; +} + +/** Analyze the requested stages of a spec with workflow-appropriate strictness. */ +export function analyzeSpecWorkflow( + spec: SpecAnalysis, + evaluation: WorkflowEvaluation | undefined, + stages?: StageName[], +): SpecAnalysisResult { + const targets = stages ?? stagesToAnalyze(spec, evaluation); + const results = targets.map((stage) => + analyzeSpecStage(spec, stage, stageAnalysisOptions(evaluation, stage)), + ); + return combineStageAnalyses(spec.folder.name, results); +} diff --git a/packages/workflow/src/spec-name.ts b/packages/workflow/src/spec-name.ts new file mode 100644 index 0000000..f64ec59 --- /dev/null +++ b/packages/workflow/src/spec-name.ts @@ -0,0 +1,84 @@ +/** + * Spec name validation. + * + * Spec names become directory names under `.kiro/specs/`, so they are the + * one user input that could escape the workspace. Validation is strict and + * every rejection explains itself. + */ + +export interface SpecNameValidation { + valid: boolean; + /** Human-readable reasons the name was rejected (empty when valid). */ + problems: string[]; +} + +const VALID_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; +const MAX_NAME_LENGTH = 100; + +/** + * Windows reserved device names. A directory named `con` cannot be created + * (or worse, behaves strangely) on Windows, and specs must stay portable. + */ +const WINDOWS_RESERVED = new Set([ + 'con', + 'prn', + 'aux', + 'nul', + ...Array.from({ length: 9 }, (_, i) => `com${i + 1}`), + ...Array.from({ length: 9 }, (_, i) => `lpt${i + 1}`), +]); + +export function validateSpecName(name: string): SpecNameValidation { + const problems: string[] = []; + + if (name.length === 0) { + return { valid: false, problems: ['Spec names must not be empty.'] }; + } + if (name.includes('/') || name.includes('\\')) { + problems.push('Spec names must not contain path separators ("/" or "\\").'); + } + if (name === '..' || name === '.' || name.includes('..')) { + problems.push('Spec names must not contain "..".'); + } + if (/^[a-zA-Z]:/.test(name) || name.startsWith('/') || name.startsWith('\\')) { + problems.push('Spec names must be plain names, not absolute paths.'); + } + if (/\s/.test(name)) { + problems.push('Spec names must not contain spaces; use hyphens instead.'); + } + if (name.includes('_')) { + problems.push('Spec names must not contain underscores; use hyphens instead.'); + } + if (/[A-Z]/.test(name)) { + problems.push('Spec names must be lowercase.'); + } + if (name.startsWith('-') || name.endsWith('-')) { + problems.push('Spec names must not start or end with a hyphen.'); + } + if (name.includes('--')) { + problems.push('Spec names must not contain consecutive hyphens.'); + } + if (name.length > MAX_NAME_LENGTH) { + problems.push(`Spec names must be at most ${MAX_NAME_LENGTH} characters long.`); + } + if (WINDOWS_RESERVED.has(name.toLowerCase())) { + problems.push(`"${name}" is a reserved device name on Windows and cannot be used.`); + } + + // Catch anything the specific checks above did not explain (e.g. emoji). + if (problems.length === 0 && !VALID_NAME.test(name)) { + problems.push( + 'Spec names may only use lowercase letters, digits, and single hyphens between words (e.g. "notification-preferences").', + ); + } + + return { valid: problems.length === 0, problems }; +} + +/** Derive a human-readable default title from a valid spec name. */ +export function titleFromSpecName(name: string): string { + return name + .split('-') + .map((word) => (word.length > 0 ? word.charAt(0).toUpperCase() + word.slice(1) : word)) + .join(' '); +} diff --git a/packages/workflow/src/state-machine.ts b/packages/workflow/src/state-machine.ts new file mode 100644 index 0000000..dfba943 --- /dev/null +++ b/packages/workflow/src/state-machine.ts @@ -0,0 +1,170 @@ +import type { + ConcreteSpecType, + ConcreteWorkflowMode, + SpecWorkflowState, + StageApproval, + StageName, + WorkflowStatus, +} from '@specbridge/core'; +import { stateStage } from '@specbridge/core'; + +/** + * The workflow state machine. + * + * A workflow is an ordered list of stages plus a prerequisite rule. Two + * shapes exist: + * + * - `sequential` — each stage requires every earlier stage to be approved + * (requirements-first, design-first, and bugfix workflows). + * - `parallel-docs` — the two document stages can be approved in either + * order; tasks requires both (quick workflows). + * + * Approval state is only ever read from sidecar state. A stage is never + * considered approved because its file exists. + */ + +export interface WorkflowShape { + specType: ConcreteSpecType; + mode: ConcreteWorkflowMode; + /** Stage order used for display and for sequential prerequisites. */ + order: StageName[]; + kind: 'sequential' | 'parallel-docs'; +} + +/** The stage that holds the written problem statement for a spec type. */ +export function documentStageFor(specType: ConcreteSpecType): StageName { + return specType === 'bugfix' ? 'bugfix' : 'requirements'; +} + +export function workflowShape( + specType: ConcreteSpecType, + mode: ConcreteWorkflowMode, +): WorkflowShape { + const documentStage = documentStageFor(specType); + switch (mode) { + case 'requirements-first': + return { specType, mode, order: [documentStage, 'design', 'tasks'], kind: 'sequential' }; + case 'design-first': + return { specType, mode, order: ['design', documentStage, 'tasks'], kind: 'sequential' }; + case 'quick': + return { specType, mode, order: [documentStage, 'design', 'tasks'], kind: 'parallel-docs' }; + } +} + +/** Stages that exist for a spec type (independent of order). */ +export function applicableStages(specType: ConcreteSpecType): StageName[] { + return [documentStageFor(specType), 'design', 'tasks']; +} + +export function isStageApplicable(specType: ConcreteSpecType, stage: StageName): boolean { + return applicableStages(specType).includes(stage); +} + +/** Direct prerequisites of a stage under the given workflow shape. */ +export function stagePrerequisites(shape: WorkflowShape, stage: StageName): StageName[] { + const index = shape.order.indexOf(stage); + if (index < 0) return []; + if (shape.kind === 'sequential') { + return shape.order.slice(0, index); + } + // parallel-docs: document stages have no prerequisites; tasks needs both. + return stage === 'tasks' ? shape.order.slice(0, 2) : []; +} + +/** Stages whose approval depends (directly or transitively) on `stage`. */ +export function dependentStages(shape: WorkflowShape, stage: StageName): StageName[] { + return shape.order.filter((candidate) => stagePrerequisites(shape, candidate).includes(stage)); +} + +function isApproved(state: SpecWorkflowState, stage: StageName): boolean { + return stateStage(state, stage)?.status === 'approved'; +} + +/** + * Recompute the blocked/draft flag of every non-approved stage from the + * recorded approvals. Returns a new stages object in workflow order. + */ +export function recomputeStages( + shape: WorkflowShape, + state: SpecWorkflowState, +): Record<string, StageApproval> { + const next: Record<string, StageApproval> = {}; + for (const stage of shape.order) { + const current = stateStage(state, stage); + if (current === undefined) continue; + if (current.status === 'approved') { + next[stage] = current; + continue; + } + const ready = stagePrerequisites(shape, stage).every((prereq) => isApproved(state, prereq)); + next[stage] = { ...current, status: ready ? 'draft' : 'blocked' }; + } + return next; +} + +const DRAFT_STATUS: Record<StageName, WorkflowStatus> = { + requirements: 'REQUIREMENTS_DRAFT', + bugfix: 'BUGFIX_DRAFT', + design: 'DESIGN_DRAFT', + tasks: 'TASKS_DRAFT', +}; + +const APPROVED_STATUS: Partial<Record<StageName, WorkflowStatus>> = { + requirements: 'REQUIREMENTS_APPROVED', + bugfix: 'BUGFIX_APPROVED', + design: 'DESIGN_APPROVED', + // tasks approved means the whole workflow is READY_FOR_IMPLEMENTATION. +}; + +/** + * Derive the stored workflow status from stage approvals alone. + * Quick workflows only distinguish READY_FOR_REVIEW / READY_FOR_IMPLEMENTATION. + */ +export function deriveWorkflowStatus( + shape: WorkflowShape, + stages: Record<string, StageApproval>, +): WorkflowStatus { + const approved = (stage: StageName): boolean => stages[stage]?.status === 'approved'; + const allApproved = shape.order.every(approved); + if (allApproved) return 'READY_FOR_IMPLEMENTATION'; + if (shape.kind === 'parallel-docs') return 'READY_FOR_REVIEW'; + + for (let i = 0; i < shape.order.length; i += 1) { + const stage = shape.order[i] as StageName; + if (approved(stage)) continue; + if (stages[stage]?.status === 'draft') return DRAFT_STATUS[stage]; + // Defensive: a blocked stage whose predecessor is approved should not + // occur after recomputeStages, but hand-edited state can express it. + const previous = i > 0 ? (shape.order[i - 1] as StageName) : undefined; + if (previous !== undefined && approved(previous)) { + return APPROVED_STATUS[previous] ?? DRAFT_STATUS[stage]; + } + return DRAFT_STATUS[stage]; + } + return 'READY_FOR_IMPLEMENTATION'; +} + +/** Workspace-relative stage file path, always with forward slashes. */ +export function stageFilePath(specName: string, stage: StageName): string { + return `.kiro/specs/${specName}/${stage}.md`; +} + +/** Initial stages for a fresh workflow: nothing approved, first stage(s) draft. */ +export function initialStages(shape: WorkflowShape, specName: string): Record<string, StageApproval> { + const stages: Record<string, StageApproval> = {}; + for (const stage of shape.order) { + const ready = stagePrerequisites(shape, stage).length === 0; + stages[stage] = { + status: ready ? 'draft' : 'blocked', + file: stageFilePath(specName, stage), + approvedAt: null, + approvedHash: null, + }; + } + return stages; +} + +/** The status a fresh spec starts in. */ +export function initialWorkflowStatus(shape: WorkflowShape): WorkflowStatus { + return deriveWorkflowStatus(shape, initialStages(shape, 'unused')); +} diff --git a/packages/workflow/src/templates.ts b/packages/workflow/src/templates.ts new file mode 100644 index 0000000..5f827a5 --- /dev/null +++ b/packages/workflow/src/templates.ts @@ -0,0 +1,340 @@ +import type { ConcreteSpecType, ConcreteWorkflowMode, StageName } from '@specbridge/core'; + +/** + * Offline Markdown templates for new specs. + * + * Rules: + * - Plain Markdown only. No front matter, no HTML comments, no SpecBridge + * metadata — every generated file must be openable by Kiro unchanged. + * - Workflow state ("> Status:" lines) is human-readable prose only; the + * sidecar state is authoritative. + * - Placeholders are deliberately machine-recognizable (angle-bracket tokens + * and "... here." instruction lines) so `spec analyze` can block approval + * until they are replaced with real content. + * - Generated files always use LF line endings and end with a newline. + */ + +export interface TemplateInput { + title: string; + /** May span multiple Markdown paragraphs; inserted verbatim. */ + description: string; +} + +export interface RenderedSpecFile { + fileName: string; + stage: StageName; + content: string; +} + +/** Default description when neither --description nor --from-file is given. */ +export const DEFAULT_FEATURE_DESCRIPTION = 'Add a short description of the feature here.'; +export const DEFAULT_BUGFIX_DESCRIPTION = 'Add a short description of the bug here.'; + +/** + * Exact body lines (trimmed) that mark a generated pending-stage document. + * The analyzer treats a line matching one of these as placeholder content. + */ +export const TEMPLATE_PLACEHOLDER_LINES: readonly string[] = [ + 'Design will be completed after requirements approval.', + 'Requirements will be derived and validated after the initial design review.', + 'Define implementation tasks after design approval.', + 'Define implementation tasks after requirements and design approval.', +]; + +function joinLines(lines: string[]): string { + return `${lines.join('\n')}\n`; +} + +function introBlock(input: TemplateInput): string[] { + const description = input.description.trim(); + return [`**${input.title}**`, '', ...description.split(/\r?\n/)]; +} + +function featureRequirements(input: TemplateInput): string { + return joinLines([ + '# Requirements Document', + '', + '## Introduction', + '', + ...introBlock(input), + '', + '## Glossary', + '', + '| Term | Definition |', + '| --- | --- |', + '| <term> | <definition> |', + '', + '## Requirements', + '', + '### Requirement 1: <initial requirement title>', + '', + '**User Story:** As a <role>, I want <capability>, so that <benefit>.', + '', + '#### Acceptance Criteria', + '', + '1. WHEN <condition or event>, THE SYSTEM SHALL <expected behavior>.', + '2. IF <error or exceptional condition>, THEN THE SYSTEM SHALL <safe behavior>.', + '', + '## Non-Functional Requirements', + '', + '- Performance: add measurable performance expectations here.', + '- Security: add authentication, authorization, and data-handling expectations here.', + '- Reliability: add availability and failure-recovery expectations here.', + '- Observability: add logging, metrics, and alerting expectations here.', + '- Compatibility: add platform and integration constraints here.', + '', + '## Edge Cases', + '', + '- Add edge cases here.', + '', + '## Out of Scope', + '', + '- Add explicitly excluded behavior here.', + ]); +} + +function featureDesign(input: TemplateInput): string { + return joinLines([ + '# Design Document', + '', + '## Overview', + '', + ...introBlock(input), + '', + '## Goals', + '', + '- Add concrete goals here.', + '', + '## Non-Goals', + '', + '- Add explicitly excluded goals here.', + '', + '## Architecture', + '', + 'Describe the overall approach here.', + '', + '## Components and Interfaces', + '', + '- Add affected components and their interfaces here.', + '', + '## Data Model', + '', + '- Add new or changed data structures here.', + '', + '## Control Flow', + '', + 'Describe the main control flow here.', + '', + '## Failure Handling', + '', + '- Add failure modes and how the system handles them here.', + '', + '## Security Considerations', + '', + '- Add authentication, authorization, and data-protection concerns here.', + '', + '## Observability', + '', + '- Add logging, metrics, and tracing decisions here.', + '', + '## Testing Strategy', + '', + '- Add unit, integration, and regression testing plans here.', + '', + '## Risks and Trade-offs', + '', + '- Add known risks and accepted trade-offs here.', + '', + '## Alternatives Considered', + '', + '- Add rejected alternatives and why they were rejected here.', + ]); +} + +function pendingDesign(): string { + return joinLines([ + '# Design Document', + '', + '> Status: Pending requirements approval.', + '', + '## Overview', + '', + 'Design will be completed after requirements approval.', + ]); +} + +function pendingRequirements(): string { + return joinLines([ + '# Requirements Document', + '', + '> Status: Pending design review.', + '', + '## Introduction', + '', + 'Requirements will be derived and validated after the initial design review.', + ]); +} + +function pendingTasksAfterDesign(): string { + return joinLines([ + '# Implementation Plan', + '', + '> Status: Pending design approval.', + '', + '- [ ] Define implementation tasks after design approval.', + ]); +} + +function pendingTasksAfterBoth(): string { + return joinLines([ + '# Implementation Plan', + '', + '> Status: Pending requirements and design approval.', + '', + '- [ ] Define implementation tasks after requirements and design approval.', + ]); +} + +function quickTasks(): string { + return joinLines([ + '# Implementation Plan', + '', + '- [ ] 1. Review and refine requirements.', + '- [ ] 2. Confirm the proposed design.', + '- [ ] 3. Implement the primary behavior.', + '- [ ] 4. Add automated tests for acceptance criteria.', + '- [ ] 5. Verify error handling and edge cases.', + '- [ ] 6. Update documentation where required.', + ]); +} + +function bugfixDocument(input: TemplateInput): string { + return joinLines([ + '# Bugfix Document', + '', + '## Summary', + '', + ...introBlock(input), + '', + '## Current Behavior', + '', + 'Describe the observed incorrect behavior here.', + '', + '## Expected Behavior', + '', + 'Describe the correct behavior here.', + '', + '## Unchanged Behavior', + '', + '- List behavior that must remain unchanged here.', + '', + '## Reproduction', + '', + '1. Add reproduction steps here.', + '', + '## Evidence', + '', + '- Logs: add relevant log lines here.', + '- Error messages: add exact error text here.', + '- Screenshots: add links or paths here.', + '- Failing tests: add failing test names here.', + '- Relevant source locations: add file paths here.', + '', + '## Constraints', + '', + '- Add implementation or compatibility constraints here.', + '', + '## Regression Risks', + '', + '- Add behavior that could regress here.', + ]); +} + +function bugfixDesign(): string { + return joinLines([ + '# Fix Design', + '', + '## Root Cause', + '', + 'Document the confirmed or suspected root cause here.', + '', + '## Proposed Fix', + '', + 'Describe the smallest safe fix here.', + '', + '## Affected Components', + '', + '- Add affected files and components here.', + '', + '## Failure Handling', + '', + '- Add failure modes introduced or fixed by this change here.', + '', + '## Alternatives Considered', + '', + '- Add rejected alternatives and why they were rejected here.', + '', + '## Regression Protection', + '', + '- Add the regression tests that will guard this fix here.', + '', + '## Validation Strategy', + '', + '- Add the checks that prove the fix works here.', + ]); +} + +function bugfixTasks(): string { + return joinLines([ + '# Bugfix Implementation Plan', + '', + '- [ ] 1. Reproduce the bug with deterministic evidence.', + '- [ ] 2. Confirm the root cause.', + '- [ ] 3. Implement the smallest safe fix.', + '- [ ] 4. Add regression tests.', + '- [ ] 5. Verify unchanged behavior.', + '- [ ] 6. Run the required validation checks.', + '- [ ] 7. Document remaining risks.', + ]); +} + +/** + * Render the three Markdown files for a new spec. + * + * Bugfix specs use the same file set in every mode (`bugfix.md`, a fix + * design, and a bugfix plan) — the workflow mode only changes the approval + * order enforced through sidecar state. + */ +export function renderSpecTemplates( + specType: ConcreteSpecType, + mode: ConcreteWorkflowMode, + input: TemplateInput, +): RenderedSpecFile[] { + if (specType === 'bugfix') { + return [ + { fileName: 'bugfix.md', stage: 'bugfix', content: bugfixDocument(input) }, + { fileName: 'design.md', stage: 'design', content: bugfixDesign() }, + { fileName: 'tasks.md', stage: 'tasks', content: bugfixTasks() }, + ]; + } + switch (mode) { + case 'requirements-first': + return [ + { fileName: 'requirements.md', stage: 'requirements', content: featureRequirements(input) }, + { fileName: 'design.md', stage: 'design', content: pendingDesign() }, + { fileName: 'tasks.md', stage: 'tasks', content: pendingTasksAfterDesign() }, + ]; + case 'design-first': + return [ + { fileName: 'requirements.md', stage: 'requirements', content: pendingRequirements() }, + { fileName: 'design.md', stage: 'design', content: featureDesign(input) }, + { fileName: 'tasks.md', stage: 'tasks', content: pendingTasksAfterBoth() }, + ]; + case 'quick': + return [ + { fileName: 'requirements.md', stage: 'requirements', content: featureRequirements(input) }, + { fileName: 'design.md', stage: 'design', content: featureDesign(input) }, + { fileName: 'tasks.md', stage: 'tasks', content: quickTasks() }, + ]; + } +} diff --git a/packages/workflow/tsconfig.json b/packages/workflow/tsconfig.json new file mode 100644 index 0000000..883bfe4 --- /dev/null +++ b/packages/workflow/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "types": ["node"] + }, + "include": ["src/**/*.ts", "tsup.config.ts"] +} diff --git a/packages/workflow/tsup.config.ts b/packages/workflow/tsup.config.ts new file mode 100644 index 0000000..97d5c95 --- /dev/null +++ b/packages/workflow/tsup.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig({ + entry: ['src/index.ts'], + format: ['esm'], + target: 'node20', + dts: true, + sourcemap: true, + clean: true, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ff435b7..52b96be 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -38,6 +38,9 @@ importers: '@specbridge/reporting': specifier: workspace:* version: link:../reporting + '@specbridge/workflow': + specifier: workspace:* + version: link:../workflow commander: specifier: ^12.1.0 version: 12.1.0 @@ -141,6 +144,22 @@ importers: specifier: ^5.6.0 version: 5.9.3 + packages/workflow: + dependencies: + '@specbridge/compat-kiro': + specifier: workspace:* + version: link:../compat-kiro + '@specbridge/core': + specifier: workspace:* + version: link:../core + devDependencies: + tsup: + specifier: ^8.3.0 + version: 8.5.1(postcss@8.5.16)(typescript@5.9.3)(yaml@2.9.0) + typescript: + specifier: ^5.6.0 + version: 5.9.3 + packages: '@esbuild/aix-ppc64@0.27.7': diff --git a/scripts/smoke.mjs b/scripts/smoke.mjs index c0193f2..306e40a 100644 --- a/scripts/smoke.mjs +++ b/scripts/smoke.mjs @@ -4,7 +4,8 @@ * Exits non-zero on the first failure. No network, no model, no API key. */ import { spawnSync } from 'node:child_process'; -import { existsSync, readFileSync } from 'node:fs'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; +import os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -121,7 +122,14 @@ run('sidecar workflow modes surface in spec list', { cwd: examples('requirements-first-project'), args: ['spec', 'list'], expectCode: 0, - expectStdout: ['requirements-first', 'DESIGN_APPROVED'], + expectStdout: ['requirements-first', 'DESIGN_DRAFT'], +}); + +run('spec status reports stage approvals from sidecar state', { + cwd: examples('requirements-first-project'), + args: ['spec', 'status', 'notification-preferences'], + expectCode: 0, + expectStdout: ['Status: DESIGN_DRAFT', 'Approved', 'Content unchanged since approval'], }); run('bugfix example classifies from layout alone', { @@ -138,6 +146,89 @@ run('planned commands fail honestly', { expectStderr: ['not implemented yet'], }); +// v0.2 authoring workflow, end to end, in a throwaway workspace. +const scratch = mkdtempSync(path.join(os.tmpdir(), 'specbridge-smoke-')); +mkdirSync(path.join(scratch, '.kiro'), { recursive: true }); + +run('spec new creates a Kiro-compatible spec offline', { + cwd: scratch, + args: [ + 'spec', + 'new', + 'notification-preferences', + '--mode', + 'requirements-first', + '--description', + 'Allow users to select email and push notification preferences.', + ], + expectCode: 0, + expectStdout: ['Created spec: notification-preferences', 'requirements.md', 'sidecar workflow state'], +}); + +run('freshly generated placeholders block approval', { + cwd: scratch, + args: ['spec', 'approve', 'notification-preferences', '--stage', 'requirements'], + expectCode: 1, + expectStderr: ['Cannot approve requirements'], +}); + +writeFileSync( + path.join(scratch, '.kiro', 'specs', 'notification-preferences', 'requirements.md'), + [ + '# Requirements Document', + '', + '## Introduction', + '', + 'Allow users to select email and push notification preferences.', + '', + '## Requirements', + '', + '### Requirement 1: Channel selection', + '', + '**User Story:** As a user, I want to pick notification channels, so that I control where alerts arrive.', + '', + '#### Acceptance Criteria', + '', + '1. WHEN a user saves channel preferences, THE SYSTEM SHALL persist the selection.', + '2. IF the preferences service is unavailable, THEN THE SYSTEM SHALL keep the previous preferences.', + '', + '## Out of Scope', + '', + '- Digest scheduling.', + '', + '## Non-Functional Requirements', + '', + '- Security: preferences are only readable by their owner.', + '', + ].join('\n'), +); + +run('spec analyze passes after real content is written', { + cwd: scratch, + args: ['spec', 'analyze', 'notification-preferences', '--stage', 'requirements'], + expectCode: 0, + expectStdout: ['Result: OK'], +}); + +run('spec approve records the stage approval', { + cwd: scratch, + args: ['spec', 'approve', 'notification-preferences', '--stage', 'requirements'], + expectCode: 0, + expectStdout: ['requirements approved', 'Status: DESIGN_DRAFT'], +}); + +writeFileSync( + path.join(scratch, '.kiro', 'specs', 'notification-preferences', 'requirements.md'), + '# Requirements Document\n\nmodified after approval\n', +); + +run('spec status detects the stale approval', { + cwd: scratch, + args: ['spec', 'status', 'notification-preferences'], + expectCode: 0, + expectStdout: ['STALE_APPROVAL', 'Modified after approval'], +}); + run('unknown spec errors helpfully', { cwd: kiroProject, args: ['spec', 'show', 'does-not-exist'], diff --git a/tests/cli/cli-smoke.test.ts b/tests/cli/cli-smoke.test.ts index bff7ed1..cd5938e 100644 --- a/tests/cli/cli-smoke.test.ts +++ b/tests/cli/cli-smoke.test.ts @@ -266,9 +266,6 @@ describe('specbridge compat check', () => { describe('planned commands are honest', () => { it.each([ - ['spec', 'new', 'x'], - ['spec', 'analyze', 'x'], - ['spec', 'approve', 'x'], ['spec', 'run', 'x'], ['spec', 'sync', 'x'], ['spec', 'verify', 'x'], @@ -293,7 +290,7 @@ describe('general CLI behavior', () => { it('--version exits 0', async () => { const result = await cli(standard, '--version'); expect(result.code).toBe(0); - expect(result.stdout).toContain('0.1.0'); + expect(result.stdout).toContain('0.2.0'); }); it('unknown commands exit 2', async () => { diff --git a/tests/cli/cli-v02-workflow.test.ts b/tests/cli/cli-v02-workflow.test.ts new file mode 100644 index 0000000..63295b6 --- /dev/null +++ b/tests/cli/cli-v02-workflow.test.ts @@ -0,0 +1,395 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { runCli } from '../../packages/cli/src/cli'; +import { FIXED_NOW, copyFixtureToTemp, emptyTempDir, fixturePath } from '../helpers.js'; + +/** + * End-to-end CLI tests for the v0.2 authoring and approval workflow. + * Everything runs in-process against temp copies of fixtures. + */ + +interface CliResult { + code: number; + stdout: string; + stderr: string; +} + +async function cli(cwd: string, ...argv: string[]): Promise<CliResult> { + const stdout: string[] = []; + const stderr: string[] = []; + const code = await runCli(argv, { + cwd, + out: (line) => stdout.push(`${line}\n`), + outRaw: (text) => stdout.push(text), + err: (line) => stderr.push(`${line}\n`), + now: () => FIXED_NOW, + }); + return { code, stdout: stdout.join(''), stderr: stderr.join('') }; +} + +function freshKiroDir(): string { + const root = emptyTempDir(); + mkdirSync(path.join(root, '.kiro')); + return root; +} + +const VALID_REQUIREMENTS = `# Requirements Document + +## Introduction + +CLI end-to-end test content. + +## Requirements + +### Requirement 1: Behavior + +**User Story:** As a user, I want predictable behavior, so that approvals mean something. + +#### Acceptance Criteria + +1. WHEN the action runs, THE SYSTEM SHALL produce the documented result. +2. IF the backend fails, THEN THE SYSTEM SHALL return an actionable error. + +## Out of Scope + +- Everything else. + +## Non-Functional Requirements + +- Security: authenticated users only. +`; + +describe('spec new (CLI)', () => { + it('creates a spec and prints the created files', async () => { + const root = freshKiroDir(); + const result = await cli( + root, + 'spec', + 'new', + 'notification-preferences', + '--description', + 'Allow users to choose email and push notification preferences.', + ); + expect(result.code).toBe(0); + expect(result.stdout).toContain('Created spec: notification-preferences'); + expect(existsSync(path.join(root, '.kiro', 'specs', 'notification-preferences', 'requirements.md'))).toBe(true); + expect(existsSync(path.join(root, '.specbridge', 'state', 'specs', 'notification-preferences.json'))).toBe(true); + }); + + it('emits a deterministic JSON plan with --dry-run --json and writes nothing (test 8)', async () => { + const root = freshKiroDir(); + const result = await cli(root, 'spec', 'new', 'payment-retry', '--mode', 'quick', '--dry-run', '--json'); + expect(result.code).toBe(0); + const report = JSON.parse(result.stdout) as { + schema: string; + data: { + dryRun: boolean; + created: boolean; + files: { fileName: string; content: string }[]; + state: { status: string; createdAt: string }; + }; + }; + expect(report.schema).toBe('specbridge.spec-new/1'); + expect(report.data.dryRun).toBe(true); + expect(report.data.created).toBe(false); + expect(report.data.files.map((f) => f.fileName).sort()).toEqual(['design.md', 'requirements.md', 'tasks.md']); + expect(report.data.state.status).toBe('READY_FOR_REVIEW'); + expect(report.data.state.createdAt).toBe(FIXED_NOW.toISOString()); + expect(existsSync(path.join(root, '.kiro', 'specs'))).toBe(false); + expect(existsSync(path.join(root, '.specbridge'))).toBe(false); + }); + + it('rejects invalid names with exit 2 and reasons', async () => { + const root = freshKiroDir(); + const result = await cli(root, 'spec', 'new', 'Payment_Retry'); + expect(result.code).toBe(2); + expect(result.stderr).toContain('lowercase'); + expect(result.stderr).toContain('underscores'); + }); + + it('rejects an existing spec with exit 2 and file listing', async () => { + const root = copyFixtureToTemp('v02-existing-kiro-no-state'); + const result = await cli(root, 'spec', 'new', 'user-authentication'); + expect(result.code).toBe(2); + expect(result.stderr).toContain('already exists'); + expect(result.stderr).toContain('requirements.md'); + }); + + it('rejects unknown --type and --mode values', async () => { + const root = freshKiroDir(); + expect((await cli(root, 'spec', 'new', 'x-spec', '--type', 'epic')).code).toBe(2); + expect((await cli(root, 'spec', 'new', 'x-spec', '--mode', 'yolo')).code).toBe(2); + }); +}); + +describe('spec analyze (CLI)', () => { + it('exits 0 on a valid stage', async () => { + const root = copyFixtureToTemp('v02-requirements-first'); + const result = await cli(root, 'spec', 'analyze', 'notification-preferences', '--stage', 'requirements'); + expect(result.code).toBe(0); + expect(result.stdout).toContain('Result: OK'); + }); + + it('exits 1 on placeholder-heavy content with error findings', async () => { + const root = copyFixtureToTemp('v02-placeholder-heavy'); + const result = await cli(root, 'spec', 'analyze', 'notification-preferences', '--stage', 'requirements'); + expect(result.code).toBe(1); + expect(result.stdout).toContain('Placeholder content'); + expect(result.stdout).toContain('Result: FAIL'); + }); + + it('strict mode turns warnings into failure (test 50)', async () => { + const root = copyFixtureToTemp('v02-invalid-ears'); + const relaxed = await cli(root, 'spec', 'analyze', 'session-timeout', '--stage', 'requirements'); + expect(relaxed.code).toBe(0); + const strict = await cli(root, 'spec', 'analyze', 'session-timeout', '--stage', 'requirements', '--strict'); + expect(strict.code).toBe(1); + expect(strict.stdout).toContain('strict mode'); + }); + + it('produces machine-readable JSON with per-stage findings', async () => { + const root = copyFixtureToTemp('v02-invalid-ears'); + const result = await cli(root, 'spec', 'analyze', 'session-timeout', '--json'); + const report = JSON.parse(result.stdout) as { + schema: string; + data: { stages: { stage: string; diagnostics: { code: string }[] }[]; failed: boolean }; + }; + expect(report.schema).toBe('specbridge.spec-analyze/1'); + const requirements = report.data.stages.find((s) => s.stage === 'requirements'); + expect(requirements?.diagnostics.some((d) => d.code === 'REQUIREMENTS_EARS_MALFORMED')).toBe(true); + }); + + it('rejects stages that do not apply to the spec type', async () => { + const root = copyFixtureToTemp('v02-bugfix-spec'); + const result = await cli(root, 'spec', 'analyze', 'login-timeout-fix', '--stage', 'requirements'); + expect(result.code).toBe(2); + expect(result.stderr).toContain('does not apply'); + }); +}); + +describe('spec approve and status (CLI)', () => { + it('walks a full requirements-first workflow with stale detection', async () => { + const root = freshKiroDir(); + await cli(root, 'spec', 'new', 'demo-spec'); + const requirementsPath = path.join(root, '.kiro', 'specs', 'demo-spec', 'requirements.md'); + + // Placeholders block the first approval attempt (exit 1). + const blocked = await cli(root, 'spec', 'approve', 'demo-spec', '--stage', 'requirements'); + expect(blocked.code).toBe(1); + expect(blocked.stderr).toContain('Cannot approve requirements'); + + writeFileSync(requirementsPath, VALID_REQUIREMENTS); + const approved = await cli(root, 'spec', 'approve', 'demo-spec', '--stage', 'requirements'); + expect(approved.code).toBe(0); + expect(approved.stdout).toContain('requirements approved'); + expect(approved.stdout).toContain('Status: DESIGN_DRAFT'); + + // Approving out of order names the missing prerequisite (exit 1). + const tasksTooEarly = await cli(root, 'spec', 'approve', 'demo-spec', '--stage', 'tasks'); + expect(tasksTooEarly.code).toBe(1); + expect(tasksTooEarly.stderr).toContain('design'); + expect(tasksTooEarly.stderr).toContain('spec approve demo-spec --stage design'); + + // Status shows the healthy approval. + const status = await cli(root, 'spec', 'status', 'demo-spec'); + expect(status.code).toBe(0); + expect(status.stdout).toContain('Status: DESIGN_DRAFT'); + expect(status.stdout).toContain('Content unchanged since approval'); + + // One byte changes -> stale. + writeFileSync(requirementsPath, `${VALID_REQUIREMENTS}x`); + const stale = await cli(root, 'spec', 'status', 'demo-spec'); + expect(stale.stdout).toContain('Status: STALE_APPROVAL'); + expect(stale.stdout).toContain('Modified after approval'); + expect(stale.stdout).toContain('Approved hash:'); + expect(stale.stdout).toContain('Current hash:'); + + // Read-only commands must not repair the state silently. + const stateBytes = readFileSync( + path.join(root, '.specbridge', 'state', 'specs', 'demo-spec.json'), + ); + await cli(root, 'spec', 'list'); + await cli(root, 'doctor'); + expect( + readFileSync(path.join(root, '.specbridge', 'state', 'specs', 'demo-spec.json')).equals(stateBytes), + ).toBe(true); + + // Reapproval repairs it. + const reapproved = await cli(root, 'spec', 'approve', 'demo-spec', '--stage', 'requirements'); + expect(reapproved.code).toBe(0); + const healthy = await cli(root, 'spec', 'status', 'demo-spec'); + expect(healthy.stdout).toContain('Status: DESIGN_DRAFT'); + }); + + it('revoke reports invalidated dependents', async () => { + const root = copyFixtureToTemp('v02-bugfix-spec'); + const revoked = await cli(root, 'spec', 'approve', 'login-timeout-fix', '--stage', 'bugfix', '--revoke'); + expect(revoked.code).toBe(0); + expect(revoked.stdout).toContain('bugfix approval revoked'); + const status = await cli(root, 'spec', 'status', 'login-timeout-fix'); + expect(status.stdout).toContain('Status: BUGFIX_DRAFT'); + }); + + it('reports unmanaged specs with a suggested first approval', async () => { + const root = copyFixtureToTemp('v02-existing-kiro-no-state'); + const result = await cli(root, 'spec', 'status', 'user-authentication'); + expect(result.code).toBe(0); + expect(result.stdout).toContain('Approval state: unmanaged'); + expect(result.stdout).toContain('spec approve user-authentication --stage requirements'); + }); + + it('initializes state on the first approval of an existing Kiro spec', async () => { + const root = copyFixtureToTemp('v02-existing-kiro-no-state'); + const result = await cli(root, 'spec', 'approve', 'user-authentication', '--stage', 'requirements'); + expect(result.code).toBe(0); + expect(result.stdout).toContain('Sidecar state initialized'); + const status = await cli(root, 'spec', 'status', 'user-authentication'); + expect(status.stdout).toContain('Origin: initialized from an existing Kiro workspace'); + }); + + it('emits versioned JSON from spec status', async () => { + const root = copyFixtureToTemp('v02-stale-requirements-approval'); + const result = await cli(root, 'spec', 'status', 'payment-retry', '--json'); + const report = JSON.parse(result.stdout) as { + schema: string; + data: { + approvalHealth: string; + effectiveStatus: string; + stages: { stage: string; effective: string }[]; + }; + }; + expect(report.schema).toBe('specbridge.spec-status/1'); + expect(report.data.approvalHealth).toBe('stale'); + expect(report.data.effectiveStatus).toBe('STALE_APPROVAL'); + expect(report.data.stages.find((s) => s.stage === 'requirements')?.effective).toBe( + 'modified-after-approval', + ); + expect(report.data.stages.find((s) => s.stage === 'design')?.effective).toBe('stale-prerequisite'); + }); + + it('rejects unknown stages and inapplicable stages with exit 2', async () => { + const root = copyFixtureToTemp('v02-bugfix-spec'); + expect((await cli(root, 'spec', 'approve', 'login-timeout-fix', '--stage', 'nope')).code).toBe(2); + expect( + (await cli(root, 'spec', 'approve', 'login-timeout-fix', '--stage', 'requirements')).code, + ).toBe(2); + }); +}); + +describe('spec list / show / doctor extensions (CLI)', () => { + it('spec list shows MODE and STATUS columns including STALE_APPROVAL', async () => { + const root = copyFixtureToTemp('v02-stale-requirements-approval'); + const result = await cli(root, 'spec', 'list'); + expect(result.code).toBe(0); + expect(result.stdout).toContain('MODE'); + expect(result.stdout).toContain('STATUS'); + expect(result.stdout).toContain('requirements-first'); + expect(result.stdout).toContain('STALE_APPROVAL'); + }); + + it('spec list marks unmanaged specs', async () => { + const root = copyFixtureToTemp('v02-existing-kiro-no-state'); + const result = await cli(root, 'spec', 'list'); + expect(result.stdout).toContain('unmanaged'); + const json = await cli(root, 'spec', 'list', '--json'); + const report = JSON.parse(json.stdout) as { + data: { specs: { name: string; approvalHealth: string; managed: boolean }[] }; + }; + expect(report.data.specs.every((s) => s.approvalHealth === 'unmanaged' && !s.managed)).toBe(true); + }); + + it('spec show --state prints the sidecar state JSON', async () => { + const root = copyFixtureToTemp('v02-requirements-first'); + const result = await cli(root, 'spec', 'show', 'notification-preferences', '--state'); + expect(result.code).toBe(0); + const state = JSON.parse(result.stdout) as { schemaVersion: string; specName: string }; + expect(state.schemaVersion).toBe('1.0.0'); + expect(state.specName).toBe('notification-preferences'); + }); + + it('spec show --analysis and --status print focused sections', async () => { + const root = copyFixtureToTemp('v02-requirements-first'); + const analysis = await cli(root, 'spec', 'show', 'notification-preferences', '--analysis'); + expect(analysis.code).toBe(0); + expect(analysis.stdout).toContain('errors'); + + const status = await cli(root, 'spec', 'show', 'notification-preferences', '--status'); + expect(status.stdout).toContain('DESIGN_DRAFT'); + }); + + it('doctor reports invalid, orphan, and unmanaged sidecar state without repairing anything', async () => { + const root = copyFixtureToTemp('v02-invalid-sidecar'); + const result = await cli(root, 'doctor'); + expect(result.stdout).toContain('Sidecar state (.specbridge)'); + expect(result.stdout).toContain('sidecar state is invalid'); + // Nothing was repaired or deleted. + expect(readFileSync(path.join(root, '.specbridge', 'state', 'specs', 'broken-state.json'), 'utf8')).toBe( + '{ this is not json\n', + ); + + const orphanRoot = copyFixtureToTemp('v02-orphan-sidecar'); + const orphan = await cli(orphanRoot, 'doctor'); + expect(orphan.stdout).toContain('ghost-spec'); + expect(orphan.stdout).toContain('no matching .kiro/specs'); + expect(orphan.stdout).toContain('unmanaged'); + expect(existsSync(path.join(orphanRoot, '.specbridge', 'state', 'specs', 'ghost-spec.json'))).toBe(true); + }); + + it('doctor JSON includes the sidecar audit block', async () => { + const root = copyFixtureToTemp('v02-stale-requirements-approval'); + const result = await cli(root, 'doctor', '--json'); + const report = JSON.parse(result.stdout) as { + data: { sidecar: { staleSpecs: string[]; unmanagedSpecs: string[] } }; + }; + expect(report.data.sidecar.staleSpecs).toEqual(['payment-retry']); + }); + + it('empty workspaces stay healthy and support spec new (fixture v02-empty-workspace)', async () => { + const root = copyFixtureToTemp('v02-empty-workspace'); + const doctor = await cli(root, 'doctor'); + expect(doctor.code).toBe(0); + const created = await cli(root, 'spec', 'new', 'first-spec'); + expect(created.code).toBe(0); + const list = await cli(root, 'spec', 'list'); + expect(list.stdout).toContain('first-spec'); + }); +}); + +describe('v0.1 regression guarantees (tests 51-55)', () => { + it('manually edited specs remain byte-identical through v0.2 commands (tests 53, 54)', async () => { + const root = copyFixtureToTemp('v02-manually-edited'); + const specDir = path.join(root, '.kiro', 'specs', 'search-filters'); + const before = new Map( + ['requirements.md', 'notes.md'].map((f) => [f, readFileSync(path.join(specDir, f))]), + ); + + await cli(root, 'spec', 'list'); + await cli(root, 'spec', 'show', 'search-filters'); + await cli(root, 'spec', 'analyze', 'search-filters'); + await cli(root, 'spec', 'status', 'search-filters'); + await cli(root, 'doctor'); + + for (const [file, bytes] of before) { + expect(readFileSync(path.join(specDir, file)).equals(bytes)).toBe(true); + } + // Read-only commands never create sidecar state either. + expect(existsSync(path.join(root, '.specbridge'))).toBe(false); + }); + + it('compat check still proves byte-identical round trips on v0.2 fixtures (test 52)', async () => { + for (const fixture of ['v02-manually-edited', 'v02-requirements-first', 'v02-bugfix-spec']) { + const result = await cli(fixturePath(fixture), 'compat', 'check'); + expect(result.code).toBe(0); + expect(result.stdout).toContain('byte-identical'); + } + }); + + it('approval hashing works with Windows-style paths (test 55)', async () => { + // This suite runs on every OS in CI; on Windows the stage file paths in + // sidecar state use forward slashes while the filesystem uses backslashes. + const root = copyFixtureToTemp('v02-requirements-first'); + const status = await cli(root, 'spec', 'status', 'notification-preferences'); + expect(status.stdout).toContain('Content unchanged since approval'); + }); +}); diff --git a/tests/compatibility/spec-classification.test.ts b/tests/compatibility/spec-classification.test.ts index dbabd6a..ec38f44 100644 --- a/tests/compatibility/spec-classification.test.ts +++ b/tests/compatibility/spec-classification.test.ts @@ -1,8 +1,7 @@ import { describe, expect, it } from 'vitest'; -import type { SpecState } from '@specbridge/core'; import { resolveWorkspace } from '@specbridge/core'; import { classifySpec, findSpec } from '@specbridge/compat-kiro'; -import { fixturePath } from '../helpers.js'; +import { fixturePath, testWorkflowState } from '../helpers.js'; function folderOf(fixture: string, spec: string) { const ws = resolveWorkspace(fixturePath(fixture))!; @@ -25,12 +24,11 @@ describe('spec classification', () => { }); it('uses sidecar state for the workflow mode when available', () => { - const state: SpecState = { + const state = testWorkflowState({ specName: 'user-authentication', - specType: 'feature', workflowMode: 'design-first', status: 'DESIGN_APPROVED', - }; + }); const result = classifySpec(folderOf('standard-feature', 'user-authentication'), state); expect(result.workflowMode).toBe('design-first'); }); @@ -61,12 +59,12 @@ describe('spec classification', () => { }); it('flags sidecar/file-layout type mismatches instead of guessing', () => { - const state: SpecState = { + const state = testWorkflowState({ specName: 'login-timeout-fix', specType: 'feature', workflowMode: 'quick', - status: 'DRAFT', - }; + status: 'READY_FOR_REVIEW', + }); const result = classifySpec(folderOf('bugfix-spec', 'login-timeout-fix'), state); expect(result.type).toBe('bugfix'); expect(result.diagnostics.some((d) => d.code === 'SIDECAR_TYPE_MISMATCH')).toBe(true); diff --git a/tests/drift/evidence-and-state.test.ts b/tests/drift/evidence-and-state.test.ts index cad9088..c434f76 100644 --- a/tests/drift/evidence-and-state.test.ts +++ b/tests/drift/evidence-and-state.test.ts @@ -1,10 +1,10 @@ import { writeFileSync } from 'node:fs'; import path from 'node:path'; import { describe, expect, it } from 'vitest'; -import type { SpecState, WorkspaceInfo } from '@specbridge/core'; +import type { WorkspaceInfo } from '@specbridge/core'; import { readSpecState, writeSpecState } from '@specbridge/core'; import { listTaskEvidence, writeTaskEvidence } from '@specbridge/drift'; -import { emptyTempDir } from '../helpers.js'; +import { emptyTempDir, testWorkflowState } from '../helpers.js'; function tempWorkspace(): WorkspaceInfo { const rootDir = emptyTempDir(); @@ -19,17 +19,19 @@ function tempWorkspace(): WorkspaceInfo { describe('sidecar state and evidence storage', () => { it('round-trips spec state through .specbridge/state/specs/<name>.json', () => { const workspace = tempWorkspace(); - const state: SpecState = { + const state = testWorkflowState({ specName: 'notification-preferences', - specType: 'feature', workflowMode: 'requirements-first', - status: 'DESIGN_APPROVED', - approvals: { - requirements: { approved: true, approvedAt: '2026-07-01T10:00:00Z' }, - design: { approved: true, approvedAt: '2026-07-02T09:30:00Z' }, + status: 'DESIGN_DRAFT', + stages: { + requirements: { + status: 'approved', + approvedAt: '2026-07-01T10:00:00.000Z', + approvedHash: 'a'.repeat(64), + }, + design: { status: 'draft' }, }, - declaredImpactAreas: ['src/notifications/**'], - }; + }); const statePath = writeSpecState(workspace, state); expect(statePath).toContain(path.join('.specbridge', 'state', 'specs')); @@ -39,19 +41,15 @@ describe('sidecar state and evidence storage', () => { expect(read.state).toMatchObject({ specName: 'notification-preferences', workflowMode: 'requirements-first', - status: 'DESIGN_APPROVED', + status: 'DESIGN_DRAFT', + schemaVersion: '1.0.0', }); + expect(read.state?.stages.requirements?.approvedHash).toBe('a'.repeat(64)); }); it('degrades invalid sidecar state to a diagnostic instead of crashing', () => { const workspace = tempWorkspace(); - // Write a structurally invalid state file by hand. - writeSpecState(workspace, { - specName: 'broken', - specType: 'feature', - workflowMode: 'quick', - status: 'DRAFT', - }); + writeSpecState(workspace, testWorkflowState({ specName: 'broken' })); writeFileSync(path.join(workspace.sidecarDir, 'state', 'specs', 'broken.json'), '{not json'); const read = readSpecState(workspace, 'broken'); expect(read.state).toBeUndefined(); diff --git a/tests/fixtures/v02-bugfix-spec/.kiro/specs/login-timeout-fix/bugfix.md b/tests/fixtures/v02-bugfix-spec/.kiro/specs/login-timeout-fix/bugfix.md new file mode 100644 index 0000000..8ecfbf9 --- /dev/null +++ b/tests/fixtures/v02-bugfix-spec/.kiro/specs/login-timeout-fix/bugfix.md @@ -0,0 +1,42 @@ +# Bugfix Document + +## Summary + +**Login Timeout Fix** + +Login sessions expire after 5 minutes instead of the configured 30 minutes. + +## Current Behavior + +Users are logged out after roughly 5 minutes of inactivity even though the +session timeout is configured as 30 minutes. + +## Expected Behavior + +Sessions stay valid for the configured 30 minutes of inactivity and refresh +on activity. + +## Unchanged Behavior + +- Explicit logout still ends the session immediately. +- Password changes still invalidate all sessions. + +## Reproduction + +1. Log in with any account. +2. Wait 6 minutes without interacting. +3. Click any authenticated link: the app redirects to the login page. + +## Evidence + +- Logs: session-service rejects tokens with "exp claim in the past". +- Failing tests: SessionServiceTest.testTimeoutHonorsConfiguration. +- Relevant source locations: src/auth/session-service.ts. + +## Constraints + +- The fix must not invalidate existing active sessions on deploy. + +## Regression Risks + +- Token refresh flow: refreshing must not extend beyond the absolute cap. diff --git a/tests/fixtures/v02-bugfix-spec/.kiro/specs/login-timeout-fix/design.md b/tests/fixtures/v02-bugfix-spec/.kiro/specs/login-timeout-fix/design.md new file mode 100644 index 0000000..a6923d6 --- /dev/null +++ b/tests/fixtures/v02-bugfix-spec/.kiro/specs/login-timeout-fix/design.md @@ -0,0 +1,32 @@ +# Fix Design + +## Root Cause + +The session TTL is read from `config.sessionTimeoutSeconds` but the token +issuer treats the value as minutes, multiplying it once more downstream. + +## Proposed Fix + +Read the TTL once, in seconds, in the token issuer; delete the second +conversion in the middleware. + +## Affected Components + +- src/auth/session-service.ts +- src/auth/middleware.ts + +## Failure Handling + +- If the config value is missing, fall back to 30 minutes and log a warning. + +## Alternatives Considered + +- Converting in the middleware instead: rejected, the issuer owns the claim. + +## Regression Protection + +- New regression test pinning a 30-minute expiry claim. + +## Validation Strategy + +- Run the auth test suite and a manual timeout check in staging. diff --git a/tests/fixtures/v02-bugfix-spec/.kiro/specs/login-timeout-fix/tasks.md b/tests/fixtures/v02-bugfix-spec/.kiro/specs/login-timeout-fix/tasks.md new file mode 100644 index 0000000..c3ff7d9 --- /dev/null +++ b/tests/fixtures/v02-bugfix-spec/.kiro/specs/login-timeout-fix/tasks.md @@ -0,0 +1,9 @@ +# Bugfix Implementation Plan + +- [ ] 1. Reproduce the bug with deterministic evidence. +- [ ] 2. Confirm the root cause. +- [ ] 3. Implement the smallest safe fix. +- [ ] 4. Add regression tests. +- [ ] 5. Verify unchanged behavior. +- [ ] 6. Run the required validation checks. +- [ ] 7. Document remaining risks. diff --git a/tests/fixtures/v02-bugfix-spec/.specbridge/state/specs/login-timeout-fix.json b/tests/fixtures/v02-bugfix-spec/.specbridge/state/specs/login-timeout-fix.json new file mode 100644 index 0000000..aa30723 --- /dev/null +++ b/tests/fixtures/v02-bugfix-spec/.specbridge/state/specs/login-timeout-fix.json @@ -0,0 +1,30 @@ +{ + "schemaVersion": "1.0.0", + "specName": "login-timeout-fix", + "specType": "bugfix", + "workflowMode": "requirements-first", + "origin": "existing-kiro-workspace", + "status": "DESIGN_DRAFT", + "createdAt": "2026-07-02T09:00:00.000Z", + "updatedAt": "2026-07-02T09:30:00.000Z", + "stages": { + "bugfix": { + "status": "approved", + "file": ".kiro/specs/login-timeout-fix/bugfix.md", + "approvedAt": "2026-07-02T09:30:00.000Z", + "approvedHash": "9a2e1c59769d0a6f03c7c8e2c9ebf5fab63ad9629e545c34923c4402400ad037" + }, + "design": { + "status": "draft", + "file": ".kiro/specs/login-timeout-fix/design.md", + "approvedAt": null, + "approvedHash": null + }, + "tasks": { + "status": "blocked", + "file": ".kiro/specs/login-timeout-fix/tasks.md", + "approvedAt": null, + "approvedHash": null + } + } +} diff --git a/tests/fixtures/v02-design-first/.kiro/specs/export-pipeline/design.md b/tests/fixtures/v02-design-first/.kiro/specs/export-pipeline/design.md new file mode 100644 index 0000000..af670f2 --- /dev/null +++ b/tests/fixtures/v02-design-first/.kiro/specs/export-pipeline/design.md @@ -0,0 +1,46 @@ +# Design Document + +## Overview + +Export pipeline that streams report data to object storage. + +## Architecture + +A queue-backed worker reads export jobs and streams rows to a storage +adapter. Backpressure is handled by the queue, not in memory. + +## Components and Interfaces + +- ExportScheduler: enqueues jobs (`schedule(jobSpec): JobId`). +- ExportWorker: consumes jobs, streams rows. +- StorageAdapter interface: `putStream(key, stream)`. + +## Data Model + +- ExportJob: id, reportId, requestedBy, format, state. + +## Failure Handling + +- Worker crash: the job lease expires and another worker resumes it. +- Storage unavailable: exponential backoff, job fails after 5 attempts. + +## Security Considerations + +- Signed URLs expire after 15 minutes; exports are private by default. + +## Observability + +- Metrics: export duration, failure count, queue depth. + +## Testing Strategy + +- Unit tests for the scheduler and adapters; integration test with a fake + storage backend; regression test for resumed jobs. + +## Risks and Trade-offs + +- Streaming keeps memory flat but makes retries coarser (whole-job retry). + +## Alternatives Considered + +- Direct synchronous export from the API process: rejected, ties up web workers. diff --git a/tests/fixtures/v02-design-first/.kiro/specs/export-pipeline/requirements.md b/tests/fixtures/v02-design-first/.kiro/specs/export-pipeline/requirements.md new file mode 100644 index 0000000..1512024 --- /dev/null +++ b/tests/fixtures/v02-design-first/.kiro/specs/export-pipeline/requirements.md @@ -0,0 +1,7 @@ +# Requirements Document + +> Status: Pending design review. + +## Introduction + +Requirements will be derived and validated after the initial design review. diff --git a/tests/fixtures/v02-design-first/.kiro/specs/export-pipeline/tasks.md b/tests/fixtures/v02-design-first/.kiro/specs/export-pipeline/tasks.md new file mode 100644 index 0000000..0a8b624 --- /dev/null +++ b/tests/fixtures/v02-design-first/.kiro/specs/export-pipeline/tasks.md @@ -0,0 +1,5 @@ +# Implementation Plan + +> Status: Pending requirements and design approval. + +- [ ] Define implementation tasks after requirements and design approval. diff --git a/tests/fixtures/v02-design-first/.specbridge/state/specs/export-pipeline.json b/tests/fixtures/v02-design-first/.specbridge/state/specs/export-pipeline.json new file mode 100644 index 0000000..436c70f --- /dev/null +++ b/tests/fixtures/v02-design-first/.specbridge/state/specs/export-pipeline.json @@ -0,0 +1,30 @@ +{ + "schemaVersion": "1.0.0", + "specName": "export-pipeline", + "specType": "feature", + "workflowMode": "design-first", + "origin": "created-by-specbridge", + "status": "REQUIREMENTS_DRAFT", + "createdAt": "2026-06-20T14:00:00.000Z", + "updatedAt": "2026-06-20T15:00:00.000Z", + "stages": { + "design": { + "status": "approved", + "file": ".kiro/specs/export-pipeline/design.md", + "approvedAt": "2026-06-20T15:00:00.000Z", + "approvedHash": "1254a27087fb61d4a2069b6696b0e12aced9c942f52b5317c6c609a40bc625cc" + }, + "requirements": { + "status": "draft", + "file": ".kiro/specs/export-pipeline/requirements.md", + "approvedAt": null, + "approvedHash": null + }, + "tasks": { + "status": "blocked", + "file": ".kiro/specs/export-pipeline/tasks.md", + "approvedAt": null, + "approvedHash": null + } + } +} diff --git a/tests/fixtures/v02-empty-workspace/.kiro/.gitkeep b/tests/fixtures/v02-empty-workspace/.kiro/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/fixtures/v02-existing-kiro-no-state/.kiro/specs/login-timeout-fix/bugfix.md b/tests/fixtures/v02-existing-kiro-no-state/.kiro/specs/login-timeout-fix/bugfix.md new file mode 100644 index 0000000..8ecfbf9 --- /dev/null +++ b/tests/fixtures/v02-existing-kiro-no-state/.kiro/specs/login-timeout-fix/bugfix.md @@ -0,0 +1,42 @@ +# Bugfix Document + +## Summary + +**Login Timeout Fix** + +Login sessions expire after 5 minutes instead of the configured 30 minutes. + +## Current Behavior + +Users are logged out after roughly 5 minutes of inactivity even though the +session timeout is configured as 30 minutes. + +## Expected Behavior + +Sessions stay valid for the configured 30 minutes of inactivity and refresh +on activity. + +## Unchanged Behavior + +- Explicit logout still ends the session immediately. +- Password changes still invalidate all sessions. + +## Reproduction + +1. Log in with any account. +2. Wait 6 minutes without interacting. +3. Click any authenticated link: the app redirects to the login page. + +## Evidence + +- Logs: session-service rejects tokens with "exp claim in the past". +- Failing tests: SessionServiceTest.testTimeoutHonorsConfiguration. +- Relevant source locations: src/auth/session-service.ts. + +## Constraints + +- The fix must not invalidate existing active sessions on deploy. + +## Regression Risks + +- Token refresh flow: refreshing must not extend beyond the absolute cap. diff --git a/tests/fixtures/v02-existing-kiro-no-state/.kiro/specs/login-timeout-fix/design.md b/tests/fixtures/v02-existing-kiro-no-state/.kiro/specs/login-timeout-fix/design.md new file mode 100644 index 0000000..a6923d6 --- /dev/null +++ b/tests/fixtures/v02-existing-kiro-no-state/.kiro/specs/login-timeout-fix/design.md @@ -0,0 +1,32 @@ +# Fix Design + +## Root Cause + +The session TTL is read from `config.sessionTimeoutSeconds` but the token +issuer treats the value as minutes, multiplying it once more downstream. + +## Proposed Fix + +Read the TTL once, in seconds, in the token issuer; delete the second +conversion in the middleware. + +## Affected Components + +- src/auth/session-service.ts +- src/auth/middleware.ts + +## Failure Handling + +- If the config value is missing, fall back to 30 minutes and log a warning. + +## Alternatives Considered + +- Converting in the middleware instead: rejected, the issuer owns the claim. + +## Regression Protection + +- New regression test pinning a 30-minute expiry claim. + +## Validation Strategy + +- Run the auth test suite and a manual timeout check in staging. diff --git a/tests/fixtures/v02-existing-kiro-no-state/.kiro/specs/login-timeout-fix/tasks.md b/tests/fixtures/v02-existing-kiro-no-state/.kiro/specs/login-timeout-fix/tasks.md new file mode 100644 index 0000000..c3ff7d9 --- /dev/null +++ b/tests/fixtures/v02-existing-kiro-no-state/.kiro/specs/login-timeout-fix/tasks.md @@ -0,0 +1,9 @@ +# Bugfix Implementation Plan + +- [ ] 1. Reproduce the bug with deterministic evidence. +- [ ] 2. Confirm the root cause. +- [ ] 3. Implement the smallest safe fix. +- [ ] 4. Add regression tests. +- [ ] 5. Verify unchanged behavior. +- [ ] 6. Run the required validation checks. +- [ ] 7. Document remaining risks. diff --git a/tests/fixtures/v02-existing-kiro-no-state/.kiro/specs/user-authentication/design.md b/tests/fixtures/v02-existing-kiro-no-state/.kiro/specs/user-authentication/design.md new file mode 100644 index 0000000..af670f2 --- /dev/null +++ b/tests/fixtures/v02-existing-kiro-no-state/.kiro/specs/user-authentication/design.md @@ -0,0 +1,46 @@ +# Design Document + +## Overview + +Export pipeline that streams report data to object storage. + +## Architecture + +A queue-backed worker reads export jobs and streams rows to a storage +adapter. Backpressure is handled by the queue, not in memory. + +## Components and Interfaces + +- ExportScheduler: enqueues jobs (`schedule(jobSpec): JobId`). +- ExportWorker: consumes jobs, streams rows. +- StorageAdapter interface: `putStream(key, stream)`. + +## Data Model + +- ExportJob: id, reportId, requestedBy, format, state. + +## Failure Handling + +- Worker crash: the job lease expires and another worker resumes it. +- Storage unavailable: exponential backoff, job fails after 5 attempts. + +## Security Considerations + +- Signed URLs expire after 15 minutes; exports are private by default. + +## Observability + +- Metrics: export duration, failure count, queue depth. + +## Testing Strategy + +- Unit tests for the scheduler and adapters; integration test with a fake + storage backend; regression test for resumed jobs. + +## Risks and Trade-offs + +- Streaming keeps memory flat but makes retries coarser (whole-job retry). + +## Alternatives Considered + +- Direct synchronous export from the API process: rejected, ties up web workers. diff --git a/tests/fixtures/v02-existing-kiro-no-state/.kiro/specs/user-authentication/requirements.md b/tests/fixtures/v02-existing-kiro-no-state/.kiro/specs/user-authentication/requirements.md new file mode 100644 index 0000000..1f6d86a --- /dev/null +++ b/tests/fixtures/v02-existing-kiro-no-state/.kiro/specs/user-authentication/requirements.md @@ -0,0 +1,41 @@ +# Requirements Document + +## Introduction + +**Notification Preferences** + +Allow users to select email and push notification preferences. + +## Requirements + +### Requirement 1: Channel selection + +**User Story:** As a registered user, I want to choose which channels deliver notifications, so that I only receive them where I want. + +#### Acceptance Criteria + +1. WHEN a user saves channel preferences, THE SYSTEM SHALL persist the selection within 2 seconds. +2. IF the preferences service is unavailable, THEN THE SYSTEM SHALL keep the previous preferences and display a retry option. +3. WHEN a notification is sent, THE SYSTEM SHALL deliver it only through channels the user enabled. + +### Requirement 2: Default preferences + +**User Story:** As a new user, I want sensible notification defaults, so that I receive important messages without setup. + +#### Acceptance Criteria + +1. WHEN an account is created, THE SYSTEM SHALL enable email notifications and disable push notifications. +2. IF a notification category is mandatory, THEN THE SYSTEM SHALL deliver it regardless of user preferences. + +## Non-Functional Requirements + +- Performance: preference reads add at most 50 ms to notification dispatch. +- Security: preferences are readable and writable only by the owning user. + +## Edge Cases + +- A user disables every channel: mandatory notifications still deliver by email. + +## Out of Scope + +- Digest scheduling and quiet hours. diff --git a/tests/fixtures/v02-existing-kiro-no-state/.kiro/specs/user-authentication/tasks.md b/tests/fixtures/v02-existing-kiro-no-state/.kiro/specs/user-authentication/tasks.md new file mode 100644 index 0000000..df2a6f5 --- /dev/null +++ b/tests/fixtures/v02-existing-kiro-no-state/.kiro/specs/user-authentication/tasks.md @@ -0,0 +1,11 @@ +# Implementation Plan + +- [ ] 1. Implement the preferences data model + - _Requirements: 1.1, 2.1_ +- [ ] 2. Build the preferences service endpoint + - _Requirements: 1.1, 1.2_ +- [ ] 3. Wire notification dispatch to preference checks + - _Requirements: 1.3, 2.2_ +- [ ] 4. Add automated tests for acceptance criteria + - _Requirements: 1.1, 1.2, 1.3, 2.1, 2.2_ +- [ ] 5. Verify error handling and edge cases diff --git a/tests/fixtures/v02-invalid-ears/.kiro/specs/session-timeout/requirements.md b/tests/fixtures/v02-invalid-ears/.kiro/specs/session-timeout/requirements.md new file mode 100644 index 0000000..973c6cb --- /dev/null +++ b/tests/fixtures/v02-invalid-ears/.kiro/specs/session-timeout/requirements.md @@ -0,0 +1,18 @@ +# Requirements Document + +## Introduction + +Session timeout handling, written in a hurry. + +## Requirements + +### Requirement 1: Timeout enforcement + +**User Story:** As a security officer, I want idle sessions to expire, so that sessions are safe. + +#### Acceptance Criteria + +1. WHEN a session is idle for 30 minutes. +2. The feature works correctly for all users. +3. Handle expired tokens gracefully. +4. IF a request arrives with an expired token the request is turned away somehow. diff --git a/tests/fixtures/v02-invalid-sidecar/.kiro/specs/broken-state/requirements.md b/tests/fixtures/v02-invalid-sidecar/.kiro/specs/broken-state/requirements.md new file mode 100644 index 0000000..1f6d86a --- /dev/null +++ b/tests/fixtures/v02-invalid-sidecar/.kiro/specs/broken-state/requirements.md @@ -0,0 +1,41 @@ +# Requirements Document + +## Introduction + +**Notification Preferences** + +Allow users to select email and push notification preferences. + +## Requirements + +### Requirement 1: Channel selection + +**User Story:** As a registered user, I want to choose which channels deliver notifications, so that I only receive them where I want. + +#### Acceptance Criteria + +1. WHEN a user saves channel preferences, THE SYSTEM SHALL persist the selection within 2 seconds. +2. IF the preferences service is unavailable, THEN THE SYSTEM SHALL keep the previous preferences and display a retry option. +3. WHEN a notification is sent, THE SYSTEM SHALL deliver it only through channels the user enabled. + +### Requirement 2: Default preferences + +**User Story:** As a new user, I want sensible notification defaults, so that I receive important messages without setup. + +#### Acceptance Criteria + +1. WHEN an account is created, THE SYSTEM SHALL enable email notifications and disable push notifications. +2. IF a notification category is mandatory, THEN THE SYSTEM SHALL deliver it regardless of user preferences. + +## Non-Functional Requirements + +- Performance: preference reads add at most 50 ms to notification dispatch. +- Security: preferences are readable and writable only by the owning user. + +## Edge Cases + +- A user disables every channel: mandatory notifications still deliver by email. + +## Out of Scope + +- Digest scheduling and quiet hours. diff --git a/tests/fixtures/v02-invalid-sidecar/.kiro/specs/legacy-shape/requirements.md b/tests/fixtures/v02-invalid-sidecar/.kiro/specs/legacy-shape/requirements.md new file mode 100644 index 0000000..1f6d86a --- /dev/null +++ b/tests/fixtures/v02-invalid-sidecar/.kiro/specs/legacy-shape/requirements.md @@ -0,0 +1,41 @@ +# Requirements Document + +## Introduction + +**Notification Preferences** + +Allow users to select email and push notification preferences. + +## Requirements + +### Requirement 1: Channel selection + +**User Story:** As a registered user, I want to choose which channels deliver notifications, so that I only receive them where I want. + +#### Acceptance Criteria + +1. WHEN a user saves channel preferences, THE SYSTEM SHALL persist the selection within 2 seconds. +2. IF the preferences service is unavailable, THEN THE SYSTEM SHALL keep the previous preferences and display a retry option. +3. WHEN a notification is sent, THE SYSTEM SHALL deliver it only through channels the user enabled. + +### Requirement 2: Default preferences + +**User Story:** As a new user, I want sensible notification defaults, so that I receive important messages without setup. + +#### Acceptance Criteria + +1. WHEN an account is created, THE SYSTEM SHALL enable email notifications and disable push notifications. +2. IF a notification category is mandatory, THEN THE SYSTEM SHALL deliver it regardless of user preferences. + +## Non-Functional Requirements + +- Performance: preference reads add at most 50 ms to notification dispatch. +- Security: preferences are readable and writable only by the owning user. + +## Edge Cases + +- A user disables every channel: mandatory notifications still deliver by email. + +## Out of Scope + +- Digest scheduling and quiet hours. diff --git a/tests/fixtures/v02-invalid-sidecar/.kiro/specs/wrong-shape/requirements.md b/tests/fixtures/v02-invalid-sidecar/.kiro/specs/wrong-shape/requirements.md new file mode 100644 index 0000000..1f6d86a --- /dev/null +++ b/tests/fixtures/v02-invalid-sidecar/.kiro/specs/wrong-shape/requirements.md @@ -0,0 +1,41 @@ +# Requirements Document + +## Introduction + +**Notification Preferences** + +Allow users to select email and push notification preferences. + +## Requirements + +### Requirement 1: Channel selection + +**User Story:** As a registered user, I want to choose which channels deliver notifications, so that I only receive them where I want. + +#### Acceptance Criteria + +1. WHEN a user saves channel preferences, THE SYSTEM SHALL persist the selection within 2 seconds. +2. IF the preferences service is unavailable, THEN THE SYSTEM SHALL keep the previous preferences and display a retry option. +3. WHEN a notification is sent, THE SYSTEM SHALL deliver it only through channels the user enabled. + +### Requirement 2: Default preferences + +**User Story:** As a new user, I want sensible notification defaults, so that I receive important messages without setup. + +#### Acceptance Criteria + +1. WHEN an account is created, THE SYSTEM SHALL enable email notifications and disable push notifications. +2. IF a notification category is mandatory, THEN THE SYSTEM SHALL deliver it regardless of user preferences. + +## Non-Functional Requirements + +- Performance: preference reads add at most 50 ms to notification dispatch. +- Security: preferences are readable and writable only by the owning user. + +## Edge Cases + +- A user disables every channel: mandatory notifications still deliver by email. + +## Out of Scope + +- Digest scheduling and quiet hours. diff --git a/tests/fixtures/v02-invalid-sidecar/.specbridge/state/specs/broken-state.json b/tests/fixtures/v02-invalid-sidecar/.specbridge/state/specs/broken-state.json new file mode 100644 index 0000000..4c68736 --- /dev/null +++ b/tests/fixtures/v02-invalid-sidecar/.specbridge/state/specs/broken-state.json @@ -0,0 +1 @@ +{ this is not json diff --git a/tests/fixtures/v02-invalid-sidecar/.specbridge/state/specs/legacy-shape.json b/tests/fixtures/v02-invalid-sidecar/.specbridge/state/specs/legacy-shape.json new file mode 100644 index 0000000..96889a7 --- /dev/null +++ b/tests/fixtures/v02-invalid-sidecar/.specbridge/state/specs/legacy-shape.json @@ -0,0 +1,12 @@ +{ + "specName": "legacy-shape", + "specType": "feature", + "workflowMode": "requirements-first", + "status": "DRAFT", + "approvals": { + "requirements": { + "approved": true, + "approvedAt": "2026-07-01T10:00:00Z" + } + } +} diff --git a/tests/fixtures/v02-invalid-sidecar/.specbridge/state/specs/wrong-shape.json b/tests/fixtures/v02-invalid-sidecar/.specbridge/state/specs/wrong-shape.json new file mode 100644 index 0000000..7b68c2d --- /dev/null +++ b/tests/fixtures/v02-invalid-sidecar/.specbridge/state/specs/wrong-shape.json @@ -0,0 +1,5 @@ +{ + "schemaVersion": "1.0.0", + "specName": "wrong-shape", + "unexpected": true +} diff --git a/tests/fixtures/v02-manually-edited/.kiro/specs/search-filters/notes.md b/tests/fixtures/v02-manually-edited/.kiro/specs/search-filters/notes.md new file mode 100644 index 0000000..7c4d486 --- /dev/null +++ b/tests/fixtures/v02-manually-edited/.kiro/specs/search-filters/notes.md @@ -0,0 +1,4 @@ +# Working notes + +Free-form notes that are not a recognized spec file. SpecBridge lists this +file and never parses or modifies it. diff --git a/tests/fixtures/v02-manually-edited/.kiro/specs/search-filters/requirements.md b/tests/fixtures/v02-manually-edited/.kiro/specs/search-filters/requirements.md new file mode 100644 index 0000000..a0c5723 --- /dev/null +++ b/tests/fixtures/v02-manually-edited/.kiro/specs/search-filters/requirements.md @@ -0,0 +1,37 @@ +# Search Filters — Requirements + +## Background + +Written by hand, with custom section names and bullet criteria. SpecBridge +must read this without complaining about structure it does not require. + +## Requirements + +### Requirement 1: Filter persistence + +**User story:** As a shopper, I want my filters to survive navigation, so that I do not re-enter them on every page. + +#### Acceptance criteria + +- WHEN a shopper applies a filter and opens a product, THE SYSTEM SHALL restore the filter on return to the list. +- IF the stored filter references a removed category, THEN THE SYSTEM SHALL drop only that filter and keep the rest. + +### Requirement 2: Shareable filter URLs + +**User story:** As a shopper, I want to share filtered views, so that friends see the same results. + +#### Acceptance criteria + +- WHEN filters change, THE SYSTEM SHALL encode them in the URL query string. + +## Deliberately unusual section + +Kept to prove unknown sections survive analysis. + +## Not in scope + +- Saved-search notifications. + +## Quality attributes + +- Performance: filter restoration must not add a visible delay. diff --git a/tests/fixtures/v02-orphan-sidecar/.kiro/specs/real-spec/requirements.md b/tests/fixtures/v02-orphan-sidecar/.kiro/specs/real-spec/requirements.md new file mode 100644 index 0000000..1f6d86a --- /dev/null +++ b/tests/fixtures/v02-orphan-sidecar/.kiro/specs/real-spec/requirements.md @@ -0,0 +1,41 @@ +# Requirements Document + +## Introduction + +**Notification Preferences** + +Allow users to select email and push notification preferences. + +## Requirements + +### Requirement 1: Channel selection + +**User Story:** As a registered user, I want to choose which channels deliver notifications, so that I only receive them where I want. + +#### Acceptance Criteria + +1. WHEN a user saves channel preferences, THE SYSTEM SHALL persist the selection within 2 seconds. +2. IF the preferences service is unavailable, THEN THE SYSTEM SHALL keep the previous preferences and display a retry option. +3. WHEN a notification is sent, THE SYSTEM SHALL deliver it only through channels the user enabled. + +### Requirement 2: Default preferences + +**User Story:** As a new user, I want sensible notification defaults, so that I receive important messages without setup. + +#### Acceptance Criteria + +1. WHEN an account is created, THE SYSTEM SHALL enable email notifications and disable push notifications. +2. IF a notification category is mandatory, THEN THE SYSTEM SHALL deliver it regardless of user preferences. + +## Non-Functional Requirements + +- Performance: preference reads add at most 50 ms to notification dispatch. +- Security: preferences are readable and writable only by the owning user. + +## Edge Cases + +- A user disables every channel: mandatory notifications still deliver by email. + +## Out of Scope + +- Digest scheduling and quiet hours. diff --git a/tests/fixtures/v02-orphan-sidecar/.specbridge/state/specs/ghost-spec.json b/tests/fixtures/v02-orphan-sidecar/.specbridge/state/specs/ghost-spec.json new file mode 100644 index 0000000..ec0f447 --- /dev/null +++ b/tests/fixtures/v02-orphan-sidecar/.specbridge/state/specs/ghost-spec.json @@ -0,0 +1,30 @@ +{ + "schemaVersion": "1.0.0", + "specName": "ghost-spec", + "specType": "feature", + "workflowMode": "requirements-first", + "origin": "created-by-specbridge", + "status": "REQUIREMENTS_DRAFT", + "createdAt": "2026-07-01T09:00:00.000Z", + "updatedAt": "2026-07-01T09:00:00.000Z", + "stages": { + "requirements": { + "status": "draft", + "file": ".kiro/specs/ghost-spec/requirements.md", + "approvedAt": null, + "approvedHash": null + }, + "design": { + "status": "blocked", + "file": ".kiro/specs/ghost-spec/design.md", + "approvedAt": null, + "approvedHash": null + }, + "tasks": { + "status": "blocked", + "file": ".kiro/specs/ghost-spec/tasks.md", + "approvedAt": null, + "approvedHash": null + } + } +} diff --git a/tests/fixtures/v02-placeholder-heavy/.kiro/specs/notification-preferences/design.md b/tests/fixtures/v02-placeholder-heavy/.kiro/specs/notification-preferences/design.md new file mode 100644 index 0000000..c6cc4d4 --- /dev/null +++ b/tests/fixtures/v02-placeholder-heavy/.kiro/specs/notification-preferences/design.md @@ -0,0 +1,7 @@ +# Design Document + +> Status: Pending requirements approval. + +## Overview + +Design will be completed after requirements approval. diff --git a/tests/fixtures/v02-placeholder-heavy/.kiro/specs/notification-preferences/requirements.md b/tests/fixtures/v02-placeholder-heavy/.kiro/specs/notification-preferences/requirements.md new file mode 100644 index 0000000..36288da --- /dev/null +++ b/tests/fixtures/v02-placeholder-heavy/.kiro/specs/notification-preferences/requirements.md @@ -0,0 +1,40 @@ +# Requirements Document + +## Introduction + +**Notification Preferences** + +Add a short description of the feature here. + +## Glossary + +| Term | Definition | +| --- | --- | +| <term> | <definition> | + +## Requirements + +### Requirement 1: <initial requirement title> + +**User Story:** As a <role>, I want <capability>, so that <benefit>. + +#### Acceptance Criteria + +1. WHEN <condition or event>, THE SYSTEM SHALL <expected behavior>. +2. IF <error or exceptional condition>, THEN THE SYSTEM SHALL <safe behavior>. + +## Non-Functional Requirements + +- Performance: add measurable performance expectations here. +- Security: add authentication, authorization, and data-handling expectations here. +- Reliability: add availability and failure-recovery expectations here. +- Observability: add logging, metrics, and alerting expectations here. +- Compatibility: add platform and integration constraints here. + +## Edge Cases + +- Add edge cases here. + +## Out of Scope + +- Add explicitly excluded behavior here. diff --git a/tests/fixtures/v02-placeholder-heavy/.kiro/specs/notification-preferences/tasks.md b/tests/fixtures/v02-placeholder-heavy/.kiro/specs/notification-preferences/tasks.md new file mode 100644 index 0000000..732ac91 --- /dev/null +++ b/tests/fixtures/v02-placeholder-heavy/.kiro/specs/notification-preferences/tasks.md @@ -0,0 +1,5 @@ +# Implementation Plan + +> Status: Pending design approval. + +- [ ] Define implementation tasks after design approval. diff --git a/tests/fixtures/v02-placeholder-heavy/.specbridge/state/specs/notification-preferences.json b/tests/fixtures/v02-placeholder-heavy/.specbridge/state/specs/notification-preferences.json new file mode 100644 index 0000000..68af794 --- /dev/null +++ b/tests/fixtures/v02-placeholder-heavy/.specbridge/state/specs/notification-preferences.json @@ -0,0 +1,30 @@ +{ + "schemaVersion": "1.0.0", + "specName": "notification-preferences", + "specType": "feature", + "workflowMode": "requirements-first", + "origin": "created-by-specbridge", + "status": "REQUIREMENTS_DRAFT", + "createdAt": "2026-07-01T09:00:00.000Z", + "updatedAt": "2026-07-01T09:00:00.000Z", + "stages": { + "requirements": { + "status": "draft", + "file": ".kiro/specs/notification-preferences/requirements.md", + "approvedAt": null, + "approvedHash": null + }, + "design": { + "status": "blocked", + "file": ".kiro/specs/notification-preferences/design.md", + "approvedAt": null, + "approvedHash": null + }, + "tasks": { + "status": "blocked", + "file": ".kiro/specs/notification-preferences/tasks.md", + "approvedAt": null, + "approvedHash": null + } + } +} diff --git a/tests/fixtures/v02-quick-spec/.kiro/specs/healthcheck-endpoint/design.md b/tests/fixtures/v02-quick-spec/.kiro/specs/healthcheck-endpoint/design.md new file mode 100644 index 0000000..af670f2 --- /dev/null +++ b/tests/fixtures/v02-quick-spec/.kiro/specs/healthcheck-endpoint/design.md @@ -0,0 +1,46 @@ +# Design Document + +## Overview + +Export pipeline that streams report data to object storage. + +## Architecture + +A queue-backed worker reads export jobs and streams rows to a storage +adapter. Backpressure is handled by the queue, not in memory. + +## Components and Interfaces + +- ExportScheduler: enqueues jobs (`schedule(jobSpec): JobId`). +- ExportWorker: consumes jobs, streams rows. +- StorageAdapter interface: `putStream(key, stream)`. + +## Data Model + +- ExportJob: id, reportId, requestedBy, format, state. + +## Failure Handling + +- Worker crash: the job lease expires and another worker resumes it. +- Storage unavailable: exponential backoff, job fails after 5 attempts. + +## Security Considerations + +- Signed URLs expire after 15 minutes; exports are private by default. + +## Observability + +- Metrics: export duration, failure count, queue depth. + +## Testing Strategy + +- Unit tests for the scheduler and adapters; integration test with a fake + storage backend; regression test for resumed jobs. + +## Risks and Trade-offs + +- Streaming keeps memory flat but makes retries coarser (whole-job retry). + +## Alternatives Considered + +- Direct synchronous export from the API process: rejected, ties up web workers. diff --git a/tests/fixtures/v02-quick-spec/.kiro/specs/healthcheck-endpoint/requirements.md b/tests/fixtures/v02-quick-spec/.kiro/specs/healthcheck-endpoint/requirements.md new file mode 100644 index 0000000..a1bc6eb --- /dev/null +++ b/tests/fixtures/v02-quick-spec/.kiro/specs/healthcheck-endpoint/requirements.md @@ -0,0 +1,41 @@ +# Requirements Document + +## Introduction + +**Healthcheck Endpoint** + +Allow users to select email and push notification preferences. + +## Requirements + +### Requirement 1: Channel selection + +**User Story:** As a registered user, I want to choose which channels deliver notifications, so that I only receive them where I want. + +#### Acceptance Criteria + +1. WHEN a user saves channel preferences, THE SYSTEM SHALL persist the selection within 2 seconds. +2. IF the preferences service is unavailable, THEN THE SYSTEM SHALL keep the previous preferences and display a retry option. +3. WHEN a notification is sent, THE SYSTEM SHALL deliver it only through channels the user enabled. + +### Requirement 2: Default preferences + +**User Story:** As a new user, I want sensible notification defaults, so that I receive important messages without setup. + +#### Acceptance Criteria + +1. WHEN an account is created, THE SYSTEM SHALL enable email notifications and disable push notifications. +2. IF a notification category is mandatory, THEN THE SYSTEM SHALL deliver it regardless of user preferences. + +## Non-Functional Requirements + +- Performance: preference reads add at most 50 ms to notification dispatch. +- Security: preferences are readable and writable only by the owning user. + +## Edge Cases + +- A user disables every channel: mandatory notifications still deliver by email. + +## Out of Scope + +- Digest scheduling and quiet hours. diff --git a/tests/fixtures/v02-quick-spec/.kiro/specs/healthcheck-endpoint/tasks.md b/tests/fixtures/v02-quick-spec/.kiro/specs/healthcheck-endpoint/tasks.md new file mode 100644 index 0000000..df2a6f5 --- /dev/null +++ b/tests/fixtures/v02-quick-spec/.kiro/specs/healthcheck-endpoint/tasks.md @@ -0,0 +1,11 @@ +# Implementation Plan + +- [ ] 1. Implement the preferences data model + - _Requirements: 1.1, 2.1_ +- [ ] 2. Build the preferences service endpoint + - _Requirements: 1.1, 1.2_ +- [ ] 3. Wire notification dispatch to preference checks + - _Requirements: 1.3, 2.2_ +- [ ] 4. Add automated tests for acceptance criteria + - _Requirements: 1.1, 1.2, 1.3, 2.1, 2.2_ +- [ ] 5. Verify error handling and edge cases diff --git a/tests/fixtures/v02-quick-spec/.specbridge/state/specs/healthcheck-endpoint.json b/tests/fixtures/v02-quick-spec/.specbridge/state/specs/healthcheck-endpoint.json new file mode 100644 index 0000000..01f6af9 --- /dev/null +++ b/tests/fixtures/v02-quick-spec/.specbridge/state/specs/healthcheck-endpoint.json @@ -0,0 +1,30 @@ +{ + "schemaVersion": "1.0.0", + "specName": "healthcheck-endpoint", + "specType": "feature", + "workflowMode": "quick", + "origin": "created-by-specbridge", + "status": "READY_FOR_REVIEW", + "createdAt": "2026-07-05T08:00:00.000Z", + "updatedAt": "2026-07-05T08:00:00.000Z", + "stages": { + "requirements": { + "status": "draft", + "file": ".kiro/specs/healthcheck-endpoint/requirements.md", + "approvedAt": null, + "approvedHash": null + }, + "design": { + "status": "draft", + "file": ".kiro/specs/healthcheck-endpoint/design.md", + "approvedAt": null, + "approvedHash": null + }, + "tasks": { + "status": "blocked", + "file": ".kiro/specs/healthcheck-endpoint/tasks.md", + "approvedAt": null, + "approvedHash": null + } + } +} diff --git a/tests/fixtures/v02-requirements-first/.kiro/specs/notification-preferences/design.md b/tests/fixtures/v02-requirements-first/.kiro/specs/notification-preferences/design.md new file mode 100644 index 0000000..c6cc4d4 --- /dev/null +++ b/tests/fixtures/v02-requirements-first/.kiro/specs/notification-preferences/design.md @@ -0,0 +1,7 @@ +# Design Document + +> Status: Pending requirements approval. + +## Overview + +Design will be completed after requirements approval. diff --git a/tests/fixtures/v02-requirements-first/.kiro/specs/notification-preferences/requirements.md b/tests/fixtures/v02-requirements-first/.kiro/specs/notification-preferences/requirements.md new file mode 100644 index 0000000..1f6d86a --- /dev/null +++ b/tests/fixtures/v02-requirements-first/.kiro/specs/notification-preferences/requirements.md @@ -0,0 +1,41 @@ +# Requirements Document + +## Introduction + +**Notification Preferences** + +Allow users to select email and push notification preferences. + +## Requirements + +### Requirement 1: Channel selection + +**User Story:** As a registered user, I want to choose which channels deliver notifications, so that I only receive them where I want. + +#### Acceptance Criteria + +1. WHEN a user saves channel preferences, THE SYSTEM SHALL persist the selection within 2 seconds. +2. IF the preferences service is unavailable, THEN THE SYSTEM SHALL keep the previous preferences and display a retry option. +3. WHEN a notification is sent, THE SYSTEM SHALL deliver it only through channels the user enabled. + +### Requirement 2: Default preferences + +**User Story:** As a new user, I want sensible notification defaults, so that I receive important messages without setup. + +#### Acceptance Criteria + +1. WHEN an account is created, THE SYSTEM SHALL enable email notifications and disable push notifications. +2. IF a notification category is mandatory, THEN THE SYSTEM SHALL deliver it regardless of user preferences. + +## Non-Functional Requirements + +- Performance: preference reads add at most 50 ms to notification dispatch. +- Security: preferences are readable and writable only by the owning user. + +## Edge Cases + +- A user disables every channel: mandatory notifications still deliver by email. + +## Out of Scope + +- Digest scheduling and quiet hours. diff --git a/tests/fixtures/v02-requirements-first/.kiro/specs/notification-preferences/tasks.md b/tests/fixtures/v02-requirements-first/.kiro/specs/notification-preferences/tasks.md new file mode 100644 index 0000000..732ac91 --- /dev/null +++ b/tests/fixtures/v02-requirements-first/.kiro/specs/notification-preferences/tasks.md @@ -0,0 +1,5 @@ +# Implementation Plan + +> Status: Pending design approval. + +- [ ] Define implementation tasks after design approval. diff --git a/tests/fixtures/v02-requirements-first/.specbridge/state/specs/notification-preferences.json b/tests/fixtures/v02-requirements-first/.specbridge/state/specs/notification-preferences.json new file mode 100644 index 0000000..7617be0 --- /dev/null +++ b/tests/fixtures/v02-requirements-first/.specbridge/state/specs/notification-preferences.json @@ -0,0 +1,30 @@ +{ + "schemaVersion": "1.0.0", + "specName": "notification-preferences", + "specType": "feature", + "workflowMode": "requirements-first", + "origin": "created-by-specbridge", + "status": "DESIGN_DRAFT", + "createdAt": "2026-07-01T09:00:00.000Z", + "updatedAt": "2026-07-01T10:00:00.000Z", + "stages": { + "requirements": { + "status": "approved", + "file": ".kiro/specs/notification-preferences/requirements.md", + "approvedAt": "2026-07-01T10:00:00.000Z", + "approvedHash": "6ba8651374575ae82b49079e9041e9e2d66d8f7db0138f686cc7e75ba6b14e73" + }, + "design": { + "status": "draft", + "file": ".kiro/specs/notification-preferences/design.md", + "approvedAt": null, + "approvedHash": null + }, + "tasks": { + "status": "blocked", + "file": ".kiro/specs/notification-preferences/tasks.md", + "approvedAt": null, + "approvedHash": null + } + } +} diff --git a/tests/fixtures/v02-stale-requirements-approval/.kiro/specs/payment-retry/design.md b/tests/fixtures/v02-stale-requirements-approval/.kiro/specs/payment-retry/design.md new file mode 100644 index 0000000..af670f2 --- /dev/null +++ b/tests/fixtures/v02-stale-requirements-approval/.kiro/specs/payment-retry/design.md @@ -0,0 +1,46 @@ +# Design Document + +## Overview + +Export pipeline that streams report data to object storage. + +## Architecture + +A queue-backed worker reads export jobs and streams rows to a storage +adapter. Backpressure is handled by the queue, not in memory. + +## Components and Interfaces + +- ExportScheduler: enqueues jobs (`schedule(jobSpec): JobId`). +- ExportWorker: consumes jobs, streams rows. +- StorageAdapter interface: `putStream(key, stream)`. + +## Data Model + +- ExportJob: id, reportId, requestedBy, format, state. + +## Failure Handling + +- Worker crash: the job lease expires and another worker resumes it. +- Storage unavailable: exponential backoff, job fails after 5 attempts. + +## Security Considerations + +- Signed URLs expire after 15 minutes; exports are private by default. + +## Observability + +- Metrics: export duration, failure count, queue depth. + +## Testing Strategy + +- Unit tests for the scheduler and adapters; integration test with a fake + storage backend; regression test for resumed jobs. + +## Risks and Trade-offs + +- Streaming keeps memory flat but makes retries coarser (whole-job retry). + +## Alternatives Considered + +- Direct synchronous export from the API process: rejected, ties up web workers. diff --git a/tests/fixtures/v02-stale-requirements-approval/.kiro/specs/payment-retry/requirements.md b/tests/fixtures/v02-stale-requirements-approval/.kiro/specs/payment-retry/requirements.md new file mode 100644 index 0000000..1af987b --- /dev/null +++ b/tests/fixtures/v02-stale-requirements-approval/.kiro/specs/payment-retry/requirements.md @@ -0,0 +1,41 @@ +# Requirements Document + +## Introduction + +**Payment Retry** + +Allow users to select email and push notification preferences. + +## Requirements + +### Requirement 1: Channel selection + +**User Story:** As a registered user, I want to choose which channels deliver notifications, so that I only receive them where I want. + +#### Acceptance Criteria + +1. WHEN a user saves channel preferences, THE SYSTEM SHALL persist the selection within 2 seconds. +2. IF the preferences service is unavailable, THEN THE SYSTEM SHALL keep the previous preferences and display a retry option. +3. WHEN a notification is sent, THE SYSTEM SHALL deliver it only through channels the user enabled. + +### Requirement 2: Default preferences + +**User Story:** As a new user, I want sensible notification defaults, so that I receive important messages without setup. + +#### Acceptance Criteria + +1. WHEN an account is created, THE SYSTEM SHALL enable email notifications and disable push notifications. +2. IF a notification category is mandatory, THEN THE SYSTEM SHALL deliver it regardless of user preferences. + +## Non-Functional Requirements + +- Performance: preference reads add at most 50 ms to notification dispatch. +- Security: preferences are readable and writable only by the owning user. + +## Edge Cases + +- A user disables every channel: mandatory notifications still deliver by email. + +## Out of Scope + +- Digest scheduling and quiet hours. diff --git a/tests/fixtures/v02-stale-requirements-approval/.kiro/specs/payment-retry/tasks.md b/tests/fixtures/v02-stale-requirements-approval/.kiro/specs/payment-retry/tasks.md new file mode 100644 index 0000000..df2a6f5 --- /dev/null +++ b/tests/fixtures/v02-stale-requirements-approval/.kiro/specs/payment-retry/tasks.md @@ -0,0 +1,11 @@ +# Implementation Plan + +- [ ] 1. Implement the preferences data model + - _Requirements: 1.1, 2.1_ +- [ ] 2. Build the preferences service endpoint + - _Requirements: 1.1, 1.2_ +- [ ] 3. Wire notification dispatch to preference checks + - _Requirements: 1.3, 2.2_ +- [ ] 4. Add automated tests for acceptance criteria + - _Requirements: 1.1, 1.2, 1.3, 2.1, 2.2_ +- [ ] 5. Verify error handling and edge cases diff --git a/tests/fixtures/v02-stale-requirements-approval/.specbridge/state/specs/payment-retry.json b/tests/fixtures/v02-stale-requirements-approval/.specbridge/state/specs/payment-retry.json new file mode 100644 index 0000000..12cd10c --- /dev/null +++ b/tests/fixtures/v02-stale-requirements-approval/.specbridge/state/specs/payment-retry.json @@ -0,0 +1,30 @@ +{ + "schemaVersion": "1.0.0", + "specName": "payment-retry", + "specType": "feature", + "workflowMode": "requirements-first", + "origin": "created-by-specbridge", + "status": "TASKS_DRAFT", + "createdAt": "2026-07-01T09:00:00.000Z", + "updatedAt": "2026-07-03T10:00:00.000Z", + "stages": { + "requirements": { + "status": "approved", + "file": ".kiro/specs/payment-retry/requirements.md", + "approvedAt": "2026-07-01T10:00:00.000Z", + "approvedHash": "80658d9f683cb29bbd3a0c637f5345b88efc32fd0af8a9717e664c002f684183" + }, + "design": { + "status": "approved", + "file": ".kiro/specs/payment-retry/design.md", + "approvedAt": "2026-07-03T10:00:00.000Z", + "approvedHash": "1254a27087fb61d4a2069b6696b0e12aced9c942f52b5317c6c609a40bc625cc" + }, + "tasks": { + "status": "draft", + "file": ".kiro/specs/payment-retry/tasks.md", + "approvedAt": null, + "approvedHash": null + } + } +} diff --git a/tests/fixtures/v02-valid-ears/.kiro/specs/session-timeout/requirements.md b/tests/fixtures/v02-valid-ears/.kiro/specs/session-timeout/requirements.md new file mode 100644 index 0000000..8d6e347 --- /dev/null +++ b/tests/fixtures/v02-valid-ears/.kiro/specs/session-timeout/requirements.md @@ -0,0 +1,27 @@ +# Requirements Document + +## Introduction + +Session timeout handling for authenticated users. + +## Requirements + +### Requirement 1: Timeout enforcement + +**User Story:** As a security officer, I want idle sessions to expire, so that unattended sessions cannot be hijacked. + +#### Acceptance Criteria + +1. WHEN a session is idle for 30 minutes, THE SYSTEM SHALL invalidate the session token. +2. IF a request arrives with an expired token, THEN THE SYSTEM SHALL respond with 401 and no session data. +3. WHILE a session refresh is in progress, THE SYSTEM SHALL reject concurrent refresh attempts for the same token. +4. WHERE single sign-on is enabled, THE SYSTEM SHALL propagate the logout to the identity provider. +5. The system SHALL record every forced logout in the audit log. + +## Out of Scope + +- Remember-me tokens. + +## Non-Functional Requirements + +- Security: token invalidation propagates within 5 seconds. diff --git a/tests/helpers.ts b/tests/helpers.ts index 7618ca2..d1f4653 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -2,6 +2,14 @@ import { cpSync, mkdtempSync } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; +import type { + ConcreteSpecType, + ConcreteWorkflowMode, + SpecWorkflowState, + StageApproval, + StageName, +} from '@specbridge/core'; +import { SPEC_STATE_SCHEMA_VERSION } from '@specbridge/core'; const testsDir = path.dirname(fileURLToPath(import.meta.url)); @@ -30,3 +38,61 @@ export function copyFixtureToTemp(fixtureName: string): string { export function emptyTempDir(): string { return mkdtempSync(path.join(os.tmpdir(), 'specbridge-empty-')); } + +/** Deterministic clock for workflow tests. */ +export const FIXED_NOW = new Date('2026-07-12T10:00:00.000Z'); +export const fixedClock = (): Date => FIXED_NOW; + +/** + * Build a schema-valid v0.2 workflow state for tests. Stages default to the + * canonical order for the type with nothing approved and the first stage + * draft; pass `stages` overrides to adjust individual entries. + */ +export function testWorkflowState(options: { + specName: string; + specType?: ConcreteSpecType; + workflowMode?: ConcreteWorkflowMode; + status?: SpecWorkflowState['status']; + origin?: SpecWorkflowState['origin']; + stages?: Partial<Record<StageName, Partial<StageApproval>>>; +}): SpecWorkflowState { + const specType = options.specType ?? 'feature'; + const workflowMode = options.workflowMode ?? 'requirements-first'; + const documentStage: StageName = specType === 'bugfix' ? 'bugfix' : 'requirements'; + const order: StageName[] = + workflowMode === 'design-first' + ? ['design', documentStage, 'tasks'] + : [documentStage, 'design', 'tasks']; + + const stages: Record<string, StageApproval> = {}; + order.forEach((stage, index) => { + const base: StageApproval = { + status: index === 0 || workflowMode === 'quick' ? 'draft' : 'blocked', + file: `.kiro/specs/${options.specName}/${stage}.md`, + approvedAt: null, + approvedHash: null, + }; + if (workflowMode === 'quick' && stage === 'tasks') base.status = 'blocked'; + stages[stage] = { ...base, ...(options.stages?.[stage] ?? {}) }; + }); + + return { + schemaVersion: SPEC_STATE_SCHEMA_VERSION, + specName: options.specName, + specType, + workflowMode, + origin: options.origin ?? 'created-by-specbridge', + status: + options.status ?? + (workflowMode === 'quick' + ? 'READY_FOR_REVIEW' + : workflowMode === 'design-first' + ? 'DESIGN_DRAFT' + : specType === 'bugfix' + ? 'BUGFIX_DRAFT' + : 'REQUIREMENTS_DRAFT'), + createdAt: FIXED_NOW.toISOString(), + updatedAt: FIXED_NOW.toISOString(), + stages: stages as SpecWorkflowState['stages'], + }; +} diff --git a/tests/workflow/analysis.test.ts b/tests/workflow/analysis.test.ts new file mode 100644 index 0000000..05df24c --- /dev/null +++ b/tests/workflow/analysis.test.ts @@ -0,0 +1,385 @@ +import { describe, expect, it } from 'vitest'; +import type { WorkspaceInfo } from '@specbridge/core'; +import { readSpecState, resolveWorkspace } from '@specbridge/core'; +import type { SpecAnalysis } from '@specbridge/compat-kiro'; +import { + MarkdownDocument, + analyzeSpec, + parseBugfix, + parseDesign, + parseRequirements, + parseTasks, + requireSpec, +} from '@specbridge/compat-kiro'; +import type { StageAnalysisOptions, WorkflowEvaluation } from '@specbridge/workflow'; +import { + analyzeSpecStage, + analyzeSpecWorkflow, + classifyEars, + evaluateWorkflow, + findVaguePhrases, + scanPlaceholders, +} from '@specbridge/workflow'; +import { fixturePath } from '../helpers.js'; + +function evaluationOf(workspace: WorkspaceInfo, name: string): WorkflowEvaluation | undefined { + const stateRead = readSpecState(workspace, name); + return stateRead.state !== undefined ? evaluateWorkflow(workspace, stateRead.state) : undefined; +} + +function specOf(fixture: string, name: string): { workspace: WorkspaceInfo; spec: SpecAnalysis } { + const workspace = resolveWorkspace(fixturePath(fixture)); + if (workspace === undefined) throw new Error(`no workspace in fixture ${fixture}`); + return { workspace, spec: analyzeSpec(workspace, requireSpec(workspace, name)) }; +} + +const STRICT: StageAnalysisOptions = { + placeholderSeverity: 'error', + missingFileSeverity: 'error', + prerequisitesApproved: true, +}; + +function codes(diagnostics: { code: string }[]): string[] { + return diagnostics.map((d) => d.code); +} + +describe('requirements analysis', () => { + it('valid requirements produce no errors (test 40)', () => { + const { spec } = specOf('v02-requirements-first', 'notification-preferences'); + const result = analyzeSpecStage(spec, 'requirements', STRICT); + expect(result.diagnostics.filter((d) => d.severity === 'error')).toEqual([]); + }); + + it('a manually edited spec with custom headings produces no errors (test 53 analysis half)', () => { + const { spec } = specOf('v02-manually-edited', 'search-filters'); + const result = analyzeSpecStage(spec, 'requirements', STRICT); + expect(result.diagnostics.filter((d) => d.severity === 'error')).toEqual([]); + }); + + it('missing acceptance criteria is an error (test 41)', () => { + const document = MarkdownDocument.fromText(`# Requirements Document + +## Introduction + +Intro. + +## Requirements + +### Requirement 1: No criteria here +`); + const { spec } = specOf('v02-requirements-first', 'notification-preferences'); + const patched: SpecAnalysis = { + ...spec, + documents: { ...spec.documents, requirements: document }, + requirements: parseRequirements(document), + }; + const result = analyzeSpecStage(patched, 'requirements', STRICT); + expect(codes(result.diagnostics)).toContain('REQUIREMENTS_NO_CRITERIA'); + expect(result.diagnostics.find((d) => d.code === 'REQUIREMENTS_NO_CRITERIA')?.severity).toBe('error'); + }); + + it('duplicate requirement ids are errors (test 42)', () => { + const document = MarkdownDocument.fromText(`# Requirements Document + +## Requirements + +### Requirement 1: First + +#### Acceptance Criteria + +1. WHEN a, THE SYSTEM SHALL b. + +### Requirement 1: Duplicate + +#### Acceptance Criteria + +1. WHEN c, THE SYSTEM SHALL d. +`); + const { spec } = specOf('v02-requirements-first', 'notification-preferences'); + const patched: SpecAnalysis = { + ...spec, + documents: { ...spec.documents, requirements: document }, + requirements: parseRequirements(document), + }; + const result = analyzeSpecStage(patched, 'requirements', STRICT); + const duplicate = result.diagnostics.find((d) => d.code === 'REQUIREMENTS_DUPLICATE_ID'); + expect(duplicate?.severity).toBe('error'); + }); + + it('placeholder content is detected with line numbers (test 43)', () => { + const { spec } = specOf('v02-placeholder-heavy', 'notification-preferences'); + const result = analyzeSpecStage(spec, 'requirements', STRICT); + const placeholders = result.diagnostics.filter((d) => d.code === 'REQUIREMENTS_PLACEHOLDER'); + expect(placeholders.length).toBeGreaterThan(5); + expect(placeholders.every((d) => d.severity === 'error')).toBe(true); + expect(placeholders.some((d) => d.message.includes('<role>'))).toBe(true); + expect(placeholders.every((d) => d.line !== undefined)).toBe(true); + }); + + it('valid EARS criteria are recognized without warnings (test 44)', () => { + const { spec } = specOf('v02-valid-ears', 'session-timeout'); + const result = analyzeSpecStage(spec, 'requirements', STRICT); + expect(codes(result.diagnostics)).not.toContain('REQUIREMENTS_EARS_MALFORMED'); + expect(codes(result.diagnostics)).not.toContain('REQUIREMENTS_CRITERION_NOT_TESTABLE'); + expect(result.diagnostics.filter((d) => d.severity === 'error')).toEqual([]); + }); + + it('classifies each documented EARS pattern', () => { + expect(classifyEars('WHEN a user logs in, THE SYSTEM SHALL create a session.')).toBe('ears'); + expect(classifyEars('IF the token expired, THEN THE SYSTEM SHALL return 401.')).toBe('ears'); + expect(classifyEars('WHILE a sync runs, THE SYSTEM SHALL queue new writes.')).toBe('ears'); + expect(classifyEars('WHERE SSO is enabled, THE SYSTEM SHALL redirect to the IdP.')).toBe('ears'); + expect(classifyEars('The system SHALL log every request.')).toBe('ears'); + expect(classifyEars('WHEN a session is idle for 30 minutes.')).toBe('ears-malformed'); + expect(classifyEars('The page loads fast.')).toBe('plain'); + }); + + it('malformed EARS and vague criteria produce warnings (test 45)', () => { + const { spec } = specOf('v02-invalid-ears', 'session-timeout'); + const result = analyzeSpecStage(spec, 'requirements', STRICT); + const byCode = codes(result.diagnostics); + expect(byCode).toContain('REQUIREMENTS_EARS_MALFORMED'); + expect(byCode).toContain('REQUIREMENTS_VAGUE_CRITERION'); + expect(byCode).toContain('REQUIREMENTS_CRITERION_NOT_TESTABLE'); + for (const code of [ + 'REQUIREMENTS_EARS_MALFORMED', + 'REQUIREMENTS_VAGUE_CRITERION', + 'REQUIREMENTS_CRITERION_NOT_TESTABLE', + ]) { + expect(result.diagnostics.find((d) => d.code === code)?.severity).toBe('warning'); + } + expect(findVaguePhrases('The feature works correctly and handles input gracefully.')).toEqual([ + 'works correctly', + 'handles', + 'gracefully', + ]); + }); +}); + +describe('bugfix analysis', () => { + it('valid bugfix documents produce no errors', () => { + const { spec } = specOf('v02-bugfix-spec', 'login-timeout-fix'); + const result = analyzeSpecStage(spec, 'bugfix', STRICT); + expect(result.diagnostics.filter((d) => d.severity === 'error')).toEqual([]); + }); + + it('missing unchanged behavior is detected (test 46)', () => { + const document = MarkdownDocument.fromText(`# Bugfix Document + +## Current Behavior + +Sessions expire after 5 minutes. + +## Expected Behavior + +Sessions expire after 30 minutes. + +## Reproduction + +1. Wait. + +## Evidence + +- Logs: expiry claim in the past. + +## Regression Risks + +- Refresh flow. +`); + const { spec } = specOf('v02-bugfix-spec', 'login-timeout-fix'); + const patched: SpecAnalysis = { + ...spec, + documents: { ...spec.documents, bugfix: document }, + bugfix: parseBugfix(document), + }; + const result = analyzeSpecStage(patched, 'bugfix', STRICT); + expect(codes(result.diagnostics)).toContain('BUGFIX_NO_UNCHANGED_BEHAVIOR'); + }); + + it('identical current and expected behavior is an error', () => { + const document = MarkdownDocument.fromText(`# Bugfix Document + +## Current Behavior + +The cart total is rounded down. + +## Expected Behavior + +The cart total is rounded down. + +## Unchanged Behavior + +- Everything else. +`); + const { spec } = specOf('v02-bugfix-spec', 'login-timeout-fix'); + const patched: SpecAnalysis = { + ...spec, + documents: { ...spec.documents, bugfix: document }, + bugfix: parseBugfix(document), + }; + const result = analyzeSpecStage(patched, 'bugfix', STRICT); + const identical = result.diagnostics.find((d) => d.code === 'BUGFIX_BEHAVIOR_IDENTICAL'); + expect(identical?.severity).toBe('error'); + }); +}); + +describe('design analysis', () => { + it('missing testing strategy is detected (test 47)', () => { + const document = MarkdownDocument.fromText(`# Design Document + +## Overview + +A real design overview with actual content. + +## Architecture + +Single-module approach. + +## Components and Interfaces + +- One component. + +## Failure Handling + +- Errors are returned to the caller. + +## Security Considerations + +- None beyond the platform defaults. + +## Risks and Trade-offs + +- None. +`); + const { spec } = specOf('v02-requirements-first', 'notification-preferences'); + const patched: SpecAnalysis = { + ...spec, + documents: { ...spec.documents, design: document }, + design: parseDesign(document), + }; + const result = analyzeSpecStage(patched, 'design', STRICT); + const finding = result.diagnostics.find((d) => d.code === 'DESIGN_NO_TESTING_STRATEGY'); + expect(finding?.severity).toBe('warning'); + }); + + it('a pending design stub reports placeholders as warnings while blocked', () => { + const { spec, workspace } = specOf('v02-requirements-first', 'notification-preferences'); + const evaluation = evaluationOf(workspace, 'notification-preferences'); + const result = analyzeSpecWorkflow(spec, evaluation); + const designPlaceholders = result.diagnostics.filter((d) => d.code.startsWith('DESIGN_PLACEHOLDER')); + // Design stage is draft (requirements approved) so placeholders are errors there; + // tasks is still blocked so its placeholders are warnings. + expect(designPlaceholders.every((d) => d.severity === 'error')).toBe(true); + const tasksPlaceholders = result.diagnostics.filter((d) => d.code.startsWith('TASKS_PLACEHOLDER')); + expect(tasksPlaceholders.every((d) => d.severity === 'warning')).toBe(true); + }); +}); + +describe('tasks analysis', () => { + it('a task plan without a test task is flagged (test 48)', () => { + const document = MarkdownDocument.fromText(`# Implementation Plan + +- [ ] 1. Implement the feature +- [ ] 2. Update documentation +`); + const { spec } = specOf('v02-requirements-first', 'notification-preferences'); + const patched: SpecAnalysis = { + ...spec, + documents: { ...spec.documents, tasks: document }, + tasks: parseTasks(document), + }; + const result = analyzeSpecStage(patched, 'tasks', STRICT); + expect(codes(result.diagnostics)).toContain('TASKS_NO_TEST_TASK'); + }); + + it('nested task consistency is checked (test 49)', () => { + const document = MarkdownDocument.fromText(`# Implementation Plan + +- [x] 1. Parent marked complete + - [ ] 1.1 Required child still open + - [x] 1.2 Done child +- [ ] 2. Add tests +- [ ] 3. Verify behavior +`); + const { spec } = specOf('v02-requirements-first', 'notification-preferences'); + const patched: SpecAnalysis = { + ...spec, + documents: { ...spec.documents, tasks: document }, + tasks: parseTasks(document), + }; + const result = analyzeSpecStage(patched, 'tasks', STRICT); + const finding = result.diagnostics.find((d) => d.code === 'TASKS_PARENT_COMPLETE_CHILD_OPEN'); + expect(finding).toBeDefined(); + expect(finding?.message).toContain('1'); + }); + + it('tasks referencing nonexistent requirement ids are flagged', () => { + const document = MarkdownDocument.fromText(`# Implementation Plan + +- [ ] 1. Implement something + - _Requirements: 9.9_ +- [ ] 2. Add tests +- [ ] 3. Verify behavior +`); + const { spec } = specOf('v02-requirements-first', 'notification-preferences'); + const patched: SpecAnalysis = { + ...spec, + documents: { ...spec.documents, tasks: document }, + tasks: parseTasks(document), + }; + const result = analyzeSpecStage(patched, 'tasks', STRICT); + const finding = result.diagnostics.find((d) => d.code === 'TASKS_UNKNOWN_REQUIREMENT_REF'); + expect(finding?.message).toContain('9.9'); + }); + + it('completed tasks before approval are flagged when the plan is unapproved', () => { + const document = MarkdownDocument.fromText(`# Implementation Plan + +- [x] 1. Implement something already +- [ ] 2. Add tests +- [ ] 3. Verify behavior +`); + const { spec } = specOf('v02-requirements-first', 'notification-preferences'); + const patched: SpecAnalysis = { + ...spec, + documents: { ...spec.documents, tasks: document }, + tasks: parseTasks(document), + }; + const result = analyzeSpecStage(patched, 'tasks', { + ...STRICT, + stageStatus: 'draft', + }); + expect(codes(result.diagnostics)).toContain('TASKS_COMPLETED_BEFORE_APPROVAL'); + }); +}); + +describe('placeholder scanning', () => { + it('recognizes every placeholder family and ignores code fences', () => { + const document = MarkdownDocument.fromText(`# Doc + +As a <role> I want <capability>. + +TODO: finish this. TBD. + +- Add edge cases here. + +Describe the correct behavior here. + +\`\`\`html +<div>not a placeholder</div> +TODO: not scanned either +\`\`\` + +Real content line that stays unflagged. +`); + const scan = scanPlaceholders(document); + const texts = scan.hits.map((hit) => hit.text).join('\n'); + expect(texts).toContain('<role>'); + expect(texts).toContain('<capability>'); + expect(texts).toContain('TODO'); + expect(texts).toContain('Add edge cases here.'); + expect(texts).toContain('Describe the correct behavior here.'); + expect(texts).not.toContain('div'); + expect(scan.placeholderOnly).toBe(false); + }); +}); diff --git a/tests/workflow/approval.test.ts b/tests/workflow/approval.test.ts new file mode 100644 index 0000000..bbaec9d --- /dev/null +++ b/tests/workflow/approval.test.ts @@ -0,0 +1,397 @@ +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; +import type { ConcreteWorkflowMode, StageName, WorkspaceInfo } from '@specbridge/core'; +import { readSpecState, resolveWorkspace, sha256File } from '@specbridge/core'; +import { analyzeSpec, requireSpec } from '@specbridge/compat-kiro'; +import type { ApprovalResult } from '@specbridge/workflow'; +import { approveStage, createSpec } from '@specbridge/workflow'; +import { emptyTempDir, fixedClock } from '../helpers.js'; + +/** + * Approval-workflow tests run against real workspaces created by `spec new` + * whose first-stage documents are then replaced with valid content, exactly + * the way a user would work. + */ + +const VALID_REQUIREMENTS = `# Requirements Document + +## Introduction + +Real content for approval tests. + +## Requirements + +### Requirement 1: Behavior + +**User Story:** As a user, I want the feature to act predictably, so that I can rely on it. + +#### Acceptance Criteria + +1. WHEN the primary action runs, THE SYSTEM SHALL produce the documented result. +2. IF the backend is unavailable, THEN THE SYSTEM SHALL fail with an actionable error. + +## Out of Scope + +- Everything else. + +## Non-Functional Requirements + +- Security: only authenticated users can trigger the action. +`; + +const VALID_DESIGN = `# Design Document + +## Overview + +A small design with every commonly expected section. + +## Architecture + +One module owns the behavior. + +## Components and Interfaces + +- Component: BehaviorService (interface: run()). + +## Failure Handling + +- Backend unavailable: return an actionable error. + +## Security Considerations + +- Authentication required. + +## Testing Strategy + +- Unit tests plus one integration test. + +## Risks and Trade-offs + +- None worth noting. +`; + +const VALID_TASKS = `# Implementation Plan + +- [ ] 1. Implement the behavior service + - _Requirements: 1.1_ +- [ ] 2. Add automated tests for the acceptance criteria + - _Requirements: 1.1, 1.2_ +- [ ] 3. Verify error handling + - _Requirements: 1.2_ +`; + +const VALID_BUGFIX = `# Bugfix Document + +## Summary + +Sessions expire too early. + +## Current Behavior + +Sessions expire after 5 minutes. + +## Expected Behavior + +Sessions expire after 30 minutes. + +## Unchanged Behavior + +- Explicit logout still works immediately. + +## Reproduction + +1. Log in and wait 6 minutes. + +## Evidence + +- Failing tests: SessionServiceTest.testTimeout. + +## Regression Risks + +- Token refresh must not extend beyond the absolute cap. +`; + +interface Fixture { + workspace: WorkspaceInfo; + approve: (stage: StageName, revoke?: boolean) => ApprovalResult; + writeStage: (stage: StageName, content: string) => void; + specName: string; +} + +function makeSpec(options: { + mode?: ConcreteWorkflowMode; + bugfix?: boolean; +}): Fixture { + const root = emptyTempDir(); + mkdirSync(path.join(root, '.kiro')); + const initial = resolveWorkspace(root); + if (initial === undefined) throw new Error('workspace setup failed'); + const specName = 'test-spec'; + createSpec( + initial, + { + name: specName, + ...(options.bugfix === true ? { specType: 'bugfix' as const } : {}), + mode: options.mode ?? 'requirements-first', + }, + fixedClock, + ); + // Re-resolve so the WorkspaceInfo sees the specs dir the creation added. + const workspace = resolveWorkspace(root); + if (workspace === undefined) throw new Error('workspace setup failed'); + const dir = path.join(root, '.kiro', 'specs', specName); + const writeStage = (stage: StageName, content: string): void => { + writeFileSync(path.join(dir, `${stage}.md`), content); + }; + const approve = (stage: StageName, revoke?: boolean): ApprovalResult => { + const spec = analyzeSpec(workspace, requireSpec(workspace, specName)); + return approveStage( + workspace, + spec, + { stage, ...(revoke === true ? { revoke: true } : {}) }, + { clock: fixedClock }, + ); + }; + return { workspace, approve, writeStage, specName }; +} + +function expectApproved(result: ApprovalResult): asserts result is ApprovalResult & { ok: true; action: 'approved' } { + if (!result.ok) throw new Error(`expected approval, got: ${result.message}`); + if (result.action !== 'approved') throw new Error(`expected approved, got ${result.action}`); +} + +describe('stage approval', () => { + it('approves valid requirements and records hash + timestamp (tests 21, 27)', () => { + const fixture = makeSpec({}); + fixture.writeStage('requirements', VALID_REQUIREMENTS); + const result = fixture.approve('requirements'); + expectApproved(result); + expect(result.hash).toMatch(/^[0-9a-f]{64}$/); + + const state = readSpecState(fixture.workspace, fixture.specName).state; + expect(state?.stages.requirements?.status).toBe('approved'); + expect(state?.stages.requirements?.approvedHash).toBe(result.hash); + expect(state?.stages.requirements?.approvedAt).toBe('2026-07-12T10:00:00.000Z'); + expect(state?.status).toBe('DESIGN_DRAFT'); + // The hash is of the exact bytes on disk. + const filePath = path.join(fixture.workspace.rootDir, '.kiro', 'specs', fixture.specName, 'requirements.md'); + expect(sha256File(filePath)).toBe(result.hash); + }); + + it('blocks design approval while requirements are unapproved (test 22)', () => { + const fixture = makeSpec({}); + fixture.writeStage('design', VALID_DESIGN); + const result = fixture.approve('design'); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.reason).toBe('prerequisites-unmet'); + expect(result.failure).toBe('gate'); + expect(result.missingPrerequisites).toEqual(['requirements']); + expect(result.message).toContain('not approved yet'); + } + }); + + it('design-first workflows approve design before requirements (test 23)', () => { + const fixture = makeSpec({ mode: 'design-first' }); + // Requirements first must be blocked in a design-first workflow. + fixture.writeStage('requirements', VALID_REQUIREMENTS); + const early = fixture.approve('requirements'); + expect(early.ok).toBe(false); + if (!early.ok) expect(early.missingPrerequisites).toEqual(['design']); + + fixture.writeStage('design', VALID_DESIGN); + expectApproved(fixture.approve('design')); + expectApproved(fixture.approve('requirements')); + const state = readSpecState(fixture.workspace, fixture.specName).state; + expect(state?.status).toBe('TASKS_DRAFT'); + }); + + it('quick mode approves the two documents in either order (test 24)', () => { + const fixture = makeSpec({ mode: 'quick' }); + fixture.writeStage('design', VALID_DESIGN); + fixture.writeStage('requirements', VALID_REQUIREMENTS); + expectApproved(fixture.approve('design')); + expectApproved(fixture.approve('requirements')); + const state = readSpecState(fixture.workspace, fixture.specName).state; + expect(state?.status).toBe('READY_FOR_REVIEW'); + + expectApproved(fixture.approve('tasks')); + expect(readSpecState(fixture.workspace, fixture.specName).state?.status).toBe( + 'READY_FOR_IMPLEMENTATION', + ); + }); + + it('bugfix workflows approve bugfix, then design, then tasks (test 25)', () => { + const fixture = makeSpec({ bugfix: true }); + fixture.writeStage('bugfix', VALID_BUGFIX); + + const designTooEarly = fixture.approve('design'); + expect(designTooEarly.ok).toBe(false); + + expectApproved(fixture.approve('bugfix')); + fixture.writeStage('design', VALID_DESIGN); + expectApproved(fixture.approve('design')); + fixture.writeStage('tasks', VALID_TASKS); + expectApproved(fixture.approve('tasks')); + expect(readSpecState(fixture.workspace, fixture.specName).state?.status).toBe( + 'READY_FOR_IMPLEMENTATION', + ); + }); + + it('rejects the requirements stage on a bugfix spec (stage applicability)', () => { + const fixture = makeSpec({ bugfix: true }); + const result = fixture.approve('requirements'); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.reason).toBe('stage-not-applicable'); + expect(result.failure).toBe('usage'); + expect(result.message).toContain('bugfix, design, tasks'); + } + }); + + it('tasks approval requires every prerequisite approval (test 26)', () => { + const fixture = makeSpec({}); + fixture.writeStage('requirements', VALID_REQUIREMENTS); + fixture.writeStage('tasks', VALID_TASKS); + expectApproved(fixture.approve('requirements')); + const result = fixture.approve('tasks'); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.missingPrerequisites).toEqual(['design']); + }); + + it('reapproval updates hash and timestamp (test 28)', () => { + const fixture = makeSpec({}); + fixture.writeStage('requirements', VALID_REQUIREMENTS); + const first = fixture.approve('requirements'); + expectApproved(first); + + fixture.writeStage('requirements', `${VALID_REQUIREMENTS}\nExtra line.\n`); + const laterClock = (): Date => new Date('2026-07-12T11:00:00.000Z'); + const spec = analyzeSpec(fixture.workspace, requireSpec(fixture.workspace, fixture.specName)); + const second = approveStage(fixture.workspace, spec, { stage: 'requirements' }, { clock: laterClock }); + expectApproved(second); + expect(second.reapproved).toBe(true); + expect(second.hash).not.toBe(first.hash); + const state = readSpecState(fixture.workspace, fixture.specName).state; + expect(state?.stages.requirements?.approvedAt).toBe('2026-07-12T11:00:00.000Z'); + expect(state?.stages.requirements?.approvedHash).toBe(second.hash); + }); + + it('revoking an earlier stage invalidates dependent approvals but keeps files (test 29)', () => { + const fixture = makeSpec({}); + fixture.writeStage('requirements', VALID_REQUIREMENTS); + fixture.writeStage('design', VALID_DESIGN); + fixture.writeStage('tasks', VALID_TASKS); + expectApproved(fixture.approve('requirements')); + expectApproved(fixture.approve('design')); + expectApproved(fixture.approve('tasks')); + + const revoked = fixture.approve('requirements', true); + expect(revoked.ok).toBe(true); + if (revoked.ok && revoked.action === 'revoked') { + expect(revoked.invalidated.sort()).toEqual(['design', 'tasks']); + } + const state = readSpecState(fixture.workspace, fixture.specName).state; + expect(state?.stages.requirements?.status).toBe('draft'); + expect(state?.stages.design?.status).toBe('blocked'); + expect(state?.stages.design?.approvedHash).toBeNull(); + expect(state?.stages.tasks?.status).toBe('blocked'); + expect(state?.status).toBe('REQUIREMENTS_DRAFT'); + // Files still exist untouched. + const dir = path.join(fixture.workspace.rootDir, '.kiro', 'specs', fixture.specName); + expect(readFileSync(path.join(dir, 'design.md'), 'utf8')).toBe(VALID_DESIGN); + expect(readFileSync(path.join(dir, 'tasks.md'), 'utf8')).toBe(VALID_TASKS); + }); + + it('revoking an unapproved stage is a usage error', () => { + const fixture = makeSpec({}); + const result = fixture.approve('design', true); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.reason).toBe('nothing-to-revoke'); + expect(result.failure).toBe('usage'); + } + }); + + it('approval never modifies the Markdown files (test 30)', () => { + const fixture = makeSpec({}); + fixture.writeStage('requirements', VALID_REQUIREMENTS); + const dir = path.join(fixture.workspace.rootDir, '.kiro', 'specs', fixture.specName); + const before = ['requirements.md', 'design.md', 'tasks.md'].map((f) => + readFileSync(path.join(dir, f)), + ); + expectApproved(fixture.approve('requirements')); + const after = ['requirements.md', 'design.md', 'tasks.md'].map((f) => + readFileSync(path.join(dir, f)), + ); + before.forEach((buffer, i) => expect(buffer.equals(after[i] as Buffer)).toBe(true)); + }); + + it('a placeholder-heavy stage cannot be approved (test 31)', () => { + const fixture = makeSpec({}); + // The generated template with its placeholders is left untouched. + const result = fixture.approve('requirements'); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.reason).toBe('analysis-errors'); + expect(result.failure).toBe('gate'); + expect( + result.analysis?.diagnostics.some((d) => d.code === 'REQUIREMENTS_PLACEHOLDER'), + ).toBe(true); + } + }); + + it('warnings do not block approval (test 32)', () => { + const fixture = makeSpec({}); + // Valid but warning-prone: no out-of-scope, no NFR, vague wording. + fixture.writeStage( + 'requirements', + `# Requirements Document + +## Introduction + +Minimal but real. + +## Requirements + +### Requirement 1: Something + +**User Story:** As a user, I want results, so that I benefit. + +#### Acceptance Criteria + +1. WHEN the action runs, THE SYSTEM SHALL handle the request and record the outcome. +2. IF the backend fails, THEN THE SYSTEM SHALL report an error. +`, + ); + const result = fixture.approve('requirements'); + expectApproved(result); + expect(result.analysis.warningCount).toBeGreaterThan(0); + }); + + it('analysis errors block approval (test 33)', () => { + const fixture = makeSpec({}); + fixture.writeStage( + 'requirements', + `# Requirements Document + +## Introduction + +Real intro. + +## Requirements + +### Requirement 1: No criteria at all +`, + ); + const result = fixture.approve('requirements'); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.reason).toBe('analysis-errors'); + expect(result.analysis?.diagnostics.some((d) => d.code === 'REQUIREMENTS_NO_CRITERIA')).toBe( + true, + ); + } + }); +}); diff --git a/tests/workflow/spec-creation.test.ts b/tests/workflow/spec-creation.test.ts new file mode 100644 index 0000000..66979ca --- /dev/null +++ b/tests/workflow/spec-creation.test.ts @@ -0,0 +1,278 @@ +import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; +import type { WorkspaceInfo } from '@specbridge/core'; +import { SpecBridgeError, resolveWorkspace } from '@specbridge/core'; +import { + createSpec, + executeSpecCreation, + planSpecCreation, + titleFromSpecName, + validateSpecName, +} from '@specbridge/workflow'; +import { copyFixtureToTemp, emptyTempDir, fixedClock } from '../helpers.js'; + +function freshWorkspace(): WorkspaceInfo { + const root = emptyTempDir(); + mkdirSync(path.join(root, '.kiro'), { recursive: true }); + const workspace = resolveWorkspace(root); + if (workspace === undefined) throw new Error('workspace setup failed'); + return workspace; +} + +describe('spec name validation', () => { + it.each(['notification-preferences', 'auth-v2', 'payment-retry', 'a', 'x1'])( + 'accepts %s', + (name) => { + expect(validateSpecName(name).valid).toBe(true); + }, + ); + + it.each([ + 'NotificationPreferences', + 'notification_preferences', + '../notification', + 'notification/', + '-payment', + 'payment-', + 'payment--retry', + 'has space', + '', + '..', + 'C:\\absolute', + '/absolute', + 'a'.repeat(101), + 'nul', + 'con', + ])('rejects %j with an explanation', (name) => { + const result = validateSpecName(name); + expect(result.valid).toBe(false); + expect(result.problems.length).toBeGreaterThan(0); + }); + + it('derives a readable default title from the name', () => { + expect(titleFromSpecName('notification-preferences')).toBe('Notification Preferences'); + expect(titleFromSpecName('auth-v2')).toBe('Auth V2'); + }); +}); + +describe('spec creation', () => { + it('creates a requirements-first feature spec (test 1)', () => { + const workspace = freshWorkspace(); + const result = createSpec( + workspace, + { name: 'notification-preferences', mode: 'requirements-first' }, + fixedClock, + ); + const dir = path.join(workspace.rootDir, '.kiro', 'specs', 'notification-preferences'); + expect(readdirSync(dir).sort()).toEqual(['design.md', 'requirements.md', 'tasks.md']); + const requirements = readFileSync(path.join(dir, 'requirements.md'), 'utf8'); + expect(requirements).toContain('# Requirements Document'); + expect(requirements).toContain('**User Story:** As a <role>, I want <capability>, so that <benefit>.'); + expect(requirements).toContain('WHEN <condition or event>, THE SYSTEM SHALL <expected behavior>.'); + const design = readFileSync(path.join(dir, 'design.md'), 'utf8'); + expect(design).toContain('> Status: Pending requirements approval.'); + expect(result.plan.state.status).toBe('REQUIREMENTS_DRAFT'); + expect(result.plan.state.origin).toBe('created-by-specbridge'); + // No SpecBridge metadata inside .kiro files. + for (const file of ['requirements.md', 'design.md', 'tasks.md']) { + expect(readFileSync(path.join(dir, file), 'utf8')).not.toMatch(/specbridge/i); + expect(readFileSync(path.join(dir, file), 'utf8')).not.toMatch(/^---/); + } + }); + + it('creates a design-first feature spec (test 2)', () => { + const workspace = freshWorkspace(); + const result = createSpec(workspace, { name: 'export-pipeline', mode: 'design-first' }, fixedClock); + const dir = result.plan.dir; + expect(readFileSync(path.join(dir, 'design.md'), 'utf8')).toContain('## Alternatives Considered'); + expect(readFileSync(path.join(dir, 'requirements.md'), 'utf8')).toContain( + '> Status: Pending design review.', + ); + expect(result.plan.state.status).toBe('DESIGN_DRAFT'); + expect(Object.keys(result.plan.state.stages)).toEqual(['design', 'requirements', 'tasks']); + }); + + it('creates a quick feature spec with all three documents active (test 3)', () => { + const workspace = freshWorkspace(); + const result = createSpec(workspace, { name: 'healthcheck', mode: 'quick' }, fixedClock); + expect(result.plan.state.status).toBe('READY_FOR_REVIEW'); + expect(result.plan.state.stages.requirements?.status).toBe('draft'); + expect(result.plan.state.stages.design?.status).toBe('draft'); + expect(result.plan.state.stages.tasks?.status).toBe('blocked'); + const tasks = readFileSync(path.join(result.plan.dir, 'tasks.md'), 'utf8'); + expect(tasks).toContain('Review and refine requirements.'); + expect(tasks).toContain('Add automated tests for acceptance criteria.'); + }); + + it('creates a bugfix spec with bugfix.md instead of requirements.md (test 4)', () => { + const workspace = freshWorkspace(); + const result = createSpec( + workspace, + { name: 'cache-fallback', specType: 'bugfix', description: 'Fix stale cache fallback after upstream timeout' }, + fixedClock, + ); + expect(readdirSync(result.plan.dir).sort()).toEqual(['bugfix.md', 'design.md', 'tasks.md']); + const bugfix = readFileSync(path.join(result.plan.dir, 'bugfix.md'), 'utf8'); + expect(bugfix).toContain('# Bugfix Document'); + expect(bugfix).toContain('Fix stale cache fallback after upstream timeout'); + expect(bugfix).toContain('## Unchanged Behavior'); + expect(result.plan.state.status).toBe('BUGFIX_DRAFT'); + expect(Object.keys(result.plan.state.stages)).toEqual(['bugfix', 'design', 'tasks']); + }); + + it('rejects invalid names with all reasons listed (test 5)', () => { + const workspace = freshWorkspace(); + expect(() => planSpecCreation(workspace, { name: 'Payment_Retry' }, fixedClock)).toThrowError( + /underscores[\s\S]*lowercase/i, + ); + }); + + it('rejects path traversal in names (test 6)', () => { + const workspace = freshWorkspace(); + for (const name of ['../escape', 'a/../../b', '..', 'sub/dir']) { + expect(() => planSpecCreation(workspace, { name }, fixedClock)).toThrowError(SpecBridgeError); + } + expect(existsSync(path.join(workspace.rootDir, 'escape'))).toBe(false); + }); + + it('refuses to overwrite an existing spec and lists its files (test 7)', () => { + const root = copyFixtureToTemp('v02-existing-kiro-no-state'); + const workspace = resolveWorkspace(root); + if (workspace === undefined) throw new Error('workspace setup failed'); + try { + planSpecCreation(workspace, { name: 'user-authentication' }, fixedClock); + expect.unreachable('should have thrown'); + } catch (error) { + const specError = error as SpecBridgeError; + expect(specError.code).toBe('SPEC_ALREADY_EXISTS'); + expect(specError.message).toContain('requirements.md'); + expect(specError.message).toContain('spec show user-authentication'); + } + // Nothing was modified. + expect(readdirSync(path.join(root, '.kiro', 'specs', 'user-authentication')).sort()).toEqual([ + 'design.md', + 'requirements.md', + 'tasks.md', + ]); + }); + + it('dry-run planning writes nothing (test 8)', () => { + const workspace = freshWorkspace(); + const plan = planSpecCreation(workspace, { name: 'dry-run-spec', mode: 'quick' }, fixedClock); + expect(plan.files).toHaveLength(3); + expect(plan.state.createdAt).toBe('2026-07-12T10:00:00.000Z'); + expect(existsSync(plan.dir)).toBe(false); + expect(existsSync(path.join(workspace.rootDir, '.specbridge'))).toBe(false); + expect(existsSync(path.join(workspace.rootDir, '.kiro', 'specs'))).toBe(false); + }); + + it('inserts --description into the first document (test 9)', () => { + const workspace = freshWorkspace(); + const description = 'Allow users to choose email and push notification preferences.'; + const result = createSpec(workspace, { name: 'notification-preferences', description }, fixedClock); + const requirements = readFileSync(path.join(result.plan.dir, 'requirements.md'), 'utf8'); + expect(requirements).toContain(description); + expect(requirements).toContain('**Notification Preferences**'); + }); + + it('loads the description from --from-file (test 10)', () => { + const workspace = freshWorkspace(); + writeFileSync(path.join(workspace.rootDir, 'description.md'), 'Retry failed payments with backoff.\n'); + const result = createSpec( + workspace, + { name: 'payment-retry', fromFile: 'description.md', cwd: workspace.rootDir }, + fixedClock, + ); + expect(readFileSync(path.join(result.plan.dir, 'requirements.md'), 'utf8')).toContain( + 'Retry failed payments with backoff.', + ); + }); + + it('preserves UTF-8 description content byte-exactly (test 11)', () => { + const workspace = freshWorkspace(); + const description = 'Préférences de notification — почтовые уведомления (δ-tests).'; + writeFileSync(path.join(workspace.rootDir, 'desc.md'), `${description}\n`, 'utf8'); + const result = createSpec( + workspace, + { name: 'localized-feature', fromFile: 'desc.md', cwd: workspace.rootDir }, + fixedClock, + ); + expect(readFileSync(path.join(result.plan.dir, 'requirements.md'), 'utf8')).toContain(description); + }); + + it('rejects conflicting description inputs (test 12)', () => { + const workspace = freshWorkspace(); + writeFileSync(path.join(workspace.rootDir, 'desc.md'), 'text'); + expect(() => + planSpecCreation( + workspace, + { name: 'x-spec', description: 'inline', fromFile: 'desc.md', cwd: workspace.rootDir }, + fixedClock, + ), + ).toThrowError(/either --description or --from-file/); + }); + + it('rejects description files outside the workspace, directories, and oversized files', () => { + const workspace = freshWorkspace(); + const outside = emptyTempDir(); + writeFileSync(path.join(outside, 'desc.md'), 'outside'); + expect(() => + planSpecCreation( + workspace, + { name: 'a-spec', fromFile: path.join(outside, 'desc.md'), cwd: workspace.rootDir }, + fixedClock, + ), + ).toThrowError(/outside the workspace/); + + mkdirSync(path.join(workspace.rootDir, 'a-directory')); + expect(() => + planSpecCreation( + workspace, + { name: 'a-spec', fromFile: 'a-directory', cwd: workspace.rootDir }, + fixedClock, + ), + ).toThrowError(/directory/); + + writeFileSync(path.join(workspace.rootDir, 'big.md'), 'x'.repeat(64)); + expect(() => + planSpecCreation( + workspace, + { name: 'a-spec', fromFile: 'big.md', cwd: workspace.rootDir, maxDescriptionBytes: 16 }, + fixedClock, + ), + ).toThrowError(/too large/); + + writeFileSync(path.join(workspace.rootDir, 'bad.md'), Buffer.from([0xff, 0xfe, 0x41, 0x00])); + expect(() => + planSpecCreation( + workspace, + { name: 'a-spec', fromFile: 'bad.md', cwd: workspace.rootDir }, + fixedClock, + ), + ).toThrowError(/not valid UTF-8/); + }); + + it('a failed state write rolls the whole creation back (test 13)', () => { + const workspace = freshWorkspace(); + const plan = planSpecCreation(workspace, { name: 'doomed-spec' }, fixedClock); + // Occupy the sidecar state path with a directory so the state write fails. + mkdirSync(plan.statePath, { recursive: true }); + expect(() => executeSpecCreation(workspace, plan)).toThrowError(); + expect(existsSync(plan.dir)).toBe(false); + const tmpDir = path.join(workspace.sidecarDir, 'tmp'); + expect(!existsSync(tmpDir) || readdirSync(tmpDir).length === 0).toBe(true); + }); + + it('a rename collision leaves no partial directory or temp files', () => { + const workspace = freshWorkspace(); + const plan = planSpecCreation(workspace, { name: 'raced-spec' }, fixedClock); + // Another process creates the spec between planning and execution. + mkdirSync(plan.dir, { recursive: true }); + writeFileSync(path.join(plan.dir, 'existing.md'), 'kept'); + expect(() => executeSpecCreation(workspace, plan)).toThrowError(/already exists/); + expect(readdirSync(plan.dir)).toEqual(['existing.md']); + const tmpDir = path.join(workspace.sidecarDir, 'tmp'); + expect(!existsSync(tmpDir) || readdirSync(tmpDir).length === 0).toBe(true); + }); +}); diff --git a/tests/workflow/stale-approval.test.ts b/tests/workflow/stale-approval.test.ts new file mode 100644 index 0000000..421d321 --- /dev/null +++ b/tests/workflow/stale-approval.test.ts @@ -0,0 +1,180 @@ +import { appendFileSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; +import type { WorkspaceInfo } from '@specbridge/core'; +import { readSpecState, resolveWorkspace } from '@specbridge/core'; +import { analyzeSpec, requireSpec } from '@specbridge/compat-kiro'; +import { approveStage, createSpec, evaluateWorkflow } from '@specbridge/workflow'; +import { copyFixtureToTemp, emptyTempDir, fixedClock } from '../helpers.js'; + +function workspaceOf(root: string): WorkspaceInfo { + const workspace = resolveWorkspace(root); + if (workspace === undefined) throw new Error(`no workspace at ${root}`); + return workspace; +} + +const VALID_REQUIREMENTS = `# Requirements Document + +## Introduction + +Stale-approval test content. + +## Requirements + +### Requirement 1: Behavior + +**User Story:** As a user, I want stable behavior, so that approvals mean something. + +#### Acceptance Criteria + +1. WHEN the action runs, THE SYSTEM SHALL produce the documented result. +2. IF the backend fails, THEN THE SYSTEM SHALL return an actionable error. + +## Out of Scope + +- Everything else. + +## Non-Functional Requirements + +- Security: authenticated users only. +`; + +describe('approval hash invalidation', () => { + it('detects a one-byte modification of an approved file (test 34)', () => { + const root = emptyTempDir(); + mkdirSync(path.join(root, '.kiro')); + const setup = workspaceOf(root); + createSpec(setup, { name: 'stale-spec' }, fixedClock); + const workspace = workspaceOf(root); + const file = path.join(root, '.kiro', 'specs', 'stale-spec', 'requirements.md'); + writeFileSync(file, VALID_REQUIREMENTS); + const spec = analyzeSpec(workspace, requireSpec(workspace, 'stale-spec')); + const approved = approveStage(workspace, spec, { stage: 'requirements' }, { clock: fixedClock }); + expect(approved.ok).toBe(true); + + let state = readSpecState(workspace, 'stale-spec').state; + if (state === undefined) throw new Error('state missing'); + expect(evaluateWorkflow(workspace, state).health).toBe('ok'); + + appendFileSync(file, 'x'); + state = readSpecState(workspace, 'stale-spec').state; + if (state === undefined) throw new Error('state missing'); + const evaluation = evaluateWorkflow(workspace, state); + expect(evaluation.health).toBe('stale'); + expect(evaluation.effectiveStatus).toBe('STALE_APPROVAL'); + expect(evaluation.staleStages).toEqual(['requirements']); + expect( + evaluation.stages.find((s) => s.stage === 'requirements')?.effective, + ).toBe('modified-after-approval'); + expect(evaluation.diagnostics.some((d) => d.code === 'APPROVAL_STALE')).toBe(true); + }); + + it('downstream approvals become effectively stale (test 35)', () => { + const root = copyFixtureToTemp('v02-stale-requirements-approval'); + const workspace = workspaceOf(root); + const state = readSpecState(workspace, 'payment-retry').state; + if (state === undefined) throw new Error('fixture state invalid'); + const evaluation = evaluateWorkflow(workspace, state); + + expect(evaluation.health).toBe('stale'); + expect(evaluation.staleStages).toEqual(['requirements']); + expect(evaluation.invalidatedStages).toEqual(['design']); + expect(evaluation.stages.find((s) => s.stage === 'design')?.effective).toBe('stale-prerequisite'); + // The draft tasks stage is effectively blocked behind the stale chain. + expect(evaluation.stages.find((s) => s.stage === 'tasks')?.effective).toBe('blocked'); + expect(evaluation.diagnostics.some((d) => d.code === 'APPROVAL_DEPENDENT_STALE')).toBe(true); + }); + + it('approving on top of a stale prerequisite is blocked', () => { + const root = copyFixtureToTemp('v02-stale-requirements-approval'); + const workspace = workspaceOf(root); + const spec = analyzeSpec(workspace, requireSpec(workspace, 'payment-retry')); + const result = approveStage(workspace, spec, { stage: 'tasks' }, { clock: fixedClock }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.reason).toBe('prerequisites-unmet'); + expect(result.stalePrerequisites).toContain('requirements'); + expect(result.message).toContain('changed after approval'); + } + }); + + it('read-only evaluation does not mutate the state file (test 36)', () => { + const root = copyFixtureToTemp('v02-stale-requirements-approval'); + const workspace = workspaceOf(root); + const statePath = path.join(root, '.specbridge', 'state', 'specs', 'payment-retry.json'); + const before = readFileSync(statePath); + + const state = readSpecState(workspace, 'payment-retry').state; + if (state === undefined) throw new Error('fixture state invalid'); + evaluateWorkflow(workspace, state); + evaluateWorkflow(workspace, state); + + expect(readFileSync(statePath).equals(before)).toBe(true); + }); + + it('reapproval clears the stale status and invalidates dependents persistently (test 37)', () => { + const root = copyFixtureToTemp('v02-stale-requirements-approval'); + const workspace = workspaceOf(root); + const spec = analyzeSpec(workspace, requireSpec(workspace, 'payment-retry')); + const result = approveStage(workspace, spec, { stage: 'requirements' }, { clock: fixedClock }); + expect(result.ok).toBe(true); + if (result.ok && result.action === 'approved') { + expect(result.reapproved).toBe(true); + // Design was approved against the old requirements — no longer valid. + expect(result.invalidated).toEqual(['design']); + } + const state = readSpecState(workspace, 'payment-retry').state; + if (state === undefined) throw new Error('state missing'); + const evaluation = evaluateWorkflow(workspace, state); + expect(evaluation.health).toBe('ok'); + expect(evaluation.stages.find((s) => s.stage === 'requirements')?.effective).toBe('approved'); + expect(state.stages.design?.status).toBe('draft'); + expect(state.stages.design?.approvedHash).toBeNull(); + }); + + it('CRLF file hashes are stable across repeated evaluation (test 38)', () => { + const root = emptyTempDir(); + mkdirSync(path.join(root, '.kiro')); + const setup = workspaceOf(root); + createSpec(setup, { name: 'crlf-spec' }, fixedClock); + const workspace = workspaceOf(root); + const file = path.join(root, '.kiro', 'specs', 'crlf-spec', 'requirements.md'); + writeFileSync(file, VALID_REQUIREMENTS.replace(/\n/g, '\r\n')); + + const spec = analyzeSpec(workspace, requireSpec(workspace, 'crlf-spec')); + const approved = approveStage(workspace, spec, { stage: 'requirements' }, { clock: fixedClock }); + expect(approved.ok).toBe(true); + + const state = readSpecState(workspace, 'crlf-spec').state; + if (state === undefined) throw new Error('state missing'); + for (let i = 0; i < 3; i += 1) { + const evaluation = evaluateWorkflow(workspace, state); + expect(evaluation.health).toBe('ok'); + } + // The file still has its CRLF endings — approval reads, never rewrites. + expect(readFileSync(file, 'utf8')).toContain('\r\n'); + }); + + it('UTF-8 multibyte content hashes are stable (test 39)', () => { + const root = emptyTempDir(); + mkdirSync(path.join(root, '.kiro')); + const setup = workspaceOf(root); + createSpec(setup, { name: 'utf8-spec' }, fixedClock); + const workspace = workspaceOf(root); + const file = path.join(root, '.kiro', 'specs', 'utf8-spec', 'requirements.md'); + writeFileSync( + file, + VALID_REQUIREMENTS.replace( + 'Stale-approval test content.', + 'Préférences de notification — уведомления (δοκιμή).', + ), + ); + + const spec = analyzeSpec(workspace, requireSpec(workspace, 'utf8-spec')); + const approved = approveStage(workspace, spec, { stage: 'requirements' }, { clock: fixedClock }); + expect(approved.ok).toBe(true); + const state = readSpecState(workspace, 'utf8-spec').state; + if (state === undefined) throw new Error('state missing'); + expect(evaluateWorkflow(workspace, state).health).toBe('ok'); + }); +}); diff --git a/tests/workflow/state.test.ts b/tests/workflow/state.test.ts new file mode 100644 index 0000000..3477635 --- /dev/null +++ b/tests/workflow/state.test.ts @@ -0,0 +1,147 @@ +import { mkdirSync, readFileSync } from 'node:fs'; +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; +import type { WorkspaceInfo } from '@specbridge/core'; +import { + readSpecState, + resolveWorkspace, + specWorkflowStateSchema, +} from '@specbridge/core'; +import { analyzeSpec, discoverSpecs, requireSpec } from '@specbridge/compat-kiro'; +import { approveStage, auditSidecarState, createSpec } from '@specbridge/workflow'; +import { copyFixtureToTemp, emptyTempDir, fixedClock } from '../helpers.js'; + +function workspaceOf(root: string): WorkspaceInfo { + const workspace = resolveWorkspace(root); + if (workspace === undefined) throw new Error(`no workspace at ${root}`); + return workspace; +} + +describe('sidecar state lifecycle', () => { + it('spec new creates a state file that validates against the schema (tests 14, 15)', () => { + const root = emptyTempDir(); + mkdirSync(path.join(root, '.kiro')); + const workspace = workspaceOf(root); + const result = createSpec(workspace, { name: 'fresh-spec' }, fixedClock); + + const raw = JSON.parse(readFileSync(result.statePath, 'utf8')) as unknown; + const parsed = specWorkflowStateSchema.safeParse(raw); + expect(parsed.success).toBe(true); + if (parsed.success) { + expect(parsed.data.schemaVersion).toBe('1.0.0'); + expect(parsed.data.origin).toBe('created-by-specbridge'); + expect(parsed.data.createdAt).toBe('2026-07-12T10:00:00.000Z'); + } + }); + + it('initial states are correct for every mode (test 16)', () => { + const root = emptyTempDir(); + mkdirSync(path.join(root, '.kiro')); + const workspace = workspaceOf(root); + + const requirementsFirst = createSpec(workspace, { name: 'a-req', mode: 'requirements-first' }, fixedClock); + expect(requirementsFirst.plan.state.status).toBe('REQUIREMENTS_DRAFT'); + expect(requirementsFirst.plan.state.stages.requirements?.status).toBe('draft'); + expect(requirementsFirst.plan.state.stages.design?.status).toBe('blocked'); + expect(requirementsFirst.plan.state.stages.tasks?.status).toBe('blocked'); + + const designFirst = createSpec(workspace, { name: 'a-design', mode: 'design-first' }, fixedClock); + expect(designFirst.plan.state.status).toBe('DESIGN_DRAFT'); + expect(designFirst.plan.state.stages.design?.status).toBe('draft'); + expect(designFirst.plan.state.stages.requirements?.status).toBe('blocked'); + + const quick = createSpec(workspace, { name: 'a-quick', mode: 'quick' }, fixedClock); + expect(quick.plan.state.status).toBe('READY_FOR_REVIEW'); + expect(quick.plan.state.stages.requirements?.status).toBe('draft'); + expect(quick.plan.state.stages.design?.status).toBe('draft'); + expect(quick.plan.state.stages.tasks?.status).toBe('blocked'); + + const bugfix = createSpec(workspace, { name: 'a-bugfix', specType: 'bugfix' }, fixedClock); + expect(bugfix.plan.state.status).toBe('BUGFIX_DRAFT'); + expect(bugfix.plan.state.stages.bugfix?.status).toBe('draft'); + }); + + it('an existing Kiro spec works with no sidecar state at all (test 17)', () => { + const root = copyFixtureToTemp('v02-existing-kiro-no-state'); + const workspace = workspaceOf(root); + const spec = analyzeSpec(workspace, requireSpec(workspace, 'user-authentication')); + expect(spec.classification.type).toBe('feature'); + expect(spec.classification.workflowMode).toBe('unknown'); + expect(spec.state).toBeUndefined(); + const stateRead = readSpecState(workspace, 'user-authentication'); + expect(stateRead.exists).toBe(false); + expect(stateRead.diagnostics).toEqual([]); + }); + + it('the first approval initializes state for an existing Kiro spec (test 18)', () => { + const root = copyFixtureToTemp('v02-existing-kiro-no-state'); + const workspace = workspaceOf(root); + const spec = analyzeSpec(workspace, requireSpec(workspace, 'user-authentication')); + + const result = approveStage(workspace, spec, { stage: 'requirements' }, { clock: fixedClock }); + expect(result.ok).toBe(true); + if (result.ok && result.action === 'approved') { + expect(result.initialized).toBe(true); + expect(result.state.origin).toBe('existing-kiro-workspace'); + expect(result.state.workflowMode).toBe('requirements-first'); + expect(result.state.specType).toBe('feature'); + } + const stateRead = readSpecState(workspace, 'user-authentication'); + expect(stateRead.state?.stages.requirements?.status).toBe('approved'); + }); + + it('design-first is inferred when design is approved first on an unmanaged spec', () => { + const root = copyFixtureToTemp('v02-existing-kiro-no-state'); + const workspace = workspaceOf(root); + const spec = analyzeSpec(workspace, requireSpec(workspace, 'user-authentication')); + const result = approveStage(workspace, spec, { stage: 'design' }, { clock: fixedClock }); + expect(result.ok).toBe(true); + if (result.ok && result.action === 'approved') { + expect(result.state.workflowMode).toBe('design-first'); + } + }); + + it('approving tasks first on an unmanaged spec is refused with guidance', () => { + const root = copyFixtureToTemp('v02-existing-kiro-no-state'); + const workspace = workspaceOf(root); + const spec = analyzeSpec(workspace, requireSpec(workspace, 'user-authentication')); + const result = approveStage(workspace, spec, { stage: 'tasks' }, { clock: fixedClock }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.reason).toBe('initialization-unsupported'); + expect(result.message).toContain('Approve "requirements" or "design" first'); + } + // No state file was created by the refused command. + expect(readSpecState(workspace, 'user-authentication').exists).toBe(false); + }); + + it('invalid sidecar state degrades to diagnostics, never a crash (test 19)', () => { + const root = copyFixtureToTemp('v02-invalid-sidecar'); + const workspace = workspaceOf(root); + + const broken = readSpecState(workspace, 'broken-state'); + expect(broken.state).toBeUndefined(); + expect(broken.diagnostics[0]?.code).toBe('SIDECAR_STATE_INVALID_JSON'); + + const wrongShape = readSpecState(workspace, 'wrong-shape'); + expect(wrongShape.state).toBeUndefined(); + expect(wrongShape.diagnostics[0]?.code).toBe('SIDECAR_STATE_INVALID_SHAPE'); + + const legacy = readSpecState(workspace, 'legacy-shape'); + expect(legacy.state).toBeUndefined(); + expect(legacy.diagnostics[0]?.code).toBe('SIDECAR_STATE_LEGACY'); + + // Analysis still works: the spec is treated as unmanaged. + const spec = analyzeSpec(workspace, requireSpec(workspace, 'broken-state')); + expect(spec.classification.type).toBe('feature'); + }); + + it('orphan sidecar state is detected by the audit (test 20)', () => { + const root = copyFixtureToTemp('v02-orphan-sidecar'); + const workspace = workspaceOf(root); + const audit = auditSidecarState(workspace, discoverSpecs(workspace)); + expect(audit.orphanStates).toEqual(['ghost-spec']); + expect(audit.unmanagedSpecs).toEqual(['real-spec']); + expect(audit.diagnostics.some((d) => d.code === 'SIDECAR_STATE_ORPHAN')).toBe(true); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index d5664ac..5b45dab 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -7,7 +7,8 @@ "@specbridge/compat-kiro": ["./packages/compat-kiro/src/index.ts"], "@specbridge/drift": ["./packages/drift/src/index.ts"], "@specbridge/runners": ["./packages/runners/src/index.ts"], - "@specbridge/reporting": ["./packages/reporting/src/index.ts"] + "@specbridge/reporting": ["./packages/reporting/src/index.ts"], + "@specbridge/workflow": ["./packages/workflow/src/index.ts"] } }, "include": [ diff --git a/vitest.config.ts b/vitest.config.ts index c58e3c9..e974822 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -13,10 +13,14 @@ export default defineConfig({ { find: '@specbridge/drift', replacement: pkg('drift') }, { find: '@specbridge/runners', replacement: pkg('runners') }, { find: '@specbridge/reporting', replacement: pkg('reporting') }, + { find: '@specbridge/workflow', replacement: pkg('workflow') }, ], }, test: { include: ['tests/**/*.test.ts'], environment: 'node', + // CLI output assertions must see the exact text users see with NO_COLOR; + // picocolors would otherwise force ANSI codes on Windows terminals. + env: { NO_COLOR: '1' }, }, });