diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml
index 89527a58..aed5f880 100644
--- a/.github/workflows/codeql.yml
+++ b/.github/workflows/codeql.yml
@@ -56,6 +56,12 @@ jobs:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
queries: security-extended
+ # tests/security/fixtures/** are DELIBERATELY vulnerable planted-vuln
+ # samples that the security enforcement-matrix suite scans/asserts on;
+ # scanning them here just re-flags the intentional issues.
+ config: |
+ paths-ignore:
+ - tests/security/fixtures
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@ce28f5bb42b7a9f2c824e633a3f6ee835bab6858 # v3.29.0
diff --git a/.github/workflows/helm-chart.yml b/.github/workflows/helm-chart.yml
index 623ee85f..c12da202 100644
--- a/.github/workflows/helm-chart.yml
+++ b/.github/workflows/helm-chart.yml
@@ -116,6 +116,15 @@ jobs:
docker build -t forge/$svc:0.1.0 -f deploy/docker/$svc.Dockerfile .
done
+ # The chart declares postgresql/redis/minio subchart deps in Chart.yaml, so
+ # `helm install`/`upgrade` needs them vendored into charts/ before it runs —
+ # even though the kind overlay disables them (it stands up external in-cluster
+ # datastores). Without this, install fails "missing in charts/ directory".
+ - name: Add subchart repos + build chart dependencies
+ run: |
+ helm repo add bitnami https://charts.bitnami.com/bitnami
+ helm dependency build ${CHART}
+
- name: Run kind smoke tests (install + helm test + upgrade/rollback)
env:
FORGE_KIND_CLUSTER: forge-ci # reuse the kind-action cluster
diff --git a/apps/api/forge_api/auth/service.py b/apps/api/forge_api/auth/service.py
index eaaf6885..d3a80276 100644
--- a/apps/api/forge_api/auth/service.py
+++ b/apps/api/forge_api/auth/service.py
@@ -371,6 +371,7 @@ def resolve_model_client(
workspace_id: uuid.UUID,
*,
secret_id: uuid.UUID | None = None,
+ model: str | None = None,
redactor: Callable[[str], str] = redact_text,
) -> ModelClient:
"""Resolve a provider-agnostic BYOK :class:`ModelClient` for a workspace.
@@ -382,9 +383,16 @@ def resolve_model_client(
never logged. The injected ``redactor`` scrubs any provider exception before
it is re-raised as ``ModelClientError``.
+ ``model`` overrides the env-configured model name — used by the
+ Adaptive Orchestration model router (``ao-model-router``) to bind a
+ tier-resolved model onto the workspace's provider/key without touching
+ any other client knob.
+
Raises ``ModelClientError`` when no provider is configured, and
``ModelClientUnavailable`` when the provider SDK extra is not installed.
"""
+ import dataclasses
+
from forge_agent.providers import ModelClientConfig, ModelClientError, build_model_client
if secret_id is not None:
@@ -405,6 +413,8 @@ def resolve_model_client(
"no model provider configured; set FORGE_MODEL_PROVIDER and a BYOK "
"key (env or vault under MODEL_PROVIDER, + FORGE_MODEL_NAME for OpenAI)"
)
+ if model:
+ config = dataclasses.replace(config, model=model)
return build_model_client(config, redactor=redactor)
# -- OAuth descriptor --------------------------------------------------- #
diff --git a/apps/api/forge_api/routers/saml.py b/apps/api/forge_api/routers/saml.py
index 931da178..3913e21e 100644
--- a/apps/api/forge_api/routers/saml.py
+++ b/apps/api/forge_api/routers/saml.py
@@ -109,8 +109,21 @@ def _require_enabled(config: SsoConfiguration) -> None:
def _safe_next(target: str | None) -> str:
- """Only same-origin relative paths are honoured (open-redirect guard)."""
- if target and target.startswith("/") and not target.startswith("//"):
+ """Only same-origin relative paths are honoured (open-redirect guard).
+
+ Rejects protocol-relative (``//host``) and backslash-normalised
+ (``/\\host``) targets — browsers treat ``\\`` as ``/``, so ``/\\evil.com``
+ would otherwise redirect off-origin — plus any embedded control characters.
+ """
+ if (
+ target
+ and target.startswith("/")
+ and not target.startswith("//")
+ and "\\" not in target
+ and "\r" not in target
+ and "\n" not in target
+ and "\t" not in target
+ ):
return target
return "/"
diff --git a/apps/api/forge_api/routers/spec.py b/apps/api/forge_api/routers/spec.py
index 58705a73..56533678 100644
--- a/apps/api/forge_api/routers/spec.py
+++ b/apps/api/forge_api/routers/spec.py
@@ -16,29 +16,43 @@
import uuid
from collections.abc import Iterator
from contextlib import contextmanager
+from dataclasses import dataclass
from functools import lru_cache
from pathlib import Path
-from typing import Annotated
+from typing import Annotated, Any
from fastapi import APIRouter, Depends, HTTPException, status
+from fastapi.responses import PlainTextResponse
from pydantic import BaseModel, Field
from forge_api.auth.rbac import Permission
from forge_api.deps import DbSession, Principal, get_current_principal
from forge_api.routers._rbac import require_permission
from forge_api.routers.board import BoardServiceDep
+from forge_api.services import spec_version_service
+from forge_api.services.spec_draft_service import SpecDraft, draft_spec
+from forge_api.services.spec_import_service import SpecImport, SpecImportRequest, import_spec
from forge_api.settings import get_settings
from forge_contracts import (
BoardFilter,
Constitution,
+ ModelClient,
Requirement,
SpecManifest,
TaskDTO,
ValidationReport,
)
from forge_contracts.exceptions import SpecGateError
-from forge_db.models import Project
-from forge_spec import FileSpecEngine, SpecNotFoundError
+from forge_db.models import Project, SpecVersion
+from forge_orchestration_policy import Tier
+from forge_spec import (
+ FileSpecEngine,
+ ManifestDiff,
+ SpecNotFoundError,
+ diff_manifest,
+ diff_markdown,
+ spec_id_for_key,
+)
router = APIRouter(
prefix="/spec",
@@ -116,6 +130,33 @@ def _spec_errors() -> Iterator[None]:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
+def _record_version(
+ engine: FileSpecEngine,
+ db: DbSession,
+ principal: Principal,
+ manifest: SpecManifest,
+) -> SpecVersion:
+ """Snapshot ``manifest`` (+ its rendered serializations) as the next version.
+
+ Called after every successful save (``spec_create`` / ``write_manifest`` /
+ ``write_spec_markdown`` / ``write_spec_manifest_yaml``); reads back the
+ just-persisted ``spec.md``/``manifest.yaml`` (always in sync post-save)
+ rather than re-rendering them independently, so the recorded snapshot is
+ byte-identical to what a reader of the engine sees right now.
+ """
+ spec_id = spec_id_for_key(manifest.id)
+ spec_md = engine.read_spec_md(spec_id)
+ manifest_yaml = engine.read_manifest_yaml(spec_id)
+ return spec_version_service.record_version(
+ db,
+ workspace_id=principal.workspace_id,
+ manifest=manifest,
+ spec_md=spec_md,
+ manifest_yaml=manifest_yaml,
+ created_by=principal.user_id,
+ )
+
+
class ConstitutionInitRequest(BaseModel):
"""Body for ``POST /spec/constitution``."""
@@ -131,6 +172,21 @@ class SpecCreateRequest(BaseModel):
requirements: list[Requirement] = Field(default_factory=list)
+class TextContent(BaseModel):
+ """Body for the ``spec.md`` / ``manifest.yaml`` write endpoints."""
+
+ content: str
+
+
+class DraftSpecRequest(BaseModel):
+ """Body for ``POST /spec/draft`` (BYOK AI spec drafting; draft-only)."""
+
+ goal: str = Field(min_length=1, description="One-line engineering goal to draft a spec for.")
+ epic_id: uuid.UUID | None = None
+ #: Optional project whose constitution seeds the spec-authoring prompt.
+ project_id: uuid.UUID | None = None
+
+
# --------------------------------------------------------------------------- #
# Routes #
# --------------------------------------------------------------------------- #
@@ -153,9 +209,16 @@ def constitution_init(engine: EngineDep, request: ConstitutionInitRequest) -> Co
status_code=status.HTTP_201_CREATED,
dependencies=[WriteGate],
)
-def spec_create(engine: EngineDep, request: SpecCreateRequest) -> SpecManifest:
- """Create a draft spec for an epic."""
- return engine.spec_create(request.epic_id, request.name, request.requirements)
+def spec_create(
+ engine: EngineDep,
+ request: SpecCreateRequest,
+ db: DbSession,
+ principal: Annotated[Principal, Depends(get_current_principal)],
+) -> SpecManifest:
+ """Create a draft spec for an epic (recorded as version 1)."""
+ manifest = engine.spec_create(request.epic_id, request.name, request.requirements)
+ _record_version(engine, db, principal, manifest)
+ return manifest
@router.get("/specs/{spec_id}", response_model=SpecManifest, dependencies=[ReadGate])
@@ -166,10 +229,97 @@ def read_manifest(engine: EngineDep, spec_id: uuid.UUID) -> SpecManifest:
@router.put("/specs/{spec_id}", response_model=SpecManifest, dependencies=[WriteGate])
-def write_manifest(engine: EngineDep, spec_id: uuid.UUID, manifest: SpecManifest) -> SpecManifest:
- """Persist (create or update) a spec manifest."""
+def write_manifest(
+ engine: EngineDep,
+ spec_id: uuid.UUID,
+ manifest: SpecManifest,
+ db: DbSession,
+ principal: Annotated[Principal, Depends(get_current_principal)],
+) -> SpecManifest:
+ """Persist (create or update) a spec manifest; records a new version."""
+ with _spec_errors():
+ updated = engine.write_manifest(manifest)
+ _record_version(engine, db, principal, updated)
+ return updated
+
+
+@router.get(
+ "/specs/{spec_id}/markdown",
+ dependencies=[ReadGate],
+ response_class=PlainTextResponse,
+)
+def read_spec_markdown(engine: EngineDep, spec_id: uuid.UUID) -> PlainTextResponse:
+ """Read the spec's ``spec.md`` prose serialization (always kept in sync)."""
with _spec_errors():
- return engine.write_manifest(manifest)
+ text = engine.read_spec_md(spec_id)
+ return PlainTextResponse(text)
+
+
+@router.put("/specs/{spec_id}/markdown", response_model=SpecManifest, dependencies=[WriteGate])
+def write_spec_markdown(
+ engine: EngineDep,
+ spec_id: uuid.UUID,
+ body: TextContent,
+ db: DbSession,
+ principal: Annotated[Principal, Depends(get_current_principal)],
+) -> SpecManifest:
+ """Save a spec edited as ``spec.md`` prose; re-renders ``manifest.yaml`` to match.
+
+ The spec being edited must already exist at ``spec_id`` (404 otherwise);
+ the document's own frontmatter id governs which spec is written, mirroring
+ ``PUT /spec/specs/{spec_id}``. Records a new version on success.
+ """
+ with _spec_errors():
+ engine.read_manifest(spec_id)
+ updated = engine.save_spec_md(body.content)
+ _record_version(engine, db, principal, updated)
+ return updated
+
+
+@router.get(
+ "/specs/{spec_id}/manifest",
+ dependencies=[ReadGate],
+ response_class=PlainTextResponse,
+)
+def read_spec_manifest_yaml(engine: EngineDep, spec_id: uuid.UUID) -> PlainTextResponse:
+ """Read the spec's ``manifest.yaml`` serialization (always kept in sync)."""
+ with _spec_errors():
+ text = engine.read_manifest_yaml(spec_id)
+ return PlainTextResponse(text)
+
+
+@router.put("/specs/{spec_id}/manifest", response_model=SpecManifest, dependencies=[WriteGate])
+def write_spec_manifest_yaml(
+ engine: EngineDep,
+ spec_id: uuid.UUID,
+ body: TextContent,
+ db: DbSession,
+ principal: Annotated[Principal, Depends(get_current_principal)],
+) -> SpecManifest:
+ """Save a spec edited as ``manifest.yaml``; re-renders ``spec.md`` to match.
+
+ Unlike the markdown endpoint, this may also *create* a new spec: when no
+ spec resolves to ``spec_id`` yet, the YAML's own id governs where it is
+ written (mirroring ``PUT /spec/specs/{spec_id}``'s create-or-update
+ semantics). Records a new version on success.
+ """
+ with _spec_errors():
+ updated = engine.save_manifest_yaml(body.content)
+ _record_version(engine, db, principal, updated)
+ return updated
+
+
+@router.get(
+ "/constitution/{project_id}",
+ response_model=Constitution,
+ dependencies=[ReadGate],
+)
+def read_constitution(engine: EngineDep, project_id: uuid.UUID) -> Constitution:
+ """Read a project's constitution; 404 if it was never initialised."""
+ constitution = engine.read_constitution(project_id)
+ if constitution is None:
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="constitution not found")
+ return constitution
@router.post("/specs/{spec_id}/clarify", response_model=SpecManifest, dependencies=[WriteGate])
@@ -211,6 +361,237 @@ def validate(engine: EngineDep, task_id: uuid.UUID) -> ValidationReport:
return engine.validate(task_id)
+# --------------------------------------------------------------------------- #
+# ss-versioning: spec version history + diff #
+# --------------------------------------------------------------------------- #
+#
+# A version is recorded (see ``_record_version``) on every save through the
+# editing endpoints above. These read-only routes list a spec's version
+# history and diff any two of its versions — both the raw ``spec.md`` prose
+# (line-level) and the structured manifest (id-keyed adds/removes/changes per
+# list field). Versions are looked up by ``spec_id`` (the same deterministic
+# uuid as everywhere else in this router) + a 1-based ``version_number``.
+
+
+class SpecVersionSummary(BaseModel):
+ """One row of a spec's version history (no snapshot payload)."""
+
+ version_number: int
+ name: str
+ status: str
+ created_at: str
+ created_by: uuid.UUID | None = None
+
+
+class SpecVersionDetail(SpecVersionSummary):
+ """A single version's full snapshot."""
+
+ manifest: SpecManifest
+ spec_md: str
+ manifest_yaml: str
+
+
+class SpecVersionDiff(BaseModel):
+ """The diff between two versions of a spec."""
+
+ from_version: int
+ to_version: int
+ markdown: list[Any] = Field(default_factory=list)
+ manifest: ManifestDiff
+
+
+def _version_summary(version: SpecVersion) -> SpecVersionSummary:
+ return SpecVersionSummary(
+ version_number=version.version_number,
+ name=version.name,
+ status=version.status,
+ created_at=version.created_at.isoformat(),
+ created_by=version.created_by,
+ )
+
+
+@router.get(
+ "/specs/{spec_id}/versions",
+ response_model=list[SpecVersionSummary],
+ dependencies=[ReadGate],
+)
+def list_spec_versions(
+ spec_id: uuid.UUID,
+ principal: Annotated[Principal, Depends(get_current_principal)],
+ db: DbSession,
+) -> list[SpecVersionSummary]:
+ """List a spec's versions, newest first (empty if never saved)."""
+ versions = spec_version_service.list_versions(
+ db, workspace_id=principal.workspace_id, spec_id=spec_id
+ )
+ return [_version_summary(v) for v in versions]
+
+
+def _get_version_or_404(
+ db: DbSession, principal: Principal, spec_id: uuid.UUID, version_number: int
+) -> SpecVersion:
+ version = spec_version_service.get_version(
+ db, workspace_id=principal.workspace_id, spec_id=spec_id, version_number=version_number
+ )
+ if version is None:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail=f"spec version {version_number} not found",
+ )
+ return version
+
+
+@router.get(
+ "/specs/{spec_id}/versions/{version_number}",
+ response_model=SpecVersionDetail,
+ dependencies=[ReadGate],
+)
+def read_spec_version(
+ spec_id: uuid.UUID,
+ version_number: int,
+ principal: Annotated[Principal, Depends(get_current_principal)],
+ db: DbSession,
+) -> SpecVersionDetail:
+ """Read one version's full snapshot (manifest + both serializations)."""
+ version = _get_version_or_404(db, principal, spec_id, version_number)
+ return SpecVersionDetail(
+ **_version_summary(version).model_dump(),
+ manifest=SpecManifest.model_validate(version.manifest),
+ spec_md=version.spec_md,
+ manifest_yaml=version.manifest_yaml,
+ )
+
+
+@router.get(
+ "/specs/{spec_id}/versions/{from_version}/diff/{to_version}",
+ response_model=SpecVersionDiff,
+ dependencies=[ReadGate],
+)
+def diff_spec_versions(
+ spec_id: uuid.UUID,
+ from_version: int,
+ to_version: int,
+ principal: Annotated[Principal, Depends(get_current_principal)],
+ db: DbSession,
+) -> SpecVersionDiff:
+ """Diff two versions of a spec: line-level markdown + structured manifest."""
+ older = _get_version_or_404(db, principal, spec_id, from_version)
+ newer = _get_version_or_404(db, principal, spec_id, to_version)
+ markdown_diff = diff_markdown(older.spec_md, newer.spec_md)
+ manifest_diff = diff_manifest(
+ SpecManifest.model_validate(older.manifest), SpecManifest.model_validate(newer.manifest)
+ )
+ return SpecVersionDiff(
+ from_version=from_version,
+ to_version=to_version,
+ markdown=[line.model_dump() for line in markdown_diff],
+ manifest=manifest_diff,
+ )
+
+
+# --------------------------------------------------------------------------- #
+# ss-draft: BYOK AI spec drafting (POST /spec/draft) #
+# --------------------------------------------------------------------------- #
+#
+# Uses the ao-model-router to pick a model for the workspace's BYOK provider,
+# resolves the HARD-02 ModelClient (env/vault key) bound to that model, and
+# streams a spec.md draft seeded with the project constitution. Draft-only:
+# nothing is persisted. The binding is a single overridable dependency so tests
+# inject a mocked ModelClient (no live key / network).
+
+#: The Adaptive Orchestration tier used for spec authoring. Drafting a spec is
+#: high-leverage work, so it routes to the senior model by default.
+_DRAFT_TIER: Tier = "senior"
+
+
+@dataclass(frozen=True)
+class DraftModelBinding:
+ """A resolved model client + the router-chosen model for a draft call."""
+
+ client: ModelClient
+ model: str
+
+
+def get_draft_binding(
+ principal: Annotated[Principal, Depends(get_current_principal)],
+) -> DraftModelBinding:
+ """Resolve the BYOK model client + router-chosen model for spec drafting.
+
+ The provider comes from the workspace's ``FORGE_MODEL_*`` env config; the
+ ``ao-model-router`` maps the spec-authoring tier to a concrete model on that
+ provider; the HARD-02 client is then resolved with the workspace's BYOK key
+ bound to that model. Overridden in tests to inject a mocked client.
+ """
+ from forge_agent.providers import ModelClientConfig, ModelClientError
+ from forge_agent.providers.router import ModelRouter
+ from forge_api.auth.service import get_auth_service
+
+ config = ModelClientConfig.from_env()
+ if config is None:
+ raise HTTPException(
+ status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
+ detail=(
+ "no model provider configured for spec drafting; set FORGE_MODEL_PROVIDER "
+ "and a BYOK key"
+ ),
+ )
+ model = ModelRouter(provider=config.provider).resolve(_DRAFT_TIER)
+ try:
+ client = get_auth_service().resolve_model_client(principal.workspace_id, model=model)
+ except ModelClientError as exc:
+ raise HTTPException(
+ status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=str(exc)
+ ) from exc
+ return DraftModelBinding(client=client, model=model)
+
+
+DraftBindingDep = Annotated[DraftModelBinding, Depends(get_draft_binding)]
+
+
+@router.post("/draft", response_model=SpecDraft, dependencies=[WriteGate])
+def draft_spec_endpoint(
+ engine: EngineDep, binding: DraftBindingDep, request: DraftSpecRequest
+) -> SpecDraft:
+ """Draft a ``spec.md`` from a one-line goal via the BYOK model (draft-only).
+
+ Seeds the spec-authoring prompt with the project constitution (when a
+ ``project_id`` resolving to one is supplied), streams the draft, and returns
+ a parsed :class:`SpecManifest` preview plus token/cost accounting. Nothing is
+ persisted — a human refines the draft via the spec-editing endpoints.
+ """
+ from forge_agent.providers import ModelClientError
+
+ constitution = (
+ engine.read_constitution(request.project_id) if request.project_id is not None else None
+ )
+ try:
+ return draft_spec(
+ binding.client,
+ goal=request.goal,
+ model=binding.model,
+ constitution=constitution,
+ epic_id=request.epic_id,
+ )
+ except ModelClientError as exc:
+ raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc
+
+
+# --------------------------------------------------------------------------- #
+# ss-import: external spec import (POST /spec/import) #
+# --------------------------------------------------------------------------- #
+#
+# Turns an existing markdown or YAML spec (pasted or uploaded from outside
+# Forge) into a spec.md draft — parse/normalize only, no model call. Draft-only
+# like ``POST /spec/draft``: nothing is persisted; a human refines the result
+# via the normal spec-editing endpoints.
+
+
+@router.post("/import", response_model=SpecImport, dependencies=[WriteGate])
+def import_spec_endpoint(request: SpecImportRequest) -> SpecImport:
+ """Import an external markdown/YAML spec as a ``spec.md`` draft (draft-only)."""
+ return import_spec(request.content, source_format=request.source_format)
+
+
# --------------------------------------------------------------------------- #
# F23 spec-validation dashboard: GET /projects/{project_id}/specs #
# --------------------------------------------------------------------------- #
@@ -290,7 +671,13 @@ def project_spec_overview(
"SpecCreateRequest",
"SpecDashboard",
"SpecEngineRegistry",
+ "SpecImport",
+ "SpecImportRequest",
"SpecOverview",
+ "SpecVersionDetail",
+ "SpecVersionDiff",
+ "SpecVersionSummary",
+ "TextContent",
"get_spec_engine",
"get_spec_registry",
"project_router",
diff --git a/apps/api/forge_api/schemas/ao_settings.py b/apps/api/forge_api/schemas/ao_settings.py
index ac2ec76f..895cddaf 100644
--- a/apps/api/forge_api/schemas/ao_settings.py
+++ b/apps/api/forge_api/schemas/ao_settings.py
@@ -8,11 +8,11 @@
from uuid import UUID
-from forge_orchestration_policy import Strategy, Tier
from pydantic import BaseModel, Field
from forge_agent.providers.config import ProviderName
from forge_contracts.orchestration_config import AgentRole, Effort, RoleConfigSource
+from forge_orchestration_policy import Strategy, Tier
__all__ = [
"AoSettingsOut",
diff --git a/apps/api/forge_api/services/ao_settings_service.py b/apps/api/forge_api/services/ao_settings_service.py
index 6365eca4..896a4906 100644
--- a/apps/api/forge_api/services/ao_settings_service.py
+++ b/apps/api/forge_api/services/ao_settings_service.py
@@ -15,16 +15,6 @@
from dataclasses import dataclass, field
from uuid import UUID
-from forge_orchestration_policy import Strategy, Tier
-from forge_orchestration_policy.complexity import _JUNIOR_MAX as _DEFAULT_JUNIOR_MAX
-from forge_orchestration_policy.complexity import _MEDIOR_MAX as _DEFAULT_MEDIOR_MAX
-from forge_orchestration_policy.complexity import (
- BlastRadiusLevel,
- SizingSignals,
- score_complexity,
-)
-from forge_orchestration_policy.role_config import resolve_effective_config
-
from forge_agent.providers.config import ProviderName
from forge_agent.providers.router import ModelRouter
from forge_contracts.enums import Priority, TaskKind
@@ -36,6 +26,15 @@
Effort,
RoleConfigStore,
)
+from forge_orchestration_policy import Strategy, Tier
+from forge_orchestration_policy.complexity import _JUNIOR_MAX as _DEFAULT_JUNIOR_MAX
+from forge_orchestration_policy.complexity import _MEDIOR_MAX as _DEFAULT_MEDIOR_MAX
+from forge_orchestration_policy.complexity import (
+ BlastRadiusLevel,
+ SizingSignals,
+ score_complexity,
+)
+from forge_orchestration_policy.role_config import resolve_effective_config
__all__ = ["AoSettingsService", "EffectiveAoSettings", "RoutingPreview"]
diff --git a/apps/api/forge_api/services/spec_draft_service.py b/apps/api/forge_api/services/spec_draft_service.py
new file mode 100644
index 00000000..d8b791f2
--- /dev/null
+++ b/apps/api/forge_api/services/spec_draft_service.py
@@ -0,0 +1,231 @@
+"""BYOK AI spec drafting (slice ``ss-draft`` — track: Spec Studio).
+
+``POST /spec/draft`` asks the workspace's BYOK model — chosen by the
+``ao-model-router`` and resolved through the existing HARD-02
+:class:`~forge_contracts.ModelClient` — to draft a ``spec.md`` from a one-line
+goal, with a spec-authoring system prompt **seeded with the project
+constitution**. The draft is *streamed* (progressive assembly), then parsed to a
+:class:`~forge_contracts.SpecManifest` *preview*. This is draft-only: nothing is
+persisted; a human refines the result via the normal spec-editing endpoints.
+
+Token/cost accounting rides the existing HARD-02 seam
+(:class:`~forge_agent.providers.UsageAccumulator` + ``cost_usd``). The frozen
+:class:`~forge_contracts.ModelStreamEvent` carries only text deltas (no usage),
+so token counts for a streamed draft are *estimated* from the prompt and the
+assembled draft and then priced through the very same cost table — the service
+never reimplements the pricing logic.
+"""
+
+from __future__ import annotations
+
+import math
+import uuid
+from typing import Any
+
+from pydantic import BaseModel, Field
+
+from forge_agent.providers import UsageAccumulator
+from forge_contracts import (
+ Constitution,
+ ModelMessage,
+ ModelRequest,
+ SpecManifest,
+ TokenUsage,
+)
+from forge_spec import SpecParseError, parse_spec_md
+
+__all__ = [
+ "DRAFT_PLACEHOLDER_ID",
+ "SpecDraft",
+ "build_draft_request",
+ "build_system_prompt",
+ "draft_spec",
+ "estimate_tokens",
+]
+
+#: A draft has no real spec id yet (it is never persisted), so the model is told
+#: to use this placeholder in the frontmatter; a human assigns the real id when
+#: the draft is created for real via ``POST /spec/specs`` / ``PUT`` editing.
+DRAFT_PLACEHOLDER_ID = "SPEC-DRAFT"
+
+#: Default draft generation knobs (the injected client still owns provider-level
+#: timeouts/retries; these only shape the request).
+_DRAFT_MAX_TOKENS = 4000
+_DRAFT_TEMPERATURE = 0.2
+
+_BASE_INSTRUCTIONS = (
+ "You are Forge's spec author. You turn a one-line engineering goal into a "
+ "single, precise, testable specification following Spec-Driven Development. "
+ "Write concrete, verifiable requirements and Given/When/Then acceptance "
+ "criteria that each trace back to a requirement. Surface genuine ambiguity "
+ "as open questions rather than inventing scope. Output ONLY the spec.md "
+ "document — no preamble, no commentary, no code fences."
+)
+
+#: The exact ``spec.md`` serialization contract the parser
+#: (:func:`forge_spec.parse_spec_md`) expects. Kept in lock-step with
+#: :func:`forge_spec.render_spec_md`.
+_SPEC_MD_CONTRACT = (
+ "Emit the document in EXACTLY this format:\n\n"
+ "---\n"
+ f"id: {DRAFT_PLACEHOLDER_ID}\n"
+ "status: draft\n"
+ "---\n\n"
+ "## Goal\n\n"
+ "\n\n"
+ "## Requirements\n\n"
+ "- **R1**: \n"
+ "- **R2**: \n\n"
+ "## Acceptance Criteria\n\n"
+ "- **A1** (R1): Given , when , then \n\n"
+ "## Constraints\n\n"
+ "- \n\n"
+ "## Open Questions\n\n"
+ "- **Q1**: \n\n"
+ "The YAML frontmatter block (between the '---' lines) and the '## Goal' "
+ "section are mandatory; omit any other section that has no content."
+)
+
+
+class SpecDraft(BaseModel):
+ """The draft-only result of ``POST /spec/draft`` (nothing is persisted)."""
+
+ goal: str
+ epic_id: uuid.UUID | None = None
+ model: str
+ spec_md: str
+ #: The parsed preview, or ``None`` when the drafted markdown did not parse
+ #: (``parse_error`` then explains why — the raw ``spec_md`` is still returned
+ #: for the human to fix).
+ manifest: SpecManifest | None = None
+ parse_error: str | None = None
+ #: The ``model_usage`` accounting artifact (input/output tokens + ``cost_usd``).
+ usage: dict[str, Any] = Field(default_factory=dict)
+
+
+def build_system_prompt(constitution: Constitution | None) -> str:
+ """Build the spec-authoring system prompt, seeded with the constitution.
+
+ When a ``constitution`` is available its principles and architecture
+ guardrails are injected so the drafted spec conforms to the project's
+ engineering constitution; otherwise the base authoring instructions and the
+ ``spec.md`` format contract are used unchanged.
+ """
+ parts: list[str] = [_BASE_INSTRUCTIONS]
+ if constitution is not None:
+ if constitution.principles:
+ bullet = "\n".join(f"- {p}" for p in constitution.principles)
+ parts.append("Project constitution — principles:\n" + bullet)
+ if constitution.architecture_guardrails:
+ bullet = "\n".join(f"- {g}" for g in constitution.architecture_guardrails)
+ parts.append("Project constitution — architecture guardrails:\n" + bullet)
+ parts.append(_SPEC_MD_CONTRACT)
+ return "\n\n".join(parts)
+
+
+def build_draft_request(
+ *,
+ goal: str,
+ model: str,
+ system: str,
+ epic_id: uuid.UUID | None = None,
+) -> ModelRequest:
+ """Build the streaming :class:`~forge_contracts.ModelRequest` for a draft."""
+ user = f"Draft a spec.md for this engineering goal:\n\n{goal.strip()}"
+ if epic_id is not None:
+ user += f"\n\nThis spec belongs to epic {epic_id}."
+ return ModelRequest(
+ model=model,
+ system=system,
+ messages=[ModelMessage(role="user", content=user)],
+ max_tokens=_DRAFT_MAX_TOKENS,
+ temperature=_DRAFT_TEMPERATURE,
+ )
+
+
+def estimate_tokens(text: str) -> int:
+ """Estimate tokens for ``text`` (~4 chars/token; a non-empty string is >= 1).
+
+ Used only because the streaming contract surfaces no provider usage; the
+ estimate is priced through the shared ``cost_usd`` table so accounting stays
+ consistent with the rest of the platform.
+ """
+ if not text:
+ return 0
+ return max(1, math.ceil(len(text) / 4))
+
+
+def _extract_spec_md(raw: str) -> str:
+ """Best-effort clean-up of the streamed text into a parseable ``spec.md``.
+
+ Strips an accidental Markdown code-fence wrapper and any prose the model
+ emitted before the YAML frontmatter, so a slightly chatty model still yields
+ a parseable document. A well-formed draft passes through unchanged.
+ """
+ text = raw.strip()
+ if not text:
+ return text
+ if text.startswith("```"):
+ lines = text.splitlines()
+ lines = lines[1:] # drop the opening ``` / ```markdown fence
+ if lines and lines[-1].strip() == "```":
+ lines = lines[:-1]
+ text = "\n".join(lines).strip()
+ marker = text.find("---")
+ if marker > 0:
+ text = text[marker:].strip()
+ return text + "\n"
+
+
+def draft_spec(
+ client: Any,
+ *,
+ goal: str,
+ model: str,
+ constitution: Constitution | None = None,
+ epic_id: uuid.UUID | None = None,
+) -> SpecDraft:
+ """Stream a spec draft from ``client`` and return the parsed preview + cost.
+
+ ``client`` is any :class:`~forge_contracts.ModelClient` (a live BYOK client
+ in production, a mock in tests). The draft is assembled from the streamed
+ text deltas, parsed to a :class:`~forge_contracts.SpecManifest` preview
+ (never persisted), and token/cost is recorded via
+ :class:`~forge_agent.providers.UsageAccumulator`.
+ """
+ system = build_system_prompt(constitution)
+ request = build_draft_request(goal=goal, model=model, system=system, epic_id=epic_id)
+
+ chunks: list[str] = []
+ for event in client.stream(request):
+ piece = event.delta if event.delta is not None else event.text
+ if piece:
+ chunks.append(piece)
+ raw = "".join(chunks)
+
+ spec_md = _extract_spec_md(raw)
+ manifest: SpecManifest | None = None
+ parse_error: str | None = None
+ try:
+ manifest = parse_spec_md(spec_md)
+ except SpecParseError as exc:
+ parse_error = str(exc)
+
+ prompt_text = (system or "") + "\n" + (request.messages[-1].content if request.messages else "")
+ accumulator = UsageAccumulator()
+ accumulator.add(
+ TokenUsage(
+ input_tokens=estimate_tokens(prompt_text),
+ output_tokens=estimate_tokens(raw),
+ )
+ )
+
+ return SpecDraft(
+ goal=goal,
+ epic_id=epic_id,
+ model=model,
+ spec_md=spec_md,
+ manifest=manifest,
+ parse_error=parse_error,
+ usage=accumulator.to_artifact(model),
+ )
diff --git a/apps/api/forge_api/services/spec_import_service.py b/apps/api/forge_api/services/spec_import_service.py
new file mode 100644
index 00000000..97e09915
--- /dev/null
+++ b/apps/api/forge_api/services/spec_import_service.py
@@ -0,0 +1,367 @@
+"""External spec import (slice ``ss-import`` — track: Spec Studio).
+
+Turns an existing spec authored *outside* Forge — a pasted/uploaded markdown
+document (a GitHub issue, an RFC, a PRD) or a YAML manifest from another tool —
+into a Forge ``spec.md`` draft, so a human can bring existing work into the SDD
+lifecycle instead of retyping it.
+
+Three tiers of effort, cheapest first:
+
+1. **Direct parse** — the content is already a valid Forge ``spec.md``
+ (:func:`forge_spec.parse_spec_md`) or ``manifest.yaml``
+ (:func:`forge_spec.load_manifest`); it round-trips byte-for-byte in meaning.
+2. **Normalize** — the content is YAML or markdown but uses looser shapes
+ (``title``/``summary`` instead of ``name``, plain string lists instead of
+ typed requirement objects, arbitrary heading names). Best-effort mapping
+ onto :class:`~forge_contracts.SpecManifest`, assigning sequential ids
+ (``R1``, ``A1``, ``Q1``, ...) where the source had none.
+3. **Give up gracefully** — genuinely unparseable content (e.g. binary noise)
+ is still returned verbatim as ``spec_md`` with ``parse_error`` set, mirroring
+ ``ss-draft``'s graceful-failure contract, so the human can hand-fix it in the
+ markdown editor rather than losing the paste.
+
+This is draft-only, like ``POST /spec/draft``: nothing is persisted here — the
+human reviews/refines the result and saves it via the normal spec-editing
+endpoints (``PUT /spec/specs/{id}`` / ``/markdown`` / ``/manifest``).
+"""
+
+from __future__ import annotations
+
+import re
+from typing import Any, Literal
+
+import yaml
+from pydantic import BaseModel, Field
+
+from forge_contracts import (
+ AcceptanceCriterion,
+ OpenQuestion,
+ Requirement,
+ SpecManifest,
+)
+from forge_contracts.enums import SpecStatus
+from forge_spec import SpecParseError, load_manifest, parse_spec_md, render_spec_md
+
+__all__ = [
+ "IMPORT_PLACEHOLDER_ID",
+ "SpecImport",
+ "SpecImportFormat",
+ "detect_format",
+ "import_spec",
+]
+
+#: An imported spec has no real spec id yet (it is never persisted directly),
+#: mirroring ``ss-draft``'s ``DRAFT_PLACEHOLDER_ID`` convention.
+IMPORT_PLACEHOLDER_ID = "SPEC-IMPORT"
+
+SpecImportFormat = Literal["markdown", "yaml"]
+
+_REQUESTED_FORMATS = ("markdown", "yaml", "auto")
+
+
+class SpecImport(BaseModel):
+ """The draft-only result of ``POST /spec/import`` (nothing is persisted)."""
+
+ source_format: SpecImportFormat
+ spec_md: str
+ #: The parsed/normalized preview, or ``None`` when the content did not
+ #: parse or normalize (``parse_error`` then explains why).
+ manifest: SpecManifest | None = None
+ parse_error: str | None = None
+ #: ``True`` when the source needed best-effort normalization (loose YAML
+ #: keys, arbitrary markdown headings) rather than parsing directly as a
+ #: canonical Forge ``spec.md`` / ``manifest.yaml``.
+ normalized: bool = False
+
+
+# --------------------------------------------------------------------------- #
+# Format detection #
+# --------------------------------------------------------------------------- #
+
+
+def detect_format(content: str, requested: str = "auto") -> SpecImportFormat:
+ """Resolve the source format: an explicit hint, or sniffed from ``content``.
+
+ A canonical (or loosely-shaped) ``spec.md`` always has at least one
+ Markdown ``#`` heading; genuine YAML never does, so heading detection is
+ the deciding signal. Content that is neither valid YAML nor has headings
+ still falls back to markdown (the more forgiving of the two normalizers).
+ """
+ if requested in ("markdown", "yaml"):
+ return requested # type: ignore[return-value]
+ stripped = content.strip()
+ if _HEADING_RE.search(stripped) is not None:
+ return "markdown"
+ try:
+ data = yaml.safe_load(stripped)
+ except yaml.YAMLError:
+ return "markdown"
+ if isinstance(data, dict) and data:
+ return "yaml"
+ return "markdown"
+
+
+# --------------------------------------------------------------------------- #
+# Loose-YAML normalization #
+# --------------------------------------------------------------------------- #
+
+#: Alternate keys another tool might use for each manifest field, tried in order.
+_YAML_NAME_KEYS = ("name", "title", "summary")
+_YAML_REQUIREMENT_KEYS = ("requirements", "user_stories", "stories")
+_YAML_ACCEPTANCE_KEYS = ("acceptance_criteria", "acceptance", "criteria")
+_YAML_CONSTRAINT_KEYS = ("constraints", "non_functional_requirements", "nfrs")
+_YAML_QUESTION_KEYS = ("open_questions", "questions")
+
+
+def _first_present(data: dict[str, Any], keys: tuple[str, ...]) -> Any:
+ for key in keys:
+ value = data.get(key)
+ if value:
+ return value
+ return None
+
+
+def _text_of(item: Any) -> str:
+ if isinstance(item, str):
+ return item
+ if isinstance(item, dict):
+ for key in ("text", "description", "body", "summary"):
+ value = item.get(key)
+ if isinstance(value, str) and value:
+ return value
+ return str(item)
+
+
+def _coerce_requirements(raw: Any) -> list[Requirement]:
+ if not isinstance(raw, list):
+ return []
+ out: list[Requirement] = []
+ for i, item in enumerate(raw, start=1):
+ rid = item.get("id") if isinstance(item, dict) else None
+ out.append(Requirement(id=str(rid) if rid else f"R{i}", text=_text_of(item)))
+ return out
+
+
+def _coerce_acceptance(raw: Any, requirement_ids: list[str]) -> list[AcceptanceCriterion]:
+ if not isinstance(raw, list):
+ return []
+ out: list[AcceptanceCriterion] = []
+ for i, item in enumerate(raw, start=1):
+ aid = item.get("id") if isinstance(item, dict) else None
+ refs = item.get("req_refs") if isinstance(item, dict) else None
+ out.append(
+ AcceptanceCriterion(
+ id=str(aid) if aid else f"A{i}",
+ text=_text_of(item),
+ req_refs=list(refs) if isinstance(refs, list) else requirement_ids,
+ )
+ )
+ return out
+
+
+def _coerce_open_questions(raw: Any) -> list[OpenQuestion]:
+ if not isinstance(raw, list):
+ return []
+ out: list[OpenQuestion] = []
+ for i, item in enumerate(raw, start=1):
+ qid = item.get("id") if isinstance(item, dict) else None
+ resolution = item.get("resolution") if isinstance(item, dict) else None
+ out.append(
+ OpenQuestion(
+ id=str(qid) if qid else f"Q{i}",
+ text=_text_of(item),
+ resolution=resolution if isinstance(resolution, str) else None,
+ )
+ )
+ return out
+
+
+def _coerce_str_list(raw: Any) -> list[str]:
+ if not isinstance(raw, list):
+ return []
+ return [_text_of(item) for item in raw]
+
+
+def _manifest_from_loose_yaml(data: dict[str, Any]) -> SpecManifest:
+ """Best-effort map a loosely-shaped YAML mapping onto ``SpecManifest``."""
+ name = _first_present(data, _YAML_NAME_KEYS) or "Imported spec"
+ requirements = _coerce_requirements(_first_present(data, _YAML_REQUIREMENT_KEYS))
+ acceptance = _coerce_acceptance(
+ _first_present(data, _YAML_ACCEPTANCE_KEYS), [r.id for r in requirements]
+ )
+ constraints = _coerce_str_list(_first_present(data, _YAML_CONSTRAINT_KEYS))
+ open_questions = _coerce_open_questions(_first_present(data, _YAML_QUESTION_KEYS))
+ return SpecManifest(
+ id=IMPORT_PLACEHOLDER_ID,
+ name=str(name),
+ status=SpecStatus.DRAFT,
+ requirements=requirements,
+ acceptance_criteria=acceptance,
+ constraints=constraints,
+ open_questions=open_questions,
+ )
+
+
+# --------------------------------------------------------------------------- #
+# Loose-markdown normalization #
+# --------------------------------------------------------------------------- #
+
+# Linear-time patterns: a greedy `(.+)` to end-of-line (no lazy `.+?` + trailing
+# `\s*$` overlap, which CodeQL flags as polynomial/ReDoS on user-supplied text),
+# with `[ \t]` separators so whitespace classes don't overlap the capture. The
+# callers already `.strip()` the captured group, so trailing spaces are handled.
+_HEADING_RE = re.compile(r"^(#{1,6})[ \t]+(\S.*)$", re.MULTILINE)
+_BULLET_RE = re.compile(r"^[ \t]*[-*][ \t]+(\S.*)$")
+
+#: Heading text (lowercased, trailing ':' stripped) -> the bucket it feeds.
+_SECTION_ALIASES: dict[str, str] = {
+ "goal": "goal",
+ "summary": "goal",
+ "overview": "goal",
+ "objective": "goal",
+ "description": "goal",
+ "requirements": "requirements",
+ "functional requirements": "requirements",
+ "user stories": "requirements",
+ "acceptance criteria": "acceptance_criteria",
+ "acceptance": "acceptance_criteria",
+ "constraints": "constraints",
+ "non-functional requirements": "constraints",
+ "non functional requirements": "constraints",
+ "open questions": "open_questions",
+ "questions": "open_questions",
+}
+
+
+def _normalize_markdown(text: str) -> SpecManifest:
+ """Best-effort map arbitrary markdown headings/bullets onto ``SpecManifest``."""
+ buckets: dict[str, list[str]] = {
+ "goal": [],
+ "requirements": [],
+ "acceptance_criteria": [],
+ "constraints": [],
+ "open_questions": [],
+ }
+ name: str | None = None
+ current: str | None = None
+ for line in text.splitlines():
+ heading = _HEADING_RE.match(line)
+ if heading is not None:
+ level, title = heading.group(1), heading.group(2).strip().lower().rstrip(":")
+ key = _SECTION_ALIASES.get(title)
+ if key is not None:
+ current = key
+ elif level == "#" and name is None:
+ name = heading.group(2).strip()
+ current = None
+ else:
+ current = None
+ continue
+ if current is None:
+ continue
+ bullet = _BULLET_RE.match(line)
+ stripped = line.strip()
+ if bullet is not None:
+ buckets[current].append(bullet.group(1).strip())
+ elif stripped:
+ buckets[current].append(stripped)
+
+ if name is None:
+ if buckets["goal"]:
+ name = buckets["goal"][0]
+ else:
+ first_line = next((ln.strip() for ln in text.splitlines() if ln.strip()), "")
+ name = first_line[:200] or "Imported spec"
+
+ requirements = [
+ Requirement(id=f"R{i}", text=t) for i, t in enumerate(buckets["requirements"], 1)
+ ]
+ requirement_ids = [r.id for r in requirements]
+ acceptance = [
+ AcceptanceCriterion(id=f"A{i}", text=t, req_refs=requirement_ids)
+ for i, t in enumerate(buckets["acceptance_criteria"], 1)
+ ]
+ open_questions = [
+ OpenQuestion(id=f"Q{i}", text=t) for i, t in enumerate(buckets["open_questions"], 1)
+ ]
+
+ return SpecManifest(
+ id=IMPORT_PLACEHOLDER_ID,
+ name=name,
+ status=SpecStatus.DRAFT,
+ requirements=requirements,
+ acceptance_criteria=acceptance,
+ constraints=buckets["constraints"],
+ open_questions=open_questions,
+ )
+
+
+# --------------------------------------------------------------------------- #
+# Entry point #
+# --------------------------------------------------------------------------- #
+
+
+def import_spec(content: str, *, source_format: str = "auto") -> SpecImport:
+ """Import ``content`` (an external markdown or YAML spec) as a draft.
+
+ ``source_format`` is ``"markdown"``, ``"yaml"``, or ``"auto"`` (sniffed via
+ :func:`detect_format`). Always returns a result — never raises — mirroring
+ ``ss-draft``'s graceful-failure contract: unparseable content still comes
+ back with the raw ``spec_md`` and a ``parse_error`` explaining why.
+ """
+ fmt = detect_format(content, source_format)
+
+ if fmt == "yaml":
+ try:
+ manifest = load_manifest(content)
+ return SpecImport(
+ source_format="yaml",
+ spec_md=render_spec_md(manifest),
+ manifest=manifest,
+ normalized=False,
+ )
+ except Exception:
+ pass
+ try:
+ data = yaml.safe_load(content) or {}
+ if not isinstance(data, dict):
+ raise ValueError("YAML content must deserialize to a mapping")
+ manifest = _manifest_from_loose_yaml(data)
+ return SpecImport(
+ source_format="yaml",
+ spec_md=render_spec_md(manifest),
+ manifest=manifest,
+ normalized=True,
+ )
+ except Exception as exc:
+ return SpecImport(source_format="yaml", spec_md=content, parse_error=str(exc))
+
+ try:
+ manifest = parse_spec_md(content)
+ return SpecImport(
+ source_format="markdown", spec_md=content, manifest=manifest, normalized=False
+ )
+ except SpecParseError:
+ pass
+ try:
+ manifest = _normalize_markdown(content)
+ return SpecImport(
+ source_format="markdown",
+ spec_md=render_spec_md(manifest),
+ manifest=manifest,
+ normalized=True,
+ )
+ except Exception as exc:
+ return SpecImport(
+ source_format="markdown", spec_md=content, parse_error=str(exc), normalized=True
+ )
+
+
+class SpecImportRequest(BaseModel):
+ """Body for ``POST /spec/import``."""
+
+ content: str = Field(min_length=1, description="The pasted/uploaded spec text.")
+ source_format: Literal["markdown", "yaml", "auto"] = "auto"
+
+
+__all__.append("SpecImportRequest")
diff --git a/apps/api/forge_api/services/spec_version_service.py b/apps/api/forge_api/services/spec_version_service.py
new file mode 100644
index 00000000..e6f40e70
--- /dev/null
+++ b/apps/api/forge_api/services/spec_version_service.py
@@ -0,0 +1,88 @@
+"""Records + reads spec version snapshots (ss-versioning).
+
+``FileSpecEngine`` is filesystem-backed and keeps no history: every save
+overwrites ``manifest.yaml``/``spec.md`` in place. This service is the durable
+side-channel the spec router calls on every save (``spec_create``,
+``write_manifest``, ``write_spec_markdown``, ``write_spec_manifest_yaml``): it
+appends an immutable :class:`~forge_db.models.SpecVersion` row carrying a full
+snapshot, so Spec Studio can list a spec's version history and diff any two
+versions even though the engine itself only ever holds the *current* state.
+
+Workspace-scoped throughout (mirrors every other DB-backed repo in
+``apps/api``): a version is only ever recorded, listed, or read for the
+caller's own workspace.
+"""
+
+from __future__ import annotations
+
+import uuid
+
+from sqlalchemy import func, select
+from sqlalchemy.orm import Session
+
+from forge_contracts import SpecManifest
+from forge_db.models import SpecVersion
+from forge_spec import spec_id_for_key
+
+
+def record_version(
+ db: Session,
+ *,
+ workspace_id: uuid.UUID,
+ manifest: SpecManifest,
+ spec_md: str,
+ manifest_yaml: str,
+ created_by: uuid.UUID | None,
+) -> SpecVersion:
+ """Append the next version snapshot for ``manifest.id`` and commit it."""
+ spec_id = spec_id_for_key(manifest.id)
+ next_number = (
+ db.execute(
+ select(func.coalesce(func.max(SpecVersion.version_number), 0)).where(
+ SpecVersion.workspace_id == workspace_id,
+ SpecVersion.spec_id == spec_id,
+ )
+ ).scalar_one()
+ + 1
+ )
+ version = SpecVersion(
+ workspace_id=workspace_id,
+ spec_id=spec_id,
+ spec_key=manifest.id,
+ version_number=next_number,
+ name=manifest.name,
+ status=manifest.status.value,
+ manifest=manifest.model_dump(mode="json"),
+ spec_md=spec_md,
+ manifest_yaml=manifest_yaml,
+ created_by=created_by,
+ )
+ db.add(version)
+ db.commit()
+ db.refresh(version)
+ return version
+
+
+def list_versions(db: Session, *, workspace_id: uuid.UUID, spec_id: uuid.UUID) -> list[SpecVersion]:
+ """List a spec's versions, newest first."""
+ stmt = (
+ select(SpecVersion)
+ .where(SpecVersion.workspace_id == workspace_id, SpecVersion.spec_id == spec_id)
+ .order_by(SpecVersion.version_number.desc())
+ )
+ return list(db.execute(stmt).scalars())
+
+
+def get_version(
+ db: Session, *, workspace_id: uuid.UUID, spec_id: uuid.UUID, version_number: int
+) -> SpecVersion | None:
+ """Read one specific version snapshot, or ``None`` if unknown."""
+ stmt = select(SpecVersion).where(
+ SpecVersion.workspace_id == workspace_id,
+ SpecVersion.spec_id == spec_id,
+ SpecVersion.version_number == version_number,
+ )
+ return db.execute(stmt).scalar_one_or_none()
+
+
+__all__ = ["get_version", "list_versions", "record_version"]
diff --git a/apps/api/tests/test_rbac_tenant_r2.py b/apps/api/tests/test_rbac_tenant_r2.py
index eefec7ad..591f12d3 100644
--- a/apps/api/tests/test_rbac_tenant_r2.py
+++ b/apps/api/tests/test_rbac_tenant_r2.py
@@ -245,13 +245,41 @@ def test_workflow_run_is_workspace_scoped() -> None:
def test_spec_is_workspace_scoped(tmp_path) -> None:
+ from sqlalchemy import StaticPool, create_engine
+ from sqlalchemy.orm import Session, sessionmaker
+
+ from forge_api.db import get_db
from forge_api.routers.spec import SpecEngineRegistry, get_spec_registry
+ from forge_db.base import Base
+ from forge_db.models import Workspace
from forge_spec import spec_id_for_key
app = create_app()
registry = SpecEngineRegistry(tmp_path / "specs")
app.dependency_overrides[get_spec_registry] = lambda: registry
+ # ss-versioning: spec saves now also record a ``spec_version`` row, so
+ # this needs a real DB session (SQLite in-memory, mirroring the other
+ # hermetic spec-router test fixtures).
+ db_engine = create_engine(
+ "sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool
+ )
+ Base.metadata.create_all(db_engine)
+ db_factory = sessionmaker(bind=db_engine, expire_on_commit=False, class_=Session)
+ with db_factory() as session:
+ session.add(Workspace(id=TEST_WORKSPACE_ID, name="Acme", slug="acme"))
+ session.add(Workspace(id=OTHER_WORKSPACE_ID, name="Other", slug="other"))
+ session.commit()
+
+ def _override_db() -> Iterator[Session]:
+ session = db_factory()
+ try:
+ yield session
+ finally:
+ session.close()
+
+ app.dependency_overrides[get_db] = _override_db
+
_as(app, make_test_principal(role=UserRole.MEMBER, workspace_id=TEST_WORKSPACE_ID))
with TestClient(app) as client:
created = client.post(
diff --git a/apps/api/tests/test_spec_draft.py b/apps/api/tests/test_spec_draft.py
new file mode 100644
index 00000000..3f8767aa
--- /dev/null
+++ b/apps/api/tests/test_spec_draft.py
@@ -0,0 +1,293 @@
+"""ss-draft: BYOK AI spec drafting (``POST /spec/draft``).
+
+Covers the service (prompt shape seeded with the constitution, streaming
+assembly, parse to a manifest preview, token/cost accounting) and the wired
+endpoint (draft-only, RBAC, constitution seeding). The ``ModelClient`` is
+MOCKED throughout — no live key, no network.
+"""
+
+from __future__ import annotations
+
+import uuid
+from collections.abc import Callable, Iterator
+from pathlib import Path
+
+import pytest
+from fastapi import FastAPI
+from fastapi.testclient import TestClient
+
+from forge_agent.providers import cost_usd
+from forge_api.deps import Principal
+from forge_api.main import create_app
+from forge_api.routers.spec import DraftModelBinding, get_draft_binding, get_spec_engine
+from forge_api.services.spec_draft_service import (
+ DRAFT_PLACEHOLDER_ID,
+ build_system_prompt,
+ draft_spec,
+ estimate_tokens,
+)
+from forge_contracts import (
+ AcceptanceCriterion,
+ Constitution,
+ ModelRequest,
+ ModelResponse,
+ ModelStreamEvent,
+ Requirement,
+ SpecManifest,
+ TokenUsage,
+)
+from forge_contracts.enums import SpecStatus, UserRole
+from forge_spec import FileSpecEngine, render_spec_md, spec_id_for_key
+
+_MODEL = "claude-opus-4-8"
+
+
+def _principal(role: UserRole = UserRole.ADMIN) -> Principal:
+ return Principal(
+ user_id=uuid.uuid4(),
+ workspace_id=uuid.uuid4(),
+ role=role,
+ email="test@forge.local",
+ auth_method="test",
+ scopes=["*"],
+ )
+
+
+def _sample_spec_md() -> str:
+ """A well-formed spec.md the model would emit (guaranteed round-trippable)."""
+ manifest = SpecManifest(
+ id=DRAFT_PLACEHOLDER_ID,
+ name="Customer search by name",
+ status=SpecStatus.DRAFT,
+ requirements=[Requirement(id="R1", text="Search customers by name")],
+ acceptance_criteria=[
+ AcceptanceCriterion(
+ id="A1",
+ text="Given a name, when searching, then matching customers are returned",
+ req_refs=["R1"],
+ )
+ ],
+ )
+ return render_spec_md(manifest)
+
+
+class _StreamingSpy:
+ """A mocked ``ModelClient`` that streams ``chunks`` and records requests."""
+
+ def __init__(self, chunks: list[str]) -> None:
+ self._chunks = chunks
+ self.requests: list[ModelRequest] = []
+
+ def complete(self, request: ModelRequest) -> ModelResponse: # pragma: no cover - unused
+ self.requests.append(request)
+ return ModelResponse(content="".join(self._chunks))
+
+ def stream(self, request: ModelRequest) -> Iterator[ModelStreamEvent]:
+ self.requests.append(request)
+ for chunk in self._chunks:
+ yield ModelStreamEvent(type="text", text=chunk, delta=chunk)
+
+
+def _chunked(text: str, size: int = 37) -> list[str]:
+ return [text[i : i + size] for i in range(0, len(text), size)] or [""]
+
+
+# --------------------------------------------------------------------------- #
+# Service unit tests #
+# --------------------------------------------------------------------------- #
+
+
+def test_estimate_tokens_is_deterministic_and_nonzero() -> None:
+ assert estimate_tokens("") == 0
+ assert estimate_tokens("x") == 1
+ assert estimate_tokens("abcd" * 10) == 10
+
+
+def test_build_system_prompt_seeds_constitution() -> None:
+ constitution = Constitution(
+ project_id=uuid.uuid4(),
+ principles=["Prefer boring technology", "Tests are non-negotiable"],
+ architecture_guardrails=["Singular table names", "tz-aware datetimes"],
+ )
+ prompt = build_system_prompt(constitution)
+ assert "Prefer boring technology" in prompt
+ assert "Tests are non-negotiable" in prompt
+ assert "Singular table names" in prompt
+ # The spec.md format contract is always present so the draft parses.
+ assert "## Goal" in prompt
+ assert DRAFT_PLACEHOLDER_ID in prompt
+
+
+def test_build_system_prompt_without_constitution() -> None:
+ prompt = build_system_prompt(None)
+ assert "## Goal" in prompt # the spec.md format contract is always present
+ assert DRAFT_PLACEHOLDER_ID in prompt
+ assert "constitution" not in prompt.lower() # none supplied -> not injected
+
+
+def test_draft_spec_assembles_stream_and_parses() -> None:
+ spec_md = _sample_spec_md()
+ client = _StreamingSpy(_chunked(spec_md))
+
+ draft = draft_spec(client, goal="Let users search customers by name", model=_MODEL)
+
+ # Streaming assembly reconstructed the full document across chunks.
+ assert draft.spec_md == spec_md
+ assert draft.parse_error is None
+ assert draft.manifest is not None
+ assert draft.manifest.name == "Customer search by name"
+ assert draft.manifest.requirements[0].id == "R1"
+ assert draft.model == _MODEL
+
+
+def test_draft_spec_prompt_shape_carries_goal_and_constitution() -> None:
+ client = _StreamingSpy(_chunked(_sample_spec_md()))
+ constitution = Constitution(project_id=uuid.uuid4(), principles=["Ship small, safe changes"])
+ epic_id = uuid.uuid4()
+
+ draft_spec(
+ client,
+ goal="Add SSO login",
+ model=_MODEL,
+ constitution=constitution,
+ epic_id=epic_id,
+ )
+
+ assert len(client.requests) == 1
+ request = client.requests[0]
+ assert request.model == _MODEL
+ assert request.system is not None and "Ship small, safe changes" in request.system
+ user = request.messages[-1].content
+ assert "Add SSO login" in user
+ assert str(epic_id) in user
+
+
+def test_draft_spec_records_cost_via_pricing_table() -> None:
+ spec_md = _sample_spec_md()
+ client = _StreamingSpy(_chunked(spec_md))
+
+ draft = draft_spec(client, goal="Search customers", model=_MODEL)
+
+ usage = draft.usage
+ assert usage["output_tokens"] == estimate_tokens(spec_md)
+ assert usage["input_tokens"] > 0
+ assert usage["calls"] == 1
+ # Cost rides the shared HARD-02 pricing table (not reimplemented here).
+ expected = cost_usd(
+ _MODEL,
+ TokenUsage(input_tokens=usage["input_tokens"], output_tokens=usage["output_tokens"]),
+ )
+ assert usage["cost_usd"] == expected
+ assert usage["cost_usd"] > 0.0
+
+
+def test_draft_spec_parse_error_is_graceful() -> None:
+ client = _StreamingSpy(["This is not a spec at all, just prose."])
+
+ draft = draft_spec(client, goal="whatever", model=_MODEL)
+
+ assert draft.manifest is None
+ assert draft.parse_error is not None
+ assert draft.spec_md # raw text still returned for the human to fix
+ assert draft.usage["cost_usd"] >= 0.0
+
+
+def test_draft_spec_strips_code_fence_wrapper() -> None:
+ spec_md = _sample_spec_md()
+ fenced = "```markdown\n" + spec_md + "```\n"
+ client = _StreamingSpy(_chunked(fenced))
+
+ draft = draft_spec(client, goal="Search customers", model=_MODEL)
+
+ assert draft.manifest is not None
+ assert draft.manifest.name == "Customer search by name"
+
+
+# --------------------------------------------------------------------------- #
+# Endpoint integration tests #
+# --------------------------------------------------------------------------- #
+
+
+@pytest.fixture
+def spy() -> _StreamingSpy:
+ return _StreamingSpy(_chunked(_sample_spec_md()))
+
+
+def _make_client(
+ tmp_path: Path,
+ authenticate_app: Callable[..., FastAPI],
+ spy: _StreamingSpy,
+ *,
+ role: UserRole = UserRole.ADMIN,
+) -> tuple[TestClient, FileSpecEngine]:
+ app = create_app()
+ authenticate_app(app, _principal(role=role))
+ engine = FileSpecEngine(root=tmp_path / "specs")
+ app.dependency_overrides[get_spec_engine] = lambda: engine
+ app.dependency_overrides[get_draft_binding] = lambda: DraftModelBinding(
+ client=spy, model=_MODEL
+ )
+ return TestClient(app), engine
+
+
+def test_draft_endpoint_returns_manifest_preview(
+ tmp_path: Path, authenticate_app: Callable[..., FastAPI], spy: _StreamingSpy
+) -> None:
+ client, _ = _make_client(tmp_path, authenticate_app, spy)
+ with client:
+ resp = client.post("/spec/draft", json={"goal": "Search customers by name"})
+ assert resp.status_code == 200, resp.text
+ body = resp.json()
+ assert body["model"] == _MODEL
+ assert body["manifest"]["name"] == "Customer search by name"
+ assert body["parse_error"] is None
+ assert body["usage"]["cost_usd"] > 0.0
+ assert body["spec_md"].startswith("---")
+
+
+def test_draft_endpoint_seeds_constitution_from_project(
+ tmp_path: Path, authenticate_app: Callable[..., FastAPI], spy: _StreamingSpy
+) -> None:
+ client, engine = _make_client(tmp_path, authenticate_app, spy)
+ project_id = uuid.uuid4()
+ engine.constitution_init(project_id, ["Latency budget is 200ms"])
+ with client:
+ resp = client.post(
+ "/spec/draft",
+ json={"goal": "Add a caching layer", "project_id": str(project_id)},
+ )
+ assert resp.status_code == 200, resp.text
+ # The seeded constitution principle reached the model's system prompt.
+ assert spy.requests
+ assert "Latency budget is 200ms" in (spy.requests[0].system or "")
+
+
+def test_draft_endpoint_requires_write_permission(
+ tmp_path: Path, authenticate_app: Callable[..., FastAPI], spy: _StreamingSpy
+) -> None:
+ client, _ = _make_client(tmp_path, authenticate_app, spy, role=UserRole.VIEWER)
+ with client:
+ resp = client.post("/spec/draft", json={"goal": "Search customers"})
+ assert resp.status_code == 403
+
+
+def test_draft_endpoint_rejects_empty_goal(
+ tmp_path: Path, authenticate_app: Callable[..., FastAPI], spy: _StreamingSpy
+) -> None:
+ client, _ = _make_client(tmp_path, authenticate_app, spy)
+ with client:
+ resp = client.post("/spec/draft", json={"goal": ""})
+ assert resp.status_code == 422
+
+
+def test_draft_endpoint_does_not_persist(
+ tmp_path: Path, authenticate_app: Callable[..., FastAPI], spy: _StreamingSpy
+) -> None:
+ """Draft-only: the previewed spec is not written to the engine."""
+ client, _ = _make_client(tmp_path, authenticate_app, spy)
+ with client:
+ resp = client.post("/spec/draft", json={"goal": "Search customers by name"})
+ assert resp.status_code == 200
+ spec_uuid = spec_id_for_key(DRAFT_PLACEHOLDER_ID)
+ fetched = client.get(f"/spec/specs/{spec_uuid}")
+ assert fetched.status_code == 404
diff --git a/apps/api/tests/test_spec_import.py b/apps/api/tests/test_spec_import.py
new file mode 100644
index 00000000..b0bee163
--- /dev/null
+++ b/apps/api/tests/test_spec_import.py
@@ -0,0 +1,300 @@
+"""ss-import: external spec import (``POST /spec/import``).
+
+Covers the service (direct parse, loose-YAML normalization, loose-markdown
+normalization, graceful failure) and the wired endpoint (RBAC, draft-only —
+nothing persisted). No model client involved — this is parse/normalize only.
+"""
+
+from __future__ import annotations
+
+import uuid
+from collections.abc import Callable
+from pathlib import Path
+
+from fastapi import FastAPI
+from fastapi.testclient import TestClient
+
+from forge_api.deps import Principal
+from forge_api.main import create_app
+from forge_api.routers.spec import get_spec_engine
+from forge_api.services.spec_import_service import (
+ IMPORT_PLACEHOLDER_ID,
+ detect_format,
+ import_spec,
+)
+from forge_contracts import Requirement, SpecManifest
+from forge_contracts.enums import SpecStatus, UserRole
+from forge_spec import FileSpecEngine, render_spec_md, spec_id_for_key
+
+# --------------------------------------------------------------------------- #
+# Format detection #
+# --------------------------------------------------------------------------- #
+
+
+def test_detect_format_honors_explicit_hint() -> None:
+ assert detect_format("id: x\nname: y\n", "yaml") == "yaml"
+ assert detect_format("id: x\nname: y\n", "markdown") == "markdown"
+
+
+def test_detect_format_sniffs_markdown_from_headings() -> None:
+ assert detect_format("# Title\n\nSome body text.") == "markdown"
+
+
+def test_detect_format_sniffs_yaml_from_mapping() -> None:
+ assert detect_format("title: Thing\nrequirements:\n - do X\n") == "yaml"
+
+
+def test_detect_format_falls_back_to_markdown_for_unparseable() -> None:
+ assert detect_format("not: [valid: yaml: at: all") == "markdown"
+
+
+# --------------------------------------------------------------------------- #
+# Tier 1: direct parse (already-canonical Forge documents) #
+# --------------------------------------------------------------------------- #
+
+
+def _canonical_spec_md() -> str:
+ manifest = SpecManifest(
+ id="SPEC-42",
+ name="Existing spec",
+ requirements=[Requirement(id="R1", text="Do the thing")],
+ )
+ return render_spec_md(manifest)
+
+
+def test_import_spec_md_that_is_already_canonical_passes_through() -> None:
+ spec_md = _canonical_spec_md()
+
+ result = import_spec(spec_md)
+
+ assert result.source_format == "markdown"
+ assert result.normalized is False
+ assert result.parse_error is None
+ assert result.manifest is not None
+ assert result.manifest.id == "SPEC-42"
+ assert result.spec_md == spec_md
+
+
+def test_import_manifest_yaml_that_is_already_canonical_passes_through() -> None:
+ from forge_spec import dump_manifest
+
+ manifest = SpecManifest(id="SPEC-7", name="Canonical yaml spec")
+ yaml_text = dump_manifest(manifest)
+
+ result = import_spec(yaml_text, source_format="yaml")
+
+ assert result.source_format == "yaml"
+ assert result.normalized is False
+ assert result.parse_error is None
+ assert result.manifest is not None
+ assert result.manifest.id == "SPEC-7"
+ assert result.manifest.name == "Canonical yaml spec"
+
+
+# --------------------------------------------------------------------------- #
+# Tier 2: normalize (loose shapes) #
+# --------------------------------------------------------------------------- #
+
+
+def test_import_loose_markdown_normalizes_sections() -> None:
+ content = (
+ "# Customer search\n\n"
+ "## Requirements\n"
+ "- Search customers by name\n"
+ "- Filter by status\n\n"
+ "## Acceptance Criteria\n"
+ "- Given a name, when searching, then matches return\n\n"
+ "## Constraints\n"
+ "- Must respond within 200ms\n\n"
+ "## Open Questions\n"
+ "- Should archived customers be included?\n"
+ )
+
+ result = import_spec(content)
+
+ assert result.source_format == "markdown"
+ assert result.normalized is True
+ assert result.parse_error is None
+ manifest = result.manifest
+ assert manifest is not None
+ assert manifest.name == "Customer search"
+ assert [r.text for r in manifest.requirements] == [
+ "Search customers by name",
+ "Filter by status",
+ ]
+ assert manifest.requirements[0].id == "R1"
+ assert manifest.acceptance_criteria[0].req_refs == ["R1", "R2"]
+ assert manifest.constraints == ["Must respond within 200ms"]
+ assert manifest.open_questions[0].id == "Q1"
+ # The normalized preview re-renders as valid, round-trippable spec.md.
+ assert result.spec_md.startswith("---")
+ from forge_spec import parse_spec_md
+
+ assert parse_spec_md(result.spec_md).name == "Customer search"
+
+
+def test_import_loose_markdown_without_h1_falls_back_to_first_line() -> None:
+ content = "Just some free-form notes about a feature.\n\nNo headings at all here."
+
+ result = import_spec(content)
+
+ assert result.manifest is not None
+ assert result.manifest.name == "Just some free-form notes about a feature."
+ assert result.manifest.id == IMPORT_PLACEHOLDER_ID
+
+
+def test_import_loose_yaml_normalizes_alternate_keys() -> None:
+ content = (
+ "title: Customer search\n"
+ "requirements:\n"
+ " - Search customers by name\n"
+ " - Filter by status\n"
+ "acceptance:\n"
+ " - Given a name, when searching, then matches return\n"
+ "constraints:\n"
+ " - Must respond within 200ms\n"
+ )
+
+ result = import_spec(content, source_format="yaml")
+
+ assert result.source_format == "yaml"
+ assert result.normalized is True
+ assert result.parse_error is None
+ manifest = result.manifest
+ assert manifest is not None
+ assert manifest.name == "Customer search"
+ assert len(manifest.requirements) == 2
+ assert manifest.requirements[0].id == "R1"
+ assert manifest.acceptance_criteria[0].req_refs == ["R1", "R2"]
+ assert manifest.constraints == ["Must respond within 200ms"]
+
+
+def test_import_loose_yaml_with_dict_items_extracts_text() -> None:
+ content = "name: Thing\nrequirements:\n - id: CUSTOM-1\n text: A dict-shaped requirement\n"
+
+ result = import_spec(content, source_format="yaml")
+
+ assert result.manifest is not None
+ assert result.manifest.requirements[0].id == "CUSTOM-1"
+ assert result.manifest.requirements[0].text == "A dict-shaped requirement"
+
+
+def test_import_normalized_result_defaults_to_draft_status() -> None:
+ result = import_spec("# Some spec\n\n## Requirements\n- A thing\n")
+ assert result.manifest is not None
+ assert result.manifest.status == SpecStatus.DRAFT
+
+
+# --------------------------------------------------------------------------- #
+# Tier 3: graceful failure #
+# --------------------------------------------------------------------------- #
+
+
+def test_import_yaml_that_is_not_a_mapping_fails_gracefully() -> None:
+ result = import_spec("- just\n- a\n- list\n", source_format="yaml")
+
+ assert result.manifest is None
+ assert result.parse_error is not None
+ assert result.spec_md # raw content preserved for the human to fix
+
+
+def test_import_empty_markdown_still_returns_a_draft() -> None:
+ result = import_spec("")
+
+ # Never raises; an empty document just yields an empty-shaped draft.
+ assert result.manifest is not None
+ assert result.parse_error is None
+
+
+# --------------------------------------------------------------------------- #
+# Endpoint integration tests #
+# --------------------------------------------------------------------------- #
+
+
+def _client(
+ authenticate_app: Callable[..., FastAPI],
+ *,
+ role: UserRole = UserRole.ADMIN,
+ engine: FileSpecEngine | None = None,
+) -> TestClient:
+ app = create_app()
+ principal = Principal(
+ user_id=uuid.uuid4(),
+ workspace_id=uuid.uuid4(),
+ role=role,
+ email="test@forge.local",
+ auth_method="test",
+ scopes=["*"],
+ )
+ authenticate_app(app, principal)
+ if engine is not None:
+ app.dependency_overrides[get_spec_engine] = lambda: engine
+ return TestClient(app)
+
+
+def test_import_endpoint_returns_normalized_draft(
+ authenticate_app: Callable[..., FastAPI],
+) -> None:
+ client = _client(authenticate_app)
+ with client:
+ resp = client.post(
+ "/spec/import",
+ json={
+ "content": "# My feature\n\n## Requirements\n- Do the thing\n",
+ "source_format": "markdown",
+ },
+ )
+ assert resp.status_code == 200, resp.text
+ body = resp.json()
+ assert body["source_format"] == "markdown"
+ assert body["normalized"] is True
+ assert body["manifest"]["name"] == "My feature"
+ assert body["parse_error"] is None
+
+
+def test_import_endpoint_auto_detects_yaml(authenticate_app: Callable[..., FastAPI]) -> None:
+ client = _client(authenticate_app)
+ with client:
+ resp = client.post(
+ "/spec/import",
+ json={"content": "title: A yaml spec\nrequirements:\n - Do X\n"},
+ )
+ assert resp.status_code == 200, resp.text
+ body = resp.json()
+ assert body["source_format"] == "yaml"
+ assert body["manifest"]["name"] == "A yaml spec"
+
+
+def test_import_endpoint_requires_write_permission(
+ authenticate_app: Callable[..., FastAPI],
+) -> None:
+ client = _client(authenticate_app, role=UserRole.VIEWER)
+ with client:
+ resp = client.post("/spec/import", json={"content": "# Thing\n"})
+ assert resp.status_code == 403
+
+
+def test_import_endpoint_rejects_empty_content(
+ authenticate_app: Callable[..., FastAPI],
+) -> None:
+ client = _client(authenticate_app)
+ with client:
+ resp = client.post("/spec/import", json={"content": ""})
+ assert resp.status_code == 422
+
+
+def test_import_endpoint_does_not_persist(
+ tmp_path: Path, authenticate_app: Callable[..., FastAPI]
+) -> None:
+ """Draft-only: nothing importable ends up written to the spec engine."""
+ engine = FileSpecEngine(root=tmp_path / "specs")
+ client = _client(authenticate_app, engine=engine)
+ with client:
+ resp = client.post(
+ "/spec/import",
+ json={"content": "# Thing\n\n## Requirements\n- Do X\n"},
+ )
+ assert resp.status_code == 200
+ spec_uuid = spec_id_for_key(IMPORT_PLACEHOLDER_ID)
+ fetched = client.get(f"/spec/specs/{spec_uuid}")
+ assert fetched.status_code == 404
diff --git a/apps/api/tests/test_spec_router.py b/apps/api/tests/test_spec_router.py
index 9d2b8b26..0c787bd6 100644
--- a/apps/api/tests/test_spec_router.py
+++ b/apps/api/tests/test_spec_router.py
@@ -14,18 +14,48 @@
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
+from sqlalchemy import StaticPool, create_engine
+from sqlalchemy.orm import Session, sessionmaker
+from forge_api.db import get_db
from forge_api.main import create_app
from forge_api.routers.spec import get_spec_engine
+from forge_db.base import Base
+from forge_db.models import Workspace
from forge_spec import FileSpecEngine, spec_id_for_key
+#: Mirrors ``conftest.py``'s deterministic test workspace (tests mirror rather
+#: than cross-import conftest constants, per repo convention).
+_TEST_WORKSPACE_ID = uuid.UUID("00000000-0000-0000-0000-0000000000a1")
+
@pytest.fixture
def client(tmp_path: Path, authenticate_app: Callable[..., FastAPI]) -> Iterator[TestClient]:
app = create_app()
authenticate_app(app)
engine = FileSpecEngine(root=tmp_path / "specs")
+
+ # ss-versioning: every save also records a ``spec_version`` row, so the
+ # write endpoints now need a DB session (SQLite in-memory here, mirroring
+ # ``test_project_spec_overview.py``'s hermetic fixture).
+ db_engine = create_engine(
+ "sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool
+ )
+ Base.metadata.create_all(db_engine)
+ db_factory = sessionmaker(bind=db_engine, expire_on_commit=False, class_=Session)
+ with db_factory() as session:
+ session.add(Workspace(id=_TEST_WORKSPACE_ID, name="Acme", slug="acme"))
+ session.commit()
+
+ def _override_db() -> Iterator[Session]:
+ session = db_factory()
+ try:
+ yield session
+ finally:
+ session.close()
+
app.dependency_overrides[get_spec_engine] = lambda: engine
+ app.dependency_overrides[get_db] = _override_db
with TestClient(app) as c:
yield c
@@ -70,6 +100,127 @@ def test_tasks_before_approval_is_gated_409(client: TestClient) -> None:
assert resp.status_code == 409
+def test_read_missing_constitution_is_404(client: TestClient) -> None:
+ resp = client.get(f"/spec/constitution/{uuid.uuid4()}")
+ assert resp.status_code == 404
+
+
+def test_read_constitution_after_init(client: TestClient) -> None:
+ project_id = uuid.uuid4()
+ init = client.post("/spec/constitution", json={"project_id": str(project_id)})
+ assert init.status_code == 201, init.text
+
+ resp = client.get(f"/spec/constitution/{project_id}")
+
+ assert resp.status_code == 200, resp.text
+ assert resp.json()["project_id"] == str(project_id)
+ assert resp.json()["principles"] == init.json()["principles"]
+
+
+def test_read_spec_markdown_round_trips_the_manifest(client: TestClient) -> None:
+ manifest = _create_spec(client)
+ spec_uuid = spec_id_for_key(manifest["id"])
+
+ resp = client.get(f"/spec/specs/{spec_uuid}/markdown")
+
+ assert resp.status_code == 200, resp.text
+ assert "text/plain" in resp.headers["content-type"]
+ text = resp.text
+ assert manifest["id"] in text
+ assert "Customer search" in text
+ assert "R1" in text
+
+
+def test_edit_spec_via_markdown_updates_the_manifest(client: TestClient) -> None:
+ manifest = _create_spec(client)
+ spec_uuid = spec_id_for_key(manifest["id"])
+ text = client.get(f"/spec/specs/{spec_uuid}/markdown").text
+
+ edited = text.replace(
+ "- **R1**: Search customers by name",
+ "- **R1**: Search customers by name or email",
+ )
+ resp = client.put(f"/spec/specs/{spec_uuid}/markdown", json={"content": edited})
+
+ assert resp.status_code == 200, resp.text
+ updated = resp.json()
+ assert updated["requirements"][0]["text"] == "Search customers by name or email"
+
+ # manifest.yaml was re-rendered to match.
+ yaml_text = client.get(f"/spec/specs/{spec_uuid}/manifest").text
+ assert "Search customers by name or email" in yaml_text
+
+
+def test_read_missing_spec_markdown_is_404(client: TestClient) -> None:
+ resp = client.get(f"/spec/specs/{uuid.uuid4()}/markdown")
+ assert resp.status_code == 404
+
+
+def test_read_spec_manifest_yaml_round_trips_the_manifest(client: TestClient) -> None:
+ manifest = _create_spec(client)
+ spec_uuid = spec_id_for_key(manifest["id"])
+
+ resp = client.get(f"/spec/specs/{spec_uuid}/manifest")
+
+ assert resp.status_code == 200, resp.text
+ assert "text/plain" in resp.headers["content-type"]
+ assert f"id: {manifest['id']}" in resp.text
+ assert "Search customers by name" in resp.text
+
+
+def test_create_spec_via_manifest_yaml(client: TestClient) -> None:
+ """Creating a spec straight from ``manifest.yaml`` (both formats writable)."""
+ yaml_body = (
+ "id: SPEC-99\n"
+ "name: Billing v2\n"
+ "status: draft\n"
+ "requirements:\n"
+ " - id: R1\n"
+ " text: Charge a card\n"
+ )
+ spec_uuid = spec_id_for_key("SPEC-99")
+
+ resp = client.put(f"/spec/specs/{spec_uuid}/manifest", json={"content": yaml_body})
+
+ assert resp.status_code == 200, resp.text
+ created = resp.json()
+ assert created["id"] == "SPEC-99"
+ assert created["name"] == "Billing v2"
+
+ fetched = client.get(f"/spec/specs/{spec_uuid}")
+ assert fetched.status_code == 200
+ assert fetched.json()["name"] == "Billing v2"
+
+ # spec.md was rendered to match the YAML-authored manifest.
+ md_text = client.get(f"/spec/specs/{spec_uuid}/markdown").text
+ assert "Billing v2" in md_text
+ assert "Charge a card" in md_text
+
+
+def test_edit_spec_via_manifest_yaml_updates_the_manifest(client: TestClient) -> None:
+ manifest = _create_spec(client)
+ spec_uuid = spec_id_for_key(manifest["id"])
+ yaml_text = client.get(f"/spec/specs/{spec_uuid}/manifest").text
+
+ edited = yaml_text.replace(
+ "text: Search customers by name", "text: Search customers by name, email, or phone"
+ )
+ resp = client.put(f"/spec/specs/{spec_uuid}/manifest", json={"content": edited})
+
+ assert resp.status_code == 200, resp.text
+ updated = resp.json()
+ assert updated["requirements"][0]["text"] == "Search customers by name, email, or phone"
+
+ # spec.md was re-rendered to match.
+ md_text = client.get(f"/spec/specs/{spec_uuid}/markdown").text
+ assert "Search customers by name, email, or phone" in md_text
+
+
+def test_read_missing_spec_manifest_yaml_is_404(client: TestClient) -> None:
+ resp = client.get(f"/spec/specs/{uuid.uuid4()}/manifest")
+ assert resp.status_code == 404
+
+
def test_lifecycle_clarify_plan_approve_tasks(client: TestClient) -> None:
manifest = _create_spec(client)
spec_uuid = spec_id_for_key(manifest["id"])
diff --git a/apps/api/tests/test_spec_versioning.py b/apps/api/tests/test_spec_versioning.py
new file mode 100644
index 00000000..987293bd
--- /dev/null
+++ b/apps/api/tests/test_spec_versioning.py
@@ -0,0 +1,214 @@
+"""Integration tests for spec versioning + diff (ss-versioning).
+
+Every save through the editing endpoints (``spec_create`` / ``write_manifest``
+/ ``write_spec_markdown`` / ``write_spec_manifest_yaml``) appends an immutable
+``spec_version`` row; ``GET .../versions`` lists them, ``GET
+.../versions/{n}`` reads one snapshot, and ``GET
+.../versions/{a}/diff/{b}`` diffs two of them (line-level markdown +
+structured manifest). Hermetic: SQLite in-memory backs the DB, a tmp-rooted
+``FileSpecEngine`` backs the spec content.
+"""
+
+from __future__ import annotations
+
+import uuid
+from collections.abc import Callable, Iterator
+from pathlib import Path
+
+import pytest
+from fastapi import FastAPI
+from fastapi.testclient import TestClient
+from sqlalchemy import StaticPool, create_engine
+from sqlalchemy.orm import Session, sessionmaker
+
+from forge_api.db import get_db
+from forge_api.deps import get_current_principal
+from forge_api.main import create_app
+from forge_api.routers.spec import get_spec_engine
+from forge_db.base import Base
+from forge_db.models import Workspace
+from forge_spec import FileSpecEngine, spec_id_for_key
+
+# Deterministic identities mirroring ``conftest.py``'s (tests mirror rather
+# than cross-import conftest constants, per repo convention).
+TEST_WORKSPACE_ID = uuid.UUID("00000000-0000-0000-0000-0000000000a1")
+TEST_USER_ID = uuid.UUID("00000000-0000-0000-0000-0000000000b2")
+
+
+@pytest.fixture
+def db_factory() -> Iterator[sessionmaker[Session]]:
+ engine = create_engine(
+ "sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool
+ )
+ Base.metadata.create_all(engine)
+ factory = sessionmaker(bind=engine, expire_on_commit=False, class_=Session)
+ with factory() as session:
+ session.add(Workspace(id=TEST_WORKSPACE_ID, name="Acme", slug="acme"))
+ session.commit()
+ yield factory
+
+
+@pytest.fixture
+def client(
+ tmp_path: Path,
+ authenticate_app: Callable[..., FastAPI],
+ db_factory: sessionmaker[Session],
+) -> Iterator[TestClient]:
+ app = create_app()
+ authenticate_app(app)
+ engine = FileSpecEngine(root=tmp_path / "specs")
+
+ def _override_db() -> Iterator[Session]:
+ session = db_factory()
+ try:
+ yield session
+ finally:
+ session.close()
+
+ app.dependency_overrides[get_spec_engine] = lambda: engine
+ app.dependency_overrides[get_db] = _override_db
+ with TestClient(app) as c:
+ yield c
+
+
+def _create_spec(client: TestClient, name: str = "Customer search") -> dict:
+ resp = client.post(
+ "/spec/specs",
+ json={
+ "epic_id": str(uuid.uuid4()),
+ "name": name,
+ "requirements": [{"id": "R1", "text": "Search customers by name"}],
+ },
+ )
+ assert resp.status_code == 201, resp.text
+ return resp.json()
+
+
+def test_spec_create_records_version_one(client: TestClient) -> None:
+ manifest = _create_spec(client)
+ spec_uuid = spec_id_for_key(manifest["id"])
+
+ resp = client.get(f"/spec/specs/{spec_uuid}/versions")
+
+ assert resp.status_code == 200, resp.text
+ versions = resp.json()
+ assert len(versions) == 1
+ assert versions[0]["version_number"] == 1
+ assert versions[0]["name"] == "Customer search"
+ assert versions[0]["created_by"] == str(TEST_USER_ID)
+
+
+def test_saving_manifest_appends_a_new_version(client: TestClient) -> None:
+ manifest = _create_spec(client)
+ spec_uuid = spec_id_for_key(manifest["id"])
+
+ manifest["name"] = "Customer search v2"
+ resp = client.put(f"/spec/specs/{spec_uuid}", json=manifest)
+ assert resp.status_code == 200, resp.text
+
+ versions = client.get(f"/spec/specs/{spec_uuid}/versions").json()
+ assert [v["version_number"] for v in versions] == [2, 1]
+ assert versions[0]["name"] == "Customer search v2"
+
+
+def test_saving_markdown_appends_a_new_version(client: TestClient) -> None:
+ manifest = _create_spec(client)
+ spec_uuid = spec_id_for_key(manifest["id"])
+
+ markdown = client.get(f"/spec/specs/{spec_uuid}/markdown").text
+ updated_markdown = markdown.replace(
+ "Search customers by name", "Search customers by name or email"
+ )
+ resp = client.put(
+ f"/spec/specs/{spec_uuid}/markdown",
+ json={"content": updated_markdown},
+ )
+ assert resp.status_code == 200, resp.text
+
+ versions = client.get(f"/spec/specs/{spec_uuid}/versions").json()
+ assert len(versions) == 2
+
+
+def test_read_one_version_returns_full_snapshot(client: TestClient) -> None:
+ manifest = _create_spec(client)
+ spec_uuid = spec_id_for_key(manifest["id"])
+
+ resp = client.get(f"/spec/specs/{spec_uuid}/versions/1")
+
+ assert resp.status_code == 200, resp.text
+ body = resp.json()
+ assert body["version_number"] == 1
+ assert body["manifest"]["name"] == "Customer search"
+ assert "Customer search" in body["spec_md"]
+ assert "Customer search" in body["manifest_yaml"]
+
+
+def test_read_missing_version_is_404(client: TestClient) -> None:
+ manifest = _create_spec(client)
+ spec_uuid = spec_id_for_key(manifest["id"])
+
+ resp = client.get(f"/spec/specs/{spec_uuid}/versions/99")
+
+ assert resp.status_code == 404
+
+
+def test_diff_two_versions_reports_markdown_and_manifest_changes(client: TestClient) -> None:
+ manifest = _create_spec(client)
+ spec_uuid = spec_id_for_key(manifest["id"])
+
+ manifest["name"] = "Customer search v2"
+ manifest["requirements"].append({"id": "R2", "text": "Filter by status"})
+ client.put(f"/spec/specs/{spec_uuid}", json=manifest)
+
+ resp = client.get(f"/spec/specs/{spec_uuid}/versions/1/diff/2")
+
+ assert resp.status_code == 200, resp.text
+ body = resp.json()
+ assert body["from_version"] == 1
+ assert body["to_version"] == 2
+ assert any(line["op"] == "insert" for line in body["markdown"])
+ assert any(line["op"] == "delete" for line in body["markdown"])
+ scalar_fields = {c["field"] for c in body["manifest"]["scalar_changes"]}
+ assert "name" in scalar_fields
+ added_requirement_ids = {
+ c["id"] for c in body["manifest"]["requirements"] if c["change"] == "added"
+ }
+ assert "R2" in added_requirement_ids
+
+
+def test_diff_missing_version_is_404(client: TestClient) -> None:
+ manifest = _create_spec(client)
+ spec_uuid = spec_id_for_key(manifest["id"])
+
+ resp = client.get(f"/spec/specs/{spec_uuid}/versions/1/diff/2")
+
+ assert resp.status_code == 404
+
+
+def test_versions_are_workspace_scoped(
+ client: TestClient, db_factory: sessionmaker[Session]
+) -> None:
+ other_workspace = uuid.uuid4()
+ with db_factory() as session:
+ session.add(Workspace(id=other_workspace, name="Other", slug="other"))
+ session.commit()
+
+ manifest = _create_spec(client)
+ spec_uuid = spec_id_for_key(manifest["id"])
+
+ # A different workspace never sees this spec's versions (empty, not 404 —
+ # mirrors the F23 dashboard's "no linked specs" convention).
+ from forge_api.deps import Principal
+ from forge_contracts import UserRole
+
+ other_principal = Principal(
+ user_id=uuid.uuid4(),
+ workspace_id=other_workspace,
+ role=UserRole.ADMIN,
+ auth_method="test",
+ scopes=["*"],
+ )
+ client.app.dependency_overrides[get_current_principal] = lambda: other_principal
+ resp = client.get(f"/spec/specs/{spec_uuid}/versions")
+ assert resp.status_code == 200
+ assert resp.json() == []
diff --git a/apps/web/package.json b/apps/web/package.json
index 3bf7a081..9dd86e9b 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -28,7 +28,8 @@
"next": "^16.2.9",
"react": "^19.2.7",
"react-dom": "^19.2.7",
- "tailwind-merge": "^3.6.0"
+ "tailwind-merge": "^3.6.0",
+ "yaml": "^2.9.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.3.1",
diff --git a/apps/web/src/app/(board)/specs/[id]/page.tsx b/apps/web/src/app/(board)/specs/[id]/page.tsx
new file mode 100644
index 00000000..f70133dc
--- /dev/null
+++ b/apps/web/src/app/(board)/specs/[id]/page.tsx
@@ -0,0 +1,14 @@
+import { SpecStudioPage } from "@/components/spec-studio/spec-studio-page";
+
+/**
+ * `/specs/{id}` — a dedicated, deep-linkable Spec Studio for one spec,
+ * defaulting to Guided mode.
+ */
+export default async function SpecRoute({
+ params,
+}: {
+ params: Promise<{ id: string }>;
+}) {
+ const { id } = await params;
+ return ;
+}
diff --git a/apps/web/src/app/(board)/specs/new/page.tsx b/apps/web/src/app/(board)/specs/new/page.tsx
new file mode 100644
index 00000000..d1495dd9
--- /dev/null
+++ b/apps/web/src/app/(board)/specs/new/page.tsx
@@ -0,0 +1,19 @@
+import { Suspense } from "react";
+
+import { NewSpecPage } from "@/components/spec-studio/new-spec-page";
+
+/**
+ * `/specs/new` — the guided spec-creation entry point (pick an epic, draft
+ * the goal/requirements/acceptance criteria via the Guided-mode form).
+ *
+ * Wrapped in `Suspense`: `NewSpecPage` reads `?epicId=` via `useSearchParams`
+ * (the board-epic "Create spec" entry point preselects the epic), which Next
+ * requires a Suspense boundary around for static export.
+ */
+export default function NewSpecRoute() {
+ return (
+
+
+
+ );
+}
diff --git a/apps/web/src/components/board/depth/depth-roadmap.test.tsx b/apps/web/src/components/board/depth/depth-roadmap.test.tsx
index 91f8b6e5..45e1c1a0 100644
--- a/apps/web/src/components/board/depth/depth-roadmap.test.tsx
+++ b/apps/web/src/components/board/depth/depth-roadmap.test.tsx
@@ -31,4 +31,20 @@ describe("DepthRoadmap", () => {
expect(screen.getByTestId("milestone-m1")).toHaveTextContent("Beta");
expect(within(screen.getByTestId("cell-e1-s1")).getByText("Login")).toBeInTheDocument();
});
+
+ it("gives every real epic lane a 'Create spec' action pointing at /specs/new", () => {
+ render(
+ ,
+ );
+ const link = screen.getByTestId("lane-create-spec-e1");
+ expect(link).toHaveAttribute("href", "/specs/new?epicId=e1");
+ });
+
+ it("does not offer 'Create spec' on the synthetic 'No epic' lane", () => {
+ const unepiced: TaskDTO[] = [{ id: "t2", title: "Stray", status: "backlog" }];
+ render(
+ ,
+ );
+ expect(screen.queryByTestId(/lane-create-spec-__no_epic__/)).not.toBeInTheDocument();
+ });
});
diff --git a/apps/web/src/components/board/depth/depth-roadmap.tsx b/apps/web/src/components/board/depth/depth-roadmap.tsx
index abf90b13..15769333 100644
--- a/apps/web/src/components/board/depth/depth-roadmap.tsx
+++ b/apps/web/src/components/board/depth/depth-roadmap.tsx
@@ -1,6 +1,7 @@
"use client";
-import { Flag } from "lucide-react";
+import { FilePlus2, Flag } from "lucide-react";
+import Link from "next/link";
import type {
EpicDTO,
@@ -10,7 +11,7 @@ import type {
TaskStatus,
} from "@/lib/api/types";
import { STATUS_LABELS } from "@/lib/board/status";
-import { buildRoadmap, type RoadmapColumn } from "@/lib/board/roadmap";
+import { buildRoadmap, NO_EPIC_ID, type RoadmapColumn } from "@/lib/board/roadmap";
import { cn } from "@/lib/utils";
export interface DepthRoadmapProps {
@@ -167,13 +168,25 @@ interface RoadmapLaneRowProps {
}
function RoadmapLaneRow({ laneId, label, columns, cells }: RoadmapLaneRowProps) {
+ const isRealEpic = laneId !== NO_EPIC_ID;
return (
<>
{label}
+ {isRealEpic ? (
+
+
+
+ ) : null}
{columns.map((column) => {
const cellTasks = cells[column.id] ?? [];
diff --git a/apps/web/src/components/spec-studio/ai-draft-panel.test.tsx b/apps/web/src/components/spec-studio/ai-draft-panel.test.tsx
new file mode 100644
index 00000000..af07f77c
--- /dev/null
+++ b/apps/web/src/components/spec-studio/ai-draft-panel.test.tsx
@@ -0,0 +1,153 @@
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { fireEvent, render, screen, waitFor } from "@testing-library/react";
+import type { ReactNode } from "react";
+import { describe, expect, it, vi } from "vitest";
+
+import type { ForgeApiClient } from "@/lib/api/client";
+import type { SpecDraft } from "@/lib/api/types";
+
+import { AiDraftPanel } from "./ai-draft-panel";
+
+const draft: SpecDraft = {
+ goal: "Let customers search orders by name",
+ model: "claude-opus-4-8",
+ spec_md: "---\nid: SPEC-DRAFT\nstatus: draft\n---\n\n## Goal\n\nSearch orders by name\n",
+ manifest: {
+ id: "SPEC-DRAFT",
+ name: "Search orders by name",
+ status: "draft",
+ requirements: [{ id: "R1", text: "Search orders by customer name" }],
+ },
+ usage: { input_tokens: 120, output_tokens: 340, cost_usd: 0.0123, calls: 1 },
+};
+
+function makeClient(overrides: Partial = {}): ForgeApiClient {
+ return {
+ draftSpec: vi.fn(() => Promise.resolve(draft)),
+ ...overrides,
+ } as unknown as ForgeApiClient;
+}
+
+// Real timers throughout (fake timers + RTL's `waitFor` polling deadlock one
+// another): a 1ms reveal interval with a tiny chunk size still exercises the
+// progressive-reveal behaviour (asserting an early, partial frame) while
+// keeping the test fast and using real setTimeout/setInterval end to end.
+function renderPanel(client: ForgeApiClient, onDraft = vi.fn()) {
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
+ });
+ function Wrapper({ children }: { children: ReactNode }) {
+ return {children};
+ }
+ return {
+ onDraft,
+ ...render(
+ ,
+ { wrapper: Wrapper },
+ ),
+ };
+}
+
+describe("AiDraftPanel", () => {
+ it("disables the draft button until a goal is typed", () => {
+ const client = makeClient();
+ renderPanel(client);
+ expect(screen.getByTestId("ai-draft-submit")).toBeDisabled();
+
+ fireEvent.change(screen.getByTestId("ai-draft-goal"), {
+ target: { value: "Search orders by name" },
+ });
+ expect(screen.getByTestId("ai-draft-submit")).toBeEnabled();
+ });
+
+ it("drafts, streams the spec.md into view, and hands off the parsed manifest once the reveal settles", async () => {
+ const client = makeClient();
+ const { onDraft } = renderPanel(client);
+
+ fireEvent.change(screen.getByTestId("ai-draft-goal"), {
+ target: { value: "Search orders by name" },
+ });
+ fireEvent.click(screen.getByTestId("ai-draft-submit"));
+
+ await waitFor(() =>
+ expect(client.draftSpec).toHaveBeenCalledWith(
+ expect.objectContaining({ goal: "Search orders by name" }),
+ ),
+ );
+
+ // Settles on the full drafted text (revealed progressively via a reveal
+ // interval, exercised at the unit level by `revealChunkSize`/
+ // `revealIntervalMs` — see the component doc), then hands the completed
+ // draft off exactly once, never before the stream has caught up.
+ await waitFor(() =>
+ expect(screen.getByTestId("ai-draft-stream").textContent).toBe(draft.spec_md),
+ );
+ await waitFor(() => expect(onDraft).toHaveBeenCalledTimes(1));
+ expect(onDraft).toHaveBeenCalledWith(draft);
+ });
+
+ it("surfaces the resolved model/tier and estimated cost", async () => {
+ const client = makeClient();
+ renderPanel(client);
+
+ fireEvent.change(screen.getByTestId("ai-draft-goal"), {
+ target: { value: "Search orders by name" },
+ });
+ fireEvent.click(screen.getByTestId("ai-draft-submit"));
+
+ await waitFor(() => expect(screen.getByTestId("ai-draft-model")).toBeInTheDocument());
+ expect(screen.getByTestId("ai-draft-model").textContent).toContain("claude-opus-4-8");
+ expect(screen.getByTestId("ai-draft-model").textContent).toContain("senior tier");
+ expect(screen.getByTestId("ai-draft-cost").textContent).toContain("0.0123");
+ });
+
+ it("marks the result as a draft to refine, never auto-saved", async () => {
+ const client = makeClient();
+ renderPanel(client);
+
+ fireEvent.change(screen.getByTestId("ai-draft-goal"), {
+ target: { value: "Search orders by name" },
+ });
+ fireEvent.click(screen.getByTestId("ai-draft-submit"));
+
+ await waitFor(() =>
+ expect(screen.getByTestId("ai-draft-badge").textContent).toContain("review before saving"),
+ );
+ });
+
+ it("surfaces a parse error without losing the raw draft text", async () => {
+ const badDraft: SpecDraft = {
+ ...draft,
+ manifest: null,
+ parse_error: "missing frontmatter",
+ };
+ const client = makeClient({ draftSpec: vi.fn(() => Promise.resolve(badDraft)) });
+ const { onDraft } = renderPanel(client);
+
+ fireEvent.change(screen.getByTestId("ai-draft-goal"), {
+ target: { value: "Search orders by name" },
+ });
+ fireEvent.click(screen.getByTestId("ai-draft-submit"));
+
+ await waitFor(() => expect(screen.getByTestId("ai-draft-parse-error")).toBeInTheDocument());
+ await waitFor(() => expect(onDraft).toHaveBeenCalledWith(badDraft));
+ });
+
+ it("surfaces a request error", async () => {
+ const client = makeClient({
+ draftSpec: vi.fn(() => Promise.reject(new Error("no model provider configured"))),
+ });
+ renderPanel(client);
+
+ fireEvent.change(screen.getByTestId("ai-draft-goal"), {
+ target: { value: "Search orders by name" },
+ });
+ fireEvent.click(screen.getByTestId("ai-draft-submit"));
+
+ await waitFor(() =>
+ expect(screen.getByTestId("ai-draft-error").textContent).toContain(
+ "no model provider configured",
+ ),
+ );
+ });
+});
diff --git a/apps/web/src/components/spec-studio/ai-draft-panel.tsx b/apps/web/src/components/spec-studio/ai-draft-panel.tsx
new file mode 100644
index 00000000..b44b2f0d
--- /dev/null
+++ b/apps/web/src/components/spec-studio/ai-draft-panel.tsx
@@ -0,0 +1,189 @@
+"use client";
+
+import { Sparkles } from "lucide-react";
+import { useEffect, useRef, useState } from "react";
+
+import { Button } from "@/components/ui/button";
+import { ApiError, apiClient, type ForgeApiClient } from "@/lib/api/client";
+import { useDraftSpec } from "@/lib/api/spec-studio";
+import type { SpecDraft } from "@/lib/api/types";
+
+export interface AiDraftPanelProps {
+ epicId?: string;
+ projectId?: string;
+ client?: ForgeApiClient;
+ /**
+ * Called once the drafted `spec_md` has fully streamed in, handing the
+ * caller the raw prose plus its parsed `SpecManifest` preview so it can
+ * seed the Guided or Markdown editor.
+ */
+ onDraft: (draft: SpecDraft) => void;
+ /** ms between reveal ticks (test hook; production default reads as "typing"). */
+ revealIntervalMs?: number;
+ /** Characters revealed per tick (test hook). */
+ revealChunkSize?: number;
+}
+
+function errorMessage(error: unknown): string {
+ if (error instanceof ApiError) return error.message;
+ if (error instanceof Error) return error.message;
+ return "Something went wrong";
+}
+
+/**
+ * `ss-ai-panel` — the AI draft-from-a-sentence entry point. Type a one-line
+ * goal; `POST /spec/draft` asks the workspace's BYOK model (routed by the
+ * Adaptive Orchestration model router, seeded with the project constitution)
+ * to write a `spec.md`. The full draft comes back in one response (the
+ * provider-side streaming already happened inside the backend call), but it
+ * is *revealed* here character-by-character so the authoring experience reads
+ * as the model "typing" the draft live rather than a page reflow.
+ *
+ * The result is always clearly marked as a draft to refine — nothing is
+ * auto-saved. Once the reveal settles, `onDraft` hands the caller the parsed
+ * manifest preview + raw `spec_md` so it can populate the Guided/Markdown
+ * editor. The resolved model (provider + the fixed senior authoring tier) and
+ * the estimated cost of the call are surfaced alongside the draft.
+ */
+export function AiDraftPanel({
+ epicId,
+ projectId,
+ client = apiClient,
+ onDraft,
+ revealIntervalMs = 20,
+ revealChunkSize = 12,
+}: AiDraftPanelProps) {
+ const [goal, setGoal] = useState("");
+ const [revealed, setRevealed] = useState("");
+ const draftSpec = useDraftSpec(client);
+ const timerRef = useRef | null>(null);
+
+ const fullText = draftSpec.data?.spec_md ?? "";
+ const streaming = draftSpec.isSuccess && revealed.length < fullText.length;
+
+ useEffect(
+ () => () => {
+ if (timerRef.current) clearInterval(timerRef.current);
+ },
+ [],
+ );
+
+ function handleDraft() {
+ const trimmed = goal.trim();
+ if (!trimmed) return;
+ setRevealed("");
+ if (timerRef.current) {
+ clearInterval(timerRef.current);
+ timerRef.current = null;
+ }
+ draftSpec.mutate(
+ { goal: trimmed, epic_id: epicId, project_id: projectId },
+ {
+ onSuccess: (result) => {
+ const text = result.spec_md ?? "";
+ if (!text) return;
+ let index = 0;
+ timerRef.current = setInterval(() => {
+ index = Math.min(text.length, index + revealChunkSize);
+ setRevealed(text.slice(0, index));
+ // Hand off to the caller only once the live reveal has fully
+ // caught up with the drafted text (not the instant the response
+ // arrived), so the caller only ever sees the "completed" draft —
+ // matching what the user just watched stream in — and exactly
+ // once per draft.
+ if (index >= text.length) {
+ if (timerRef.current) {
+ clearInterval(timerRef.current);
+ timerRef.current = null;
+ }
+ onDraft(result);
+ }
+ }, revealIntervalMs);
+ },
+ },
+ );
+ }
+
+ const usage = draftSpec.data?.usage;
+
+ return (
+
+
+
+
Draft with AI
+
+
+ Describe the goal in one line — a draft spec.md streams in below. It’s a starting
+ point: review and refine before saving.
+
+
+ setGoal(event.target.value)}
+ placeholder="e.g. Let customers search orders by name"
+ disabled={draftSpec.isPending}
+ className="flex-1 rounded-md border border-border bg-card px-3 py-2 text-sm text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring"
+ />
+
+
+
+ {draftSpec.isError ? (
+
+ {errorMessage(draftSpec.error)}
+
+ ) : null}
+
+ {draftSpec.isSuccess ? (
+
+
+
+ Draft — review before saving
+
+
+ {draftSpec.data.model} · senior tier
+
+ {typeof usage?.cost_usd === "number" ? (
+
+ ${usage.cost_usd.toFixed(4)}
+
+ ) : null}
+ {streaming ? Streaming… : null}
+
+
+ {revealed}
+
+ {draftSpec.data.parse_error ? (
+
+ Draft didn’t fully parse: {draftSpec.data.parse_error}. You can still edit it
+ as Markdown.
+
+ ) : null}
+
+ ) : null}
+
+ );
+}
diff --git a/apps/web/src/components/spec-studio/guided-helpers.test.ts b/apps/web/src/components/spec-studio/guided-helpers.test.ts
new file mode 100644
index 00000000..2d2b7954
--- /dev/null
+++ b/apps/web/src/components/spec-studio/guided-helpers.test.ts
@@ -0,0 +1,197 @@
+import { describe, expect, it } from "vitest";
+
+import type { SpecManifest } from "@/lib/api/types";
+
+import {
+ addAcceptanceCriterion,
+ addAdr,
+ addRequirement,
+ classifyCriterionStyle,
+ composeChecklist,
+ composeGivenWhenThen,
+ computeChecklist,
+ computeCoverage,
+ computeNudges,
+ convertCriterionText,
+ nextSequentialId,
+ parseChecklist,
+ parseGivenWhenThen,
+} from "./guided-helpers";
+
+describe("nextSequentialId", () => {
+ it("returns R1 for an empty list", () => {
+ expect(nextSequentialId("R", [])).toBe("R1");
+ });
+
+ it("returns one past the highest existing number", () => {
+ expect(nextSequentialId("R", ["R1", "R2", "R5"])).toBe("R6");
+ });
+
+ it("ignores ids that don't match the prefix pattern", () => {
+ expect(nextSequentialId("AC", ["R1", "AC3", "custom-id"])).toBe("AC4");
+ });
+});
+
+describe("Given/When/Then round trip", () => {
+ it("composes then parses back to the same parts", () => {
+ const parts = { given: "a user", when: "they sign in", then: "they land on the board" };
+ const text = composeGivenWhenThen(parts);
+ expect(text).toBe("Given a user When they sign in Then they land on the board");
+ expect(parseGivenWhenThen(text)).toEqual(parts);
+ });
+
+ it("treats unstructured text as the Then clause", () => {
+ expect(parseGivenWhenThen("just some prose")).toEqual({
+ given: "",
+ when: "",
+ then: "just some prose",
+ });
+ });
+
+ it("omits empty parts when composing", () => {
+ expect(composeGivenWhenThen({ given: "", when: "", then: "it works" })).toBe("Then it works");
+ });
+});
+
+describe("classifyCriterionStyle", () => {
+ it("defaults blank text to gherkin (the editor's default shape)", () => {
+ expect(classifyCriterionStyle("")).toBe("gherkin");
+ expect(classifyCriterionStyle(" ")).toBe("gherkin");
+ });
+
+ it("classifies Given/When/Then prose as gherkin", () => {
+ expect(classifyCriterionStyle("Given a user When they sign in Then the board loads")).toBe("gherkin");
+ expect(classifyCriterionStyle("Then it works")).toBe("gherkin");
+ });
+
+ it("classifies a keyword-free sentence as a plain assertion", () => {
+ expect(classifyCriterionStyle("The endpoint returns 200 for a valid token")).toBe("assertion");
+ });
+
+ it("classifies check-item lines as a checklist, even when a label says 'when'", () => {
+ expect(classifyCriterionStyle("- [ ] Logs an event when it runs\n- [x] Retries on failure")).toBe(
+ "checklist",
+ );
+ });
+});
+
+describe("checklist (de)serialisation", () => {
+ it("composes then parses back to the same items", () => {
+ const items = [
+ { label: "Email field validates", checked: false },
+ { label: "Password is masked", checked: true },
+ ];
+ const text = composeChecklist(items);
+ expect(text).toBe("- [ ] Email field validates\n- [x] Password is masked");
+ expect(parseChecklist(text)).toEqual(items);
+ });
+
+ it("renders an empty label without a trailing space", () => {
+ expect(composeChecklist([{ label: "", checked: false }])).toBe("- [ ]");
+ });
+});
+
+describe("convertCriterionText", () => {
+ it("wraps prose into a single unchecked item when switching to a checklist", () => {
+ expect(convertCriterionText("Then it works", "checklist")).toBe("- [ ] Then it works");
+ });
+
+ it("joins checklist labels back into prose when leaving the checklist style", () => {
+ const text = "- [ ] first\n- [x] second";
+ expect(convertCriterionText(text, "assertion")).toBe("first; second");
+ expect(convertCriterionText(text, "gherkin")).toBe("first second");
+ });
+
+ it("is a no-op between gherkin and assertion (shared flat prose)", () => {
+ expect(convertCriterionText("The system does X", "gherkin")).toBe("The system does X");
+ });
+});
+
+describe("computeNudges", () => {
+ const base: SpecManifest = { id: "s1", name: "Passwordless auth" };
+
+ it("nudges an empty goal and missing requirements", () => {
+ const nudges = computeNudges({ ...base, name: "" });
+ expect(nudges.map((n) => n.id)).toEqual(expect.arrayContaining(["no-goal", "no-requirements"]));
+ });
+
+ it("nudges a requirement with no linked acceptance criterion", () => {
+ const nudges = computeNudges({
+ ...base,
+ requirements: [{ id: "R1", text: "Sign in" }],
+ acceptance_criteria: [],
+ });
+ expect(nudges.some((n) => n.id === "uncovered-R1")).toBe(true);
+ });
+
+ it("has no coverage nudges once every requirement is linked", () => {
+ const nudges = computeNudges({
+ ...base,
+ requirements: [{ id: "R1", text: "Sign in" }],
+ acceptance_criteria: [{ id: "AC1", text: "Given...", req_refs: ["R1"] }],
+ });
+ expect(nudges.some((n) => n.id.startsWith("uncovered-"))).toBe(false);
+ expect(nudges.some((n) => n.id === "unlinked-AC1")).toBe(false);
+ });
+
+ it("nudges an unresolved open question", () => {
+ const nudges = computeNudges({
+ ...base,
+ open_questions: [{ id: "Q1", text: "Which provider?" }],
+ });
+ expect(nudges.some((n) => n.id === "open-questions")).toBe(true);
+ });
+});
+
+describe("computeCoverage", () => {
+ it("is 0/0 with no requirements", () => {
+ expect(computeCoverage({ id: "s1", name: "x" })).toEqual({ satisfied: 0, total: 0, pct: 0 });
+ });
+
+ it("computes the satisfied fraction", () => {
+ const coverage = computeCoverage({
+ id: "s1",
+ name: "x",
+ requirements: [
+ { id: "R1", text: "a" },
+ { id: "R2", text: "b" },
+ ],
+ acceptance_criteria: [{ id: "AC1", text: "t", req_refs: ["R1"] }],
+ });
+ expect(coverage).toEqual({ satisfied: 1, total: 2, pct: 50 });
+ });
+});
+
+describe("computeChecklist", () => {
+ it("is all incomplete for an empty manifest", () => {
+ const items = computeChecklist({ id: "s1", name: "" });
+ expect(items.every((i) => !i.done)).toBe(true);
+ });
+
+ it("is all complete for a fully covered manifest", () => {
+ const items = computeChecklist({
+ id: "s1",
+ name: "Passwordless auth",
+ requirements: [{ id: "R1", text: "a" }],
+ acceptance_criteria: [{ id: "AC1", text: "t", req_refs: ["R1"] }],
+ });
+ expect(items.every((i) => i.done)).toBe(true);
+ });
+});
+
+describe("add* helpers", () => {
+ it("addRequirement appends the next sequential requirement", () => {
+ expect(addRequirement([{ id: "R1", text: "a" }])).toEqual([
+ { id: "R1", text: "a" },
+ { id: "R2", text: "" },
+ ]);
+ });
+
+ it("addAcceptanceCriterion appends the next sequential AC with empty req_refs", () => {
+ expect(addAcceptanceCriterion([])).toEqual([{ id: "AC1", text: "", req_refs: [] }]);
+ });
+
+ it("addAdr appends the next sequential ADR", () => {
+ expect(addAdr([])).toEqual([{ id: "ADR1", title: "", status: "proposed" }]);
+ });
+});
diff --git a/apps/web/src/components/spec-studio/guided-helpers.ts b/apps/web/src/components/spec-studio/guided-helpers.ts
new file mode 100644
index 00000000..9e109414
--- /dev/null
+++ b/apps/web/src/components/spec-studio/guided-helpers.ts
@@ -0,0 +1,254 @@
+/**
+ * Pure helpers backing Guided mode — auto-numbering, Given/When/Then
+ * composition, and the soft validation nudges + Ready-to-create checklist /
+ * coverage meter. Kept dependency-free (no React) so they're trivially unit
+ * tested and reusable from both the form and its summary panel.
+ */
+
+import type { AcceptanceCriterion, ADR, Requirement, SpecManifest } from "@/lib/api/types";
+
+/**
+ * The next sequential id for a prefix (`"R"` / `"AC"` / `"ADR"`) given the ids
+ * already in use — scans for `${prefix}` and returns one past the
+ * highest match (or `${prefix}1` when none match), so ids stay auto-numbered
+ * even after items in the middle are removed.
+ */
+export function nextSequentialId(prefix: string, existingIds: readonly string[]): string {
+ const pattern = new RegExp(`^${prefix}(\\d+)$`);
+ let max = 0;
+ for (const id of existingIds) {
+ const match = pattern.exec(id);
+ if (match) {
+ const n = Number.parseInt(match[1], 10);
+ if (n > max) max = n;
+ }
+ }
+ return `${prefix}${max + 1}`;
+}
+
+export interface GivenWhenThen {
+ given: string;
+ when: string;
+ then: string;
+}
+
+/**
+ * Best-effort split of an AC's free-text `text` into Given/When/Then parts.
+ * Each keyword is matched independently (not as one all-or-nothing pattern),
+ * so a still-partial edit — e.g. only "Given ..." typed so far — round-trips
+ * without losing what's already there.
+ */
+export function parseGivenWhenThen(text: string): GivenWhenThen {
+ const trimmed = text.trim();
+ const givenMatch = /Given\s+(.*?)(?=\s+When\s+|\s+Then\s+|$)/is.exec(trimmed);
+ const whenMatch = /When\s+(.*?)(?=\s+Then\s+|$)/is.exec(trimmed);
+ const thenMatch = /Then\s+(.*)$/is.exec(trimmed);
+ if (!givenMatch && !whenMatch && !thenMatch) {
+ return { given: "", when: "", then: trimmed };
+ }
+ return {
+ given: givenMatch ? givenMatch[1].trim() : "",
+ when: whenMatch ? whenMatch[1].trim() : "",
+ then: thenMatch ? thenMatch[1].trim() : "",
+ };
+}
+
+/** Compose Given/When/Then parts back into the AC's single `text` field. */
+export function composeGivenWhenThen({ given, when, then }: GivenWhenThen): string {
+ const parts: string[] = [];
+ if (given) parts.push(`Given ${given}`);
+ if (when) parts.push(`When ${when}`);
+ if (then) parts.push(`Then ${then}`);
+ return parts.join(" ");
+}
+
+/**
+ * The three first-class acceptance-criterion authoring styles. Every style is
+ * encoded losslessly inside the criterion's single `text` field, so switching
+ * style never touches its `req_refs` (R#) links. Mirrors
+ * `forge_spec.criteria.classify_criterion` on the backend.
+ */
+export type CriterionStyle = "gherkin" | "assertion" | "checklist";
+
+/** `- [ ] label` / `- [x] label` — the checked box is case-insensitive. */
+const CHECK_ITEM = /^- \[([ xX])\] ?(.*)$/;
+const GHERKIN_KEYWORD = /\b(?:given|when|then)\b/i;
+
+/** One checklist entry: a `label` and whether its box is `checked`. */
+export interface CheckItem {
+ label: string;
+ checked: boolean;
+}
+
+function nonBlankLines(text: string): string[] {
+ return text
+ .split("\n")
+ .map((line) => line.trim())
+ .filter((line) => line.length > 0);
+}
+
+/**
+ * Infer a criterion's authoring style from its `text` (never throws). Blank
+ * text defaults to `"gherkin"` (the editor's default shape); a `text` whose
+ * every non-blank line is a check item is a `"checklist"` even if a label
+ * contains a Gherkin keyword; otherwise Gherkin keywords => `"gherkin"`, and
+ * anything else is a plain `"assertion"`.
+ */
+export function classifyCriterionStyle(text: string): CriterionStyle {
+ const lines = nonBlankLines(text);
+ if (lines.length === 0) return "gherkin";
+ if (lines.every((line) => CHECK_ITEM.test(line))) return "checklist";
+ if (GHERKIN_KEYWORD.test(text)) return "gherkin";
+ return "assertion";
+}
+
+/** Parse checklist `text` into items (a non-item line becomes an unchecked item). */
+export function parseChecklist(text: string): CheckItem[] {
+ return nonBlankLines(text).map((line) => {
+ const match = CHECK_ITEM.exec(line);
+ if (!match) return { label: line, checked: false };
+ return { label: match[2].trim(), checked: match[1] === "x" || match[1] === "X" };
+ });
+}
+
+/** Render checklist `items` back to canonical `- [ ] label` lines. */
+export function composeChecklist(items: readonly CheckItem[]): string {
+ return items.map((item) => `- [${item.checked ? "x" : " "}] ${item.label}`.trimEnd()).join("\n");
+}
+
+/**
+ * Re-encode a criterion's `text` for a new `target` style, preserving prose
+ * where it makes sense so switching styles doesn't silently lose content.
+ */
+export function convertCriterionText(text: string, target: CriterionStyle): string {
+ const current = classifyCriterionStyle(text);
+ if (current === target) return text;
+ if (target === "checklist") {
+ const label = text.trim();
+ return composeChecklist([{ label, checked: false }]);
+ }
+ if (current === "checklist") {
+ const labels = parseChecklist(text)
+ .map((item) => item.label)
+ .filter(Boolean);
+ // Gherkin editor will re-parse the joined prose; assertion keeps it flat.
+ return labels.join(target === "gherkin" ? " " : "; ");
+ }
+ // gherkin <-> assertion share the same flat prose encoding.
+ return text;
+}
+
+/** A single soft-validation nudge — never blocking, just surfaced guidance. */
+export interface Nudge {
+ id: string;
+ message: string;
+}
+
+/** Non-blocking nudges: gaps a human should notice before creating the spec. */
+export function computeNudges(manifest: SpecManifest): Nudge[] {
+ const nudges: Nudge[] = [];
+ const requirements = manifest.requirements ?? [];
+ const criteria = manifest.acceptance_criteria ?? [];
+
+ if (!manifest.name.trim()) {
+ nudges.push({ id: "no-goal", message: "The goal is empty — describe what this spec achieves." });
+ }
+ if (requirements.length === 0) {
+ nudges.push({ id: "no-requirements", message: "Add at least one requirement." });
+ }
+ if (requirements.length > 0 && criteria.length === 0) {
+ nudges.push({
+ id: "no-criteria",
+ message: "Add acceptance criteria so requirements can be verified.",
+ });
+ }
+ for (const req of requirements) {
+ const linked = criteria.some((ac) => (ac.req_refs ?? []).includes(req.id));
+ if (!linked) {
+ nudges.push({
+ id: `uncovered-${req.id}`,
+ message: `${req.id} has no linked acceptance criterion.`,
+ });
+ }
+ }
+ for (const req of requirements) {
+ if (!req.text.trim()) {
+ nudges.push({ id: `empty-req-${req.id}`, message: `${req.id} has no description yet.` });
+ }
+ }
+ for (const ac of criteria) {
+ if ((ac.req_refs ?? []).length === 0) {
+ nudges.push({ id: `unlinked-${ac.id}`, message: `${ac.id} isn't linked to a requirement.` });
+ }
+ }
+ const openQuestions = manifest.open_questions ?? [];
+ const unresolved = openQuestions.filter((q) => !q.resolution);
+ if (unresolved.length > 0) {
+ nudges.push({
+ id: "open-questions",
+ message: `${unresolved.length} open question${unresolved.length === 1 ? "" : "s"} still unresolved.`,
+ });
+ }
+ return nudges;
+}
+
+/** Requirement coverage: the fraction of requirements with >=1 linked AC. */
+export interface CoverageSummary {
+ satisfied: number;
+ total: number;
+ pct: number;
+}
+
+export function computeCoverage(manifest: SpecManifest): CoverageSummary {
+ const requirements = manifest.requirements ?? [];
+ const criteria = manifest.acceptance_criteria ?? [];
+ const total = requirements.length;
+ const satisfied = requirements.filter((req) =>
+ criteria.some((ac) => (ac.req_refs ?? []).includes(req.id)),
+ ).length;
+ const pct = total > 0 ? Math.round((satisfied / total) * 100) : 0;
+ return { satisfied, total, pct };
+}
+
+/** One line of the Ready-to-create checklist. */
+export interface ChecklistItem {
+ id: string;
+ label: string;
+ done: boolean;
+}
+
+/** The Ready-to-create checklist — the minimum bar for a spec worth reviewing. */
+export function computeChecklist(manifest: SpecManifest): ChecklistItem[] {
+ const requirements = manifest.requirements ?? [];
+ const criteria = manifest.acceptance_criteria ?? [];
+ const coverage = computeCoverage(manifest);
+ return [
+ { id: "goal", label: "Goal is filled in", done: manifest.name.trim().length > 0 },
+ { id: "requirements", label: "At least one requirement", done: requirements.length > 0 },
+ {
+ id: "criteria",
+ label: "At least one acceptance criterion",
+ done: criteria.length > 0,
+ },
+ {
+ id: "coverage",
+ label: "Every requirement has a linked acceptance criterion",
+ done: requirements.length > 0 && coverage.satisfied === coverage.total,
+ },
+ ];
+}
+
+export function addRequirement(requirements: Requirement[]): Requirement[] {
+ const id = nextSequentialId("R", requirements.map((r) => r.id));
+ return [...requirements, { id, text: "" }];
+}
+
+export function addAcceptanceCriterion(criteria: AcceptanceCriterion[]): AcceptanceCriterion[] {
+ const id = nextSequentialId("AC", criteria.map((c) => c.id));
+ return [...criteria, { id, text: "", req_refs: [] }];
+}
+
+export function addAdr(decisions: ADR[]): ADR[] {
+ const id = nextSequentialId("ADR", decisions.map((d) => d.id));
+ return [...decisions, { id, title: "", status: "proposed" }];
+}
diff --git a/apps/web/src/components/spec-studio/guided-mode.test.tsx b/apps/web/src/components/spec-studio/guided-mode.test.tsx
new file mode 100644
index 00000000..b9498b1f
--- /dev/null
+++ b/apps/web/src/components/spec-studio/guided-mode.test.tsx
@@ -0,0 +1,191 @@
+import { fireEvent, render, screen } from "@testing-library/react";
+import { useState } from "react";
+import { describe, expect, it, vi } from "vitest";
+
+import type { SpecManifest } from "@/lib/api/types";
+
+import { GuidedMode } from "./guided-mode";
+
+function Harness({ initial }: { initial: SpecManifest }) {
+ const [value, setValue] = useState(initial);
+ return ;
+}
+
+const baseManifest: SpecManifest = {
+ id: "s1",
+ name: "Passwordless auth",
+ requirements: [{ id: "R1", text: "Sign in without a password" }],
+};
+
+describe("GuidedMode", () => {
+ it("renders the Goal, Requirements, Acceptance Criteria and Constraints blocks", () => {
+ render();
+ expect(screen.getByTestId("guided-name")).toHaveValue("Passwordless auth");
+ expect(screen.getByTestId("guided-requirements")).toBeInTheDocument();
+ expect(screen.getByTestId("guided-acceptance-criteria")).toBeInTheDocument();
+ expect(screen.getByTestId("guided-constraints")).toBeInTheDocument();
+ });
+
+ it("auto-numbers a newly added requirement without a text input for its id", () => {
+ render();
+ fireEvent.click(screen.getByTestId("guided-add-requirement"));
+ expect(screen.getByTestId("requirement-id-0")).toHaveTextContent("R1");
+ fireEvent.click(screen.getByTestId("guided-add-requirement"));
+ expect(screen.getByTestId("requirement-id-1")).toHaveTextContent("R2");
+ });
+
+ it("adds an acceptance criterion with Given/When/Then fields and auto-numbered id", () => {
+ render();
+ fireEvent.click(screen.getByTestId("guided-add-acceptance-criterion"));
+ expect(screen.getByTestId("acceptance-criterion-id-0")).toHaveTextContent("AC1");
+ fireEvent.change(screen.getByLabelText("AC1 given"), { target: { value: "a user" } });
+ fireEvent.change(screen.getByLabelText("AC1 when"), { target: { value: "they sign in" } });
+ fireEvent.change(screen.getByLabelText("AC1 then"), { target: { value: "they land on the board" } });
+ expect(screen.getByLabelText("AC1 given")).toHaveValue("a user");
+ expect(screen.getByLabelText("AC1 when")).toHaveValue("they sign in");
+ expect(screen.getByLabelText("AC1 then")).toHaveValue("they land on the board");
+ });
+
+ it("defaults a new acceptance criterion to the Given/When/Then style", () => {
+ render();
+ fireEvent.click(screen.getByTestId("guided-add-acceptance-criterion"));
+ expect(screen.getByTestId("ac-style-0")).toHaveValue("gherkin");
+ expect(screen.getByLabelText("AC1 given")).toBeInTheDocument();
+ });
+
+ it("switches an acceptance criterion to the plain-assertion style", () => {
+ render();
+ fireEvent.click(screen.getByTestId("guided-add-acceptance-criterion"));
+ fireEvent.change(screen.getByTestId("ac-style-0"), { target: { value: "assertion" } });
+ expect(screen.queryByLabelText("AC1 given")).not.toBeInTheDocument();
+ const input = screen.getByLabelText("AC1 assertion");
+ fireEvent.change(input, { target: { value: "The endpoint returns 200" } });
+ expect(input).toHaveValue("The endpoint returns 200");
+ });
+
+ it("switches to the checklist style and edits check items", () => {
+ render();
+ fireEvent.click(screen.getByTestId("guided-add-acceptance-criterion"));
+ fireEvent.change(screen.getByTestId("ac-style-0"), { target: { value: "checklist" } });
+ // Converting an empty criterion seeds one empty item.
+ fireEvent.change(screen.getByLabelText("AC1 item 1"), { target: { value: "Email validates" } });
+ fireEvent.click(screen.getByTestId("ac-checklist-add-AC1"));
+ fireEvent.change(screen.getByLabelText("AC1 item 2"), { target: { value: "Password masked" } });
+ fireEvent.click(screen.getByLabelText("AC1 item 2 done"));
+ expect(screen.getByLabelText("AC1 item 1")).toHaveValue("Email validates");
+ expect(screen.getByLabelText("AC1 item 2 done")).toBeChecked();
+ });
+
+ it("renders a loaded checklist criterion in the checklist editor", () => {
+ render(
+ ,
+ );
+ expect(screen.getByTestId("ac-style-0")).toHaveValue("checklist");
+ expect(screen.getByLabelText("AC1 item 1")).toHaveValue("a");
+ expect(screen.getByLabelText("AC1 item 2 done")).toBeChecked();
+ });
+
+ it("keeps a requirement link when the criterion style changes", () => {
+ render(
+ ,
+ );
+ expect(screen.getByTestId("ac-linked-req-0-R1")).toBeInTheDocument();
+ fireEvent.change(screen.getByTestId("ac-style-0"), { target: { value: "checklist" } });
+ // R# linking is unaffected by the style switch.
+ expect(screen.getByTestId("ac-linked-req-0-R1")).toBeInTheDocument();
+ fireEvent.change(screen.getByTestId("ac-style-0"), { target: { value: "assertion" } });
+ expect(screen.getByTestId("ac-linked-req-0-R1")).toBeInTheDocument();
+ });
+
+ it("links an acceptance criterion to a requirement via the dropdown, not free text", () => {
+ render();
+ fireEvent.click(screen.getByTestId("guided-add-acceptance-criterion"));
+
+ // No free-text "(R#)" entry point exists — only the link dropdown.
+ expect(screen.queryByLabelText(/req_refs/i)).not.toBeInTheDocument();
+
+ const select = screen.getByTestId("ac-link-requirement-0");
+ fireEvent.change(select, { target: { value: "R1" } });
+
+ expect(screen.getByTestId("ac-linked-req-0-R1")).toHaveTextContent("R1");
+ // Once linked, R1 is no longer offered again in the dropdown.
+ expect(screen.queryByRole("option", { name: /R1/ })).not.toBeInTheDocument();
+ });
+
+ it("unlinks a requirement from an acceptance criterion", () => {
+ render(
+ ,
+ );
+ expect(screen.getByTestId("ac-linked-req-0-R1")).toBeInTheDocument();
+ fireEvent.click(screen.getByLabelText("Unlink R1 from AC1"));
+ expect(screen.queryByTestId("ac-linked-req-0-R1")).not.toBeInTheDocument();
+ });
+
+ it("keeps Advanced collapsed by default and reveals it on toggle", () => {
+ render();
+ expect(screen.queryByTestId("guided-advanced-panel")).not.toBeInTheDocument();
+ fireEvent.click(screen.getByTestId("guided-advanced-toggle"));
+ expect(screen.getByTestId("guided-advanced-panel")).toBeInTheDocument();
+ expect(screen.getByTestId("guided-execution-mode")).toBeInTheDocument();
+ expect(screen.getByTestId("guided-constitution-refs")).toBeInTheDocument();
+ expect(screen.getByTestId("guided-repos")).toBeInTheDocument();
+ expect(screen.getByTestId("guided-decisions")).toBeInTheDocument();
+ });
+
+ it("surfaces validation gaps as non-blocking nudges", () => {
+ render();
+ expect(screen.getByTestId("guided-nudge-uncovered-R1")).toBeInTheDocument();
+ // Nudges never disable Save; that's governed by `dirty`, not nudge count.
+ expect(screen.getByTestId("guided-save")).toBeEnabled();
+ });
+
+ it("clears the coverage nudge once every requirement is linked", () => {
+ render(
+ ,
+ );
+ expect(screen.queryByTestId("guided-nudge-uncovered-R1")).not.toBeInTheDocument();
+ });
+
+ it("shows a Ready-to-create checklist and coverage meter", () => {
+ render();
+ expect(screen.getByTestId("guided-coverage-meter")).toHaveTextContent("0/1 requirements covered (0%)");
+ expect(screen.getByTestId("checklist-item-goal")).toBeInTheDocument();
+ expect(screen.getByTestId("checklist-item-coverage")).toBeInTheDocument();
+ });
+
+ it("updates the coverage meter as requirements get linked", () => {
+ render(
+ ,
+ );
+ expect(screen.getByTestId("guided-coverage-meter")).toHaveTextContent(
+ "1/1 requirements covered (100%)",
+ );
+ });
+});
diff --git a/apps/web/src/components/spec-studio/guided-mode.tsx b/apps/web/src/components/spec-studio/guided-mode.tsx
new file mode 100644
index 00000000..7008fcf9
--- /dev/null
+++ b/apps/web/src/components/spec-studio/guided-mode.tsx
@@ -0,0 +1,652 @@
+"use client";
+
+import { ChevronDown, ChevronRight, Plus, Trash2 } from "lucide-react";
+import { useState } from "react";
+
+import { Button } from "@/components/ui/button";
+import {
+ SPEC_STATUSES,
+ type ADR,
+ type AcceptanceCriterion,
+ type ExecutionMode,
+ type Requirement,
+ type SpecManifest,
+ type SpecStatus,
+} from "@/lib/api/types";
+
+import {
+ addAcceptanceCriterion,
+ addAdr,
+ addRequirement,
+ classifyCriterionStyle,
+ composeChecklist,
+ composeGivenWhenThen,
+ computeChecklist,
+ computeCoverage,
+ computeNudges,
+ convertCriterionText,
+ type CriterionStyle,
+ parseChecklist,
+ parseGivenWhenThen,
+} from "./guided-helpers";
+
+export interface GuidedModeProps {
+ /** The current draft manifest (controlled). */
+ value: SpecManifest;
+ onChange: (next: SpecManifest) => void;
+ onSave: () => void;
+ saving?: boolean;
+ dirty?: boolean;
+ saveError?: string | null;
+}
+
+const EXECUTION_MODES: { value: ExecutionMode; label: string }[] = [
+ { value: "single_agent", label: "Single agent" },
+ { value: "supervised_multi_agent", label: "Supervised swarm" },
+];
+
+/**
+ * The Guided mode — a friendly, structured form over the same `SpecManifest`
+ * the Markdown and YAML modes edit. Blocks: **Goal**, **Requirements**,
+ * **Acceptance Criteria** (Given/When/Then, linked to requirements via a
+ * dropdown — never by typing `(R#)`), **Constraints**, and a collapsed
+ * **Advanced** section (constitution refs, execution mode, repos, ADRs).
+ * Validation is surfaced as non-blocking nudges, alongside a Ready-to-create
+ * checklist and a requirement-coverage meter.
+ */
+const CRITERION_STYLES: { value: CriterionStyle; label: string }[] = [
+ { value: "gherkin", label: "Given/When/Then" },
+ { value: "assertion", label: "Plain assertion" },
+ { value: "checklist", label: "Checklist" },
+];
+
+export function GuidedMode({ value, onChange, onSave, saving = false, dirty = false, saveError }: GuidedModeProps) {
+ const [advancedOpen, setAdvancedOpen] = useState(false);
+ // Style is derived from each criterion's text, but an explicit pick (keyed by
+ // criterion id) wins so an empty "assertion" doesn't snap back to the Gherkin
+ // default. Editing the text never clears the pick — R# links are unaffected.
+ const [styleOverrides, setStyleOverrides] = useState>({});
+ const requirements = value.requirements ?? [];
+ const criteria = value.acceptance_criteria ?? [];
+ const constraints = value.constraints ?? [];
+ const constitutionRefs = value.constitution_refs ?? [];
+ const repos = value.repos ?? [];
+ const decisions = value.decisions ?? [];
+
+ const nudges = computeNudges(value);
+ const checklist = computeChecklist(value);
+ const coverage = computeCoverage(value);
+
+ function setRequirements(next: Requirement[]) {
+ onChange({ ...value, requirements: next });
+ }
+
+ function setCriteria(next: AcceptanceCriterion[]) {
+ onChange({ ...value, acceptance_criteria: next });
+ }
+
+ function setDecisions(next: ADR[]) {
+ onChange({ ...value, decisions: next });
+ }
+
+ return (
+
+
+
+ {dirty ? "Unsaved changes" : "Guided"}
+
+
+
+
+
+
+
+
+ {/* --- Requirements ------------------------------------------------- */}
+
+
+ Requirements
+
+
+
+
+
+ {/* --- Acceptance Criteria ------------------------------------------ */}
+
+
+ Acceptance Criteria
+
+
+
+
+
+ {/* --- Constraints ---------------------------------------------------- */}
+
+
+ Constraints
+
+
+
+
+
+ {/* --- Advanced (collapsed by default) -------------------------------- */}
+
+
+ {advancedOpen ? (
+
+
+
+
onChange({ ...value, constitution_refs: next })}
+ />
+
+ onChange({ ...value, repos: next })}
+ />
+
+
+
Architecture decisions
+
+
+
+
+ ) : null}
+
+
+ {/* --- Nudges ---------------------------------------------------------- */}
+ {nudges.length > 0 ? (
+
+ {nudges.map((nudge) => (
+ -
+ {nudge.message}
+
+ ))}
+
+ ) : null}
+
+ {/* --- Ready-to-create checklist + coverage meter ---------------------- */}
+
+
+
+ Ready to create
+
+
+ {coverage.satisfied}/{coverage.total} requirements covered ({coverage.pct}%)
+
+
+
+
= 100 ? "bg-success" : "bg-primary"}`}
+ style={{ width: `${coverage.pct}%` }}
+ />
+
+
+ {checklist.map((item) => (
+ -
+
+
+ {item.label}
+
+
+ ))}
+
+
+
+ {saveError ? (
+
+ {saveError}
+
+ ) : null}
+
+ );
+}
+
+function StringListField({
+ label,
+ testId,
+ addTestId,
+ items,
+ onChange,
+}: {
+ label: string;
+ testId: string;
+ addTestId: string;
+ items: string[];
+ onChange: (next: string[]) => void;
+}) {
+ return (
+
+
{label}
+
+
+
+ );
+}
+
+/** Given/When/Then editor — three inputs composing the criterion's `text`. */
+function GherkinEditor({ id, text, onChange }: { id: string; text: string; onChange: (text: string) => void }) {
+ const gwt = parseGivenWhenThen(text);
+ const update = (patch: Partial) => onChange(composeGivenWhenThen({ ...gwt, ...patch }));
+ return (
+
+ {(["given", "when", "then"] as const).map((clause) => (
+
+ ))}
+
+ );
+}
+
+/** Checklist editor — a togglable, editable list of check items in `text`. */
+function ChecklistEditor({ id, text, onChange }: { id: string; text: string; onChange: (text: string) => void }) {
+ const items = parseChecklist(text);
+ const commit = (next: typeof items) => onChange(composeChecklist(next));
+ return (
+
+
+
+
+ );
+}
diff --git a/apps/web/src/components/spec-studio/markdown-mode.test.tsx b/apps/web/src/components/spec-studio/markdown-mode.test.tsx
new file mode 100644
index 00000000..148d3f93
--- /dev/null
+++ b/apps/web/src/components/spec-studio/markdown-mode.test.tsx
@@ -0,0 +1,136 @@
+import { fireEvent, render, screen } from "@testing-library/react";
+import { useState } from "react";
+import { describe, expect, it, vi } from "vitest";
+
+import { MarkdownMode } from "./markdown-mode";
+
+const VALID = `---
+id: SPEC-1
+status: draft
+constitution_refs: []
+repos: []
+execution_mode: single_agent
+skill_profile: null
+plan_ref: null
+tasks_ref: null
+validation_ref: null
+---
+
+## Goal
+
+Passwordless auth
+
+## Requirements
+
+- **R1**: Users can sign in without a password
+
+## Acceptance Criteria
+
+- **AC1** (R1): Given a valid magic link, when clicked, then the user is signed in
+
+## Constraints
+
+- Must work offline
+`;
+
+function Harness({ initial }: { initial: string }) {
+ const [value, setValue] = useState(initial);
+ return ;
+}
+
+describe("MarkdownMode", () => {
+ it("shows a valid parse status and defaults to the Structure panel", () => {
+ render();
+ expect(screen.getByTestId("markdown-status-valid")).toBeInTheDocument();
+ expect(screen.getByTestId("markdown-panel-structure")).toBeInTheDocument();
+ expect(screen.getByTestId("markdown-save")).toBeDisabled();
+ });
+
+ it("renders frontmatter and body verbatim in the raw textarea", () => {
+ render();
+ const textarea = screen.getByTestId("markdown-textarea");
+ expect(textarea).toHaveValue(VALID);
+ expect((textarea as HTMLTextAreaElement).value).toContain("---\nid: SPEC-1");
+ });
+
+ it("renders a line-number gutter matching the text line count", () => {
+ const { container } = render();
+ const gutterLines = container.querySelectorAll('[aria-hidden="true"] > div');
+ expect(gutterLines.length).toBeGreaterThanOrEqual(VALID.split("\n").length - 1);
+ });
+
+ it("switches between Structure, Preview and Traceability panels", () => {
+ render();
+
+ fireEvent.click(screen.getByTestId("markdown-panel-tab-preview"));
+ expect(screen.getByTestId("markdown-panel-preview")).toBeInTheDocument();
+ expect(screen.getByText("Passwordless auth")).toBeInTheDocument();
+
+ fireEvent.click(screen.getByTestId("markdown-panel-tab-traceability"));
+ expect(screen.getByTestId("markdown-panel-traceability")).toBeInTheDocument();
+ expect(screen.getByTestId("traceability-matrix")).toBeInTheDocument();
+
+ fireEvent.click(screen.getByTestId("markdown-panel-tab-structure"));
+ expect(screen.getByTestId("markdown-panel-structure")).toBeInTheDocument();
+ });
+
+ it("shows line-anchored parse issues for malformed markdown and disables save on invalid content is independent of dirty", () => {
+ render(
+ ,
+ );
+ expect(screen.getByTestId("markdown-status-invalid")).toBeInTheDocument();
+ const issues = screen.getByTestId("markdown-issues");
+ expect(issues).toBeInTheDocument();
+ expect(screen.getByText(/requirement must be/i)).toBeInTheDocument();
+ expect(screen.getByText(/missing a '## Goal' section/i)).toBeInTheDocument();
+ });
+
+ it("clicking an issue jumps the textarea cursor to that line", () => {
+ const text = "---\nid: SPEC-1\n---\n\n## Goal\n\nX\n\n## Requirements\n\n- broken\n";
+ render();
+ const textarea = screen.getByTestId("markdown-textarea") as HTMLTextAreaElement;
+ const issueButton = screen.getByText(/requirement must be/i).closest("button")!;
+ fireEvent.click(issueButton);
+ expect(document.activeElement).toBe(textarea);
+ });
+
+ it("live-updates the parse status and structure counts as the user types", () => {
+ render();
+ expect(screen.getByTestId("markdown-status-invalid")).toBeInTheDocument();
+
+ const textarea = screen.getByTestId("markdown-textarea");
+ fireEvent.change(textarea, { target: { value: VALID } });
+
+ expect(screen.getByTestId("markdown-status-valid")).toBeInTheDocument();
+ fireEvent.click(screen.getByTestId("markdown-panel-tab-structure"));
+ expect(screen.getByText("Requirements")).toBeInTheDocument();
+ });
+
+ it("Tab inserts an indent instead of moving focus out of the editor", () => {
+ render();
+ const textarea = screen.getByTestId("markdown-textarea") as HTMLTextAreaElement;
+ textarea.focus();
+ textarea.setSelectionRange(3, 3);
+ fireEvent.keyDown(textarea, { key: "Tab" });
+ expect(textarea).toHaveValue("abc ");
+ });
+
+ it("enables save once dirty and calls onSave", () => {
+ const onSave = vi.fn();
+ render();
+ const button = screen.getByTestId("markdown-save");
+ expect(button).toBeEnabled();
+ fireEvent.click(button);
+ expect(onSave).toHaveBeenCalled();
+ });
+
+ it("surfaces a save error when provided", () => {
+ render();
+ expect(screen.getByTestId("markdown-save-error")).toHaveTextContent("409 conflict");
+ });
+});
diff --git a/apps/web/src/components/spec-studio/markdown-mode.tsx b/apps/web/src/components/spec-studio/markdown-mode.tsx
new file mode 100644
index 00000000..62d86a6a
--- /dev/null
+++ b/apps/web/src/components/spec-studio/markdown-mode.tsx
@@ -0,0 +1,378 @@
+"use client";
+
+import { AlertTriangle, CheckCircle2, ListTree, Eye as EyeIcon, Route } from "lucide-react";
+import { useMemo, useRef, useState, type KeyboardEvent, type UIEvent } from "react";
+
+import { Button } from "@/components/ui/button";
+import { TraceabilityMatrix } from "@/components/spec/traceability-matrix";
+import { computeChecklist, computeCoverage, computeNudges } from "@/components/spec-studio/guided-helpers";
+import { hasMarkdownErrors, parseSpecMarkdown, type MarkdownIssue } from "@/lib/spec-studio/markdown-parse";
+import type { RequirementTrace } from "@/lib/api/types";
+import { cn } from "@/lib/utils";
+
+export interface MarkdownModeProps {
+ /** The current `spec.md` text (controlled). */
+ value: string;
+ onChange: (next: string) => void;
+ onSave: () => void;
+ saving?: boolean;
+ dirty?: boolean;
+ saveError?: string | null;
+}
+
+type PanelTab = "structure" | "preview" | "traceability";
+
+const PANEL_TABS: { id: PanelTab; label: string; icon: typeof ListTree }[] = [
+ { id: "structure", label: "Structure", icon: ListTree },
+ { id: "preview", label: "Preview", icon: EyeIcon },
+ { id: "traceability", label: "Traceability", icon: Route },
+];
+
+function jumpToLine(textareaRef: React.RefObject, line: number) {
+ const textarea = textareaRef.current;
+ if (!textarea) return;
+ const lines = textarea.value.split("\n");
+ let offset = 0;
+ for (let i = 0; i < line - 1 && i < lines.length; i += 1) {
+ offset += lines[i].length + 1;
+ }
+ textarea.focus();
+ textarea.setSelectionRange(offset, offset);
+}
+
+/** Build a lightweight, local requirement traceability from the parsed markdown alone (no task/test refs — those come from a backend validation run). */
+function localTraces(
+ manifest: ReturnType["manifest"],
+): RequirementTrace[] {
+ const requirements = manifest.requirements ?? [];
+ const criteria = manifest.acceptance_criteria ?? [];
+ return requirements.map((req) => {
+ const linked = criteria.filter((ac) => (ac.req_refs ?? []).includes(req.id)).map((ac) => ac.id);
+ return {
+ requirement_id: req.id,
+ text: req.text,
+ acceptance_criteria_ids: linked,
+ task_refs: [],
+ test_refs: [],
+ satisfied: linked.length > 0,
+ };
+ });
+}
+
+function IssueList({
+ issues,
+ onJump,
+}: {
+ issues: MarkdownIssue[];
+ onJump: (line: number) => void;
+}) {
+ if (issues.length === 0) return null;
+ return (
+
+ {issues.map((issue, index) => (
+ -
+
+
+ ))}
+
+ );
+}
+
+function StructurePanel({ manifest }: { manifest: ReturnType["manifest"] }) {
+ const checklist = computeChecklist(manifest);
+ const nudges = computeNudges(manifest);
+ const coverage = computeCoverage(manifest);
+ const counts: { label: string; count: number }[] = [
+ { label: "Requirements", count: (manifest.requirements ?? []).length },
+ { label: "Acceptance criteria", count: (manifest.acceptance_criteria ?? []).length },
+ { label: "Constraints", count: (manifest.constraints ?? []).length },
+ { label: "Open questions", count: (manifest.open_questions ?? []).length },
+ { label: "Decisions", count: (manifest.decisions ?? []).length },
+ ];
+ return (
+
+
+ {counts.map((c) => (
+
+
- {c.label}
+ - {c.count}
+
+ ))}
+
+
+
+ Requirement coverage: {coverage.satisfied}/{coverage.total} ({coverage.pct}%)
+
+
+ {checklist.map((item) => (
+ -
+
+ {item.label}
+
+ ))}
+
+
+ {nudges.length > 0 ? (
+
+ {nudges.map((nudge) => (
+ -
+
+ {nudge.message}
+
+ ))}
+
+ ) : null}
+
+ );
+}
+
+function PreviewPanel({ manifest }: { manifest: ReturnType["manifest"] }) {
+ const requirements = manifest.requirements ?? [];
+ const criteria = manifest.acceptance_criteria ?? [];
+ const constraints = manifest.constraints ?? [];
+ const openQuestions = manifest.open_questions ?? [];
+ const decisions = manifest.decisions ?? [];
+ return (
+
+
+ Goal
+ {manifest.name || "—"}
+
+ {requirements.length > 0 ? (
+
+ Requirements
+
+ {requirements.map((r) => (
+ -
+ {r.id} {r.text}
+
+ ))}
+
+
+ ) : null}
+ {criteria.length > 0 ? (
+
+ Acceptance Criteria
+
+ {criteria.map((c) => (
+ -
+ {c.id}{" "}
+ {(c.req_refs ?? []).length > 0 ? (
+
+ ({(c.req_refs ?? []).join(", ")})
+
+ ) : null}{" "}
+ {c.text}
+
+ ))}
+
+
+ ) : null}
+ {constraints.length > 0 ? (
+
+ Constraints
+
+ {constraints.map((c, i) => (
+ -
+ {c}
+
+ ))}
+
+
+ ) : null}
+ {openQuestions.length > 0 ? (
+
+ Open Questions
+
+ {openQuestions.map((q) => (
+ -
+ {q.id} {q.text}
+ {q.resolution ? (
+ Resolution: {q.resolution}
+ ) : null}
+
+ ))}
+
+
+ ) : null}
+ {decisions.length > 0 ? (
+
+ Decisions
+
+ {decisions.map((d) => (
+ -
+ {d.id} — {d.title}
+
+ ))}
+
+
+ ) : null}
+
+ );
+}
+
+/**
+ * The `spec.md` prose editor — Spec Studio's default human/agent surface.
+ * A JetBrains Mono, keyboard-first raw-text editor (frontmatter always
+ * visible, `Tab` inserts an indent instead of leaving the field, a
+ * line-number gutter) paired with a live parsed pane — **Structure**
+ * (section counts, the Ready checklist and coverage meter),
+ * **Preview** (a readable render of the parsed sections) and
+ * **Traceability** (local requirement -> acceptance-criteria coverage) — plus
+ * a line-anchored parse-issue list, all recomputed on every keystroke via
+ * `parseSpecMarkdown` (a client-side mirror of `forge_spec.markdown.parse_spec_md`).
+ * Saving re-renders `manifest.yaml` to match on the backend, which remains
+ * the authoritative parser.
+ */
+export function MarkdownMode({
+ value,
+ onChange,
+ onSave,
+ saving = false,
+ dirty = false,
+ saveError,
+}: MarkdownModeProps) {
+ const [panel, setPanel] = useState("structure");
+ const textareaRef = useRef(null);
+ const gutterRef = useRef(null);
+ const [scrollTop, setScrollTop] = useState(0);
+
+ const { manifest, issues } = useMemo(() => parseSpecMarkdown(value), [value]);
+ const invalid = hasMarkdownErrors(issues);
+ const lineCount = useMemo(() => Math.max(1, value.split("\n").length), [value]);
+ const traces = useMemo(() => localTraces(manifest), [manifest]);
+
+ const onScroll = (event: UIEvent) => {
+ setScrollTop(event.currentTarget.scrollTop);
+ };
+
+ /** Keyboard-first: `Tab` indents (two spaces) instead of leaving the editor. */
+ const onKeyDown = (event: KeyboardEvent) => {
+ if (event.key !== "Tab") return;
+ event.preventDefault();
+ const textarea = event.currentTarget;
+ const { selectionStart, selectionEnd } = textarea;
+ const next = `${value.slice(0, selectionStart)} ${value.slice(selectionEnd)}`;
+ onChange(next);
+ requestAnimationFrame(() => {
+ textarea.setSelectionRange(selectionStart + 2, selectionStart + 2);
+ });
+ };
+
+ return (
+
+
+
+ {invalid ? (
+
+
+ {issues.filter((i) => i.severity === "error").length} issue
+ {issues.filter((i) => i.severity === "error").length === 1 ? "" : "s"}
+
+ ) : (
+
+
+ Parses cleanly
+
+ )}
+ {dirty ?
Unsaved changes : null}
+
+
+
+
+
+
+
+ {Array.from({ length: lineCount }, (_, i) => (
+
{i + 1}
+ ))}
+
+
+
+
+
+ {PANEL_TABS.map((tab) => {
+ const Icon = tab.icon;
+ const selected = tab.id === panel;
+ return (
+
+ );
+ })}
+
+
+ {panel === "structure" ?
: null}
+ {panel === "preview" ?
: null}
+ {panel === "traceability" ? (
+
+
+
+ ) : null}
+
+
+
+
+ {saveError ? (
+
+ {saveError}
+
+ ) : null}
+
+
jumpToLine(textareaRef, line)} />
+
+ );
+}
diff --git a/apps/web/src/components/spec-studio/new-spec-page.test.tsx b/apps/web/src/components/spec-studio/new-spec-page.test.tsx
new file mode 100644
index 00000000..fc7b1a08
--- /dev/null
+++ b/apps/web/src/components/spec-studio/new-spec-page.test.tsx
@@ -0,0 +1,254 @@
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { fireEvent, render, screen, waitFor } from "@testing-library/react";
+import type { ReactNode } from "react";
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+import type { ForgeApiClient } from "@/lib/api/client";
+import type { EpicDTO, SpecDraft, SpecManifest } from "@/lib/api/types";
+
+import { NewSpecPage } from "./new-spec-page";
+
+const mockSearchParams = new URLSearchParams();
+vi.mock("next/navigation", () => ({
+ useRouter: () => ({ push: vi.fn(), replace: vi.fn(), refresh: vi.fn() }),
+ useSearchParams: () => mockSearchParams,
+}));
+
+const epics: EpicDTO[] = [
+ { id: "e1", title: "Auth overhaul" },
+ { id: "e2", title: "Billing v2" },
+];
+
+const aiDraft: SpecDraft = {
+ goal: "Passwordless auth",
+ model: "claude-opus-4-8",
+ spec_md: "---\nid: SPEC-DRAFT\nstatus: draft\n---\n\n## Goal\n\nPasswordless auth\n",
+ manifest: {
+ id: "SPEC-DRAFT",
+ name: "Passwordless auth",
+ requirements: [{ id: "R1", text: "Sign in without a password" }],
+ },
+ usage: { cost_usd: 0.01 },
+};
+
+function makeClient(overrides: Partial = {}): ForgeApiClient {
+ return {
+ listEpics: vi.fn(() => Promise.resolve(epics)),
+ createEpic: vi.fn((epic: EpicDTO) =>
+ Promise.resolve({ ...epic, id: "e-new" } as EpicDTO),
+ ),
+ createSpec: vi.fn((body: { epic_id: string; name: string }) =>
+ Promise.resolve({ id: "s-new", name: body.name, status: "draft" } as SpecManifest),
+ ),
+ putSpecManifest: vi.fn((specId: string, manifest: SpecManifest) =>
+ Promise.resolve({ ...manifest, id: specId } as SpecManifest),
+ ),
+ draftSpec: vi.fn(() => Promise.resolve(aiDraft)),
+ ...overrides,
+ } as unknown as ForgeApiClient;
+}
+
+afterEach(() => {
+ for (const key of [...mockSearchParams.keys()]) {
+ mockSearchParams.delete(key);
+ }
+});
+
+function renderPage(client: ForgeApiClient, onCreated = vi.fn()) {
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
+ });
+ function Wrapper({ children }: { children: ReactNode }) {
+ return {children};
+ }
+ return {
+ onCreated,
+ ...render(, { wrapper: Wrapper }),
+ };
+}
+
+describe("NewSpecPage", () => {
+ it("lists epics to pick from and disables create until an epic + goal are set", async () => {
+ const client = makeClient();
+ renderPage(client);
+
+ await screen.findByText("Auth overhaul");
+ expect(screen.getByTestId("create-spec")).toBeDisabled();
+
+ fireEvent.change(screen.getByTestId("new-spec-epic"), { target: { value: "e1" } });
+ expect(screen.getByTestId("create-spec")).toBeDisabled();
+
+ fireEvent.change(screen.getByTestId("guided-name"), { target: { value: "Passwordless auth" } });
+ expect(screen.getByTestId("create-spec")).toBeEnabled();
+ });
+
+ it("creates the spec and hands off the new id", async () => {
+ const client = makeClient();
+ const { onCreated } = renderPage(client);
+ await screen.findByText("Auth overhaul");
+
+ fireEvent.change(screen.getByTestId("new-spec-epic"), { target: { value: "e1" } });
+ fireEvent.change(screen.getByTestId("guided-name"), { target: { value: "Passwordless auth" } });
+ fireEvent.click(screen.getByTestId("create-spec"));
+
+ await waitFor(() =>
+ expect(client.createSpec).toHaveBeenCalledWith(
+ expect.objectContaining({ epic_id: "e1", name: "Passwordless auth" }),
+ ),
+ );
+ await waitFor(() => expect(onCreated).toHaveBeenCalledWith("s-new"));
+ // No acceptance criteria / advanced fields were drafted, so the create
+ // call alone is sufficient — no follow-up PUT is needed.
+ expect(client.putSpecManifest).not.toHaveBeenCalled();
+ });
+
+ it("uses the shared Guided-mode form for requirements and acceptance criteria", async () => {
+ const client = makeClient();
+ renderPage(client);
+ await screen.findByText("Auth overhaul");
+ expect(screen.getByTestId("guided-requirements")).toBeInTheDocument();
+ expect(screen.getByTestId("guided-acceptance-criteria")).toBeInTheDocument();
+ });
+
+ it("persists acceptance criteria drafted before creation via a follow-up PUT", async () => {
+ const client = makeClient();
+ const { onCreated } = renderPage(client);
+ await screen.findByText("Auth overhaul");
+
+ fireEvent.change(screen.getByTestId("new-spec-epic"), { target: { value: "e1" } });
+ fireEvent.change(screen.getByTestId("guided-name"), { target: { value: "Passwordless auth" } });
+
+ // Draft an acceptance criterion in the Guided form before the spec exists.
+ fireEvent.click(screen.getByTestId("guided-add-acceptance-criterion"));
+ fireEvent.change(screen.getByTestId("ac-item-0").querySelector('[aria-label$="given"]')!, {
+ target: { value: "a user" },
+ });
+
+ fireEvent.click(screen.getByTestId("create-spec"));
+
+ await waitFor(() => expect(client.createSpec).toHaveBeenCalled());
+ await waitFor(() =>
+ expect(client.putSpecManifest).toHaveBeenCalledWith(
+ "s-new",
+ expect.objectContaining({
+ acceptance_criteria: expect.arrayContaining([
+ expect.objectContaining({ text: expect.stringContaining("a user") }),
+ ]),
+ }),
+ ),
+ );
+ await waitFor(() => expect(onCreated).toHaveBeenCalledWith("s-new"));
+ });
+
+ describe("Draft with AI entry", () => {
+ it("hides the AI panel until 'Draft with AI' is selected", async () => {
+ const client = makeClient();
+ renderPage(client);
+ await screen.findByText("Auth overhaul");
+ expect(screen.queryByTestId("ai-draft-panel")).not.toBeInTheDocument();
+
+ fireEvent.click(screen.getByTestId("new-spec-entry-ai"));
+ expect(screen.getByTestId("ai-draft-panel")).toBeInTheDocument();
+ });
+
+ it("streams a drafted spec into the Guided form", async () => {
+ const client = makeClient();
+ renderPage(client);
+ await screen.findByText("Auth overhaul");
+
+ fireEvent.change(screen.getByTestId("new-spec-epic"), { target: { value: "e1" } });
+ fireEvent.click(screen.getByTestId("new-spec-entry-ai"));
+ fireEvent.change(screen.getByTestId("ai-draft-goal"), {
+ target: { value: "Passwordless auth" },
+ });
+ fireEvent.click(screen.getByTestId("ai-draft-submit"));
+
+ await waitFor(() =>
+ expect(client.draftSpec).toHaveBeenCalledWith(
+ expect.objectContaining({ goal: "Passwordless auth" }),
+ ),
+ );
+
+ // The parsed manifest preview seeds the Guided form once the AI panel's
+ // live reveal has fully streamed the drafted text in.
+ await waitFor(() => expect(screen.getByTestId("guided-name")).toHaveValue("Passwordless auth"));
+ expect(screen.getByTestId("create-spec")).toBeEnabled();
+ });
+ });
+
+ describe("starter templates", () => {
+ it("seeds a requirement and acceptance criterion when a template is picked", async () => {
+ const client = makeClient();
+ renderPage(client);
+ await screen.findByText("Auth overhaul");
+
+ fireEvent.click(screen.getByTestId("spec-template-bugfix"));
+
+ expect(screen.getByTestId("spec-template-bugfix")).toHaveAttribute("aria-pressed", "true");
+ expect(
+ screen.getByDisplayValue(/Describe the incorrect behavior/),
+ ).toBeInTheDocument();
+ });
+
+ it("does not clobber requirements already drafted before picking a template", async () => {
+ const client = makeClient();
+ renderPage(client);
+ await screen.findByText("Auth overhaul");
+
+ fireEvent.click(screen.getByTestId("guided-add-requirement"));
+ const reqInput = screen.getByLabelText(/text$/i);
+ fireEvent.change(reqInput, { target: { value: "My own requirement" } });
+
+ fireEvent.click(screen.getByTestId("spec-template-feature"));
+
+ expect(screen.getByDisplayValue("My own requirement")).toBeInTheDocument();
+ expect(
+ screen.queryByDisplayValue(/Describe the new capability/),
+ ).not.toBeInTheDocument();
+ });
+ });
+
+ describe("epic entry", () => {
+ it("preselects the epic from an ?epicId= query param (board epic 'Create spec' entry)", async () => {
+ mockSearchParams.set("epicId", "e2");
+ const client = makeClient();
+ renderPage(client);
+ await screen.findByText("Auth overhaul");
+
+ expect(screen.getByTestId("new-spec-epic")).toHaveValue("e2");
+ });
+
+ it("creates a new epic then the spec when 'Create new epic' is chosen (standalone /specs/new entry)", async () => {
+ const client = makeClient();
+ const { onCreated } = renderPage(client);
+ await screen.findByText("Auth overhaul");
+
+ fireEvent.change(screen.getByTestId("new-spec-epic"), {
+ target: { value: "__new_epic__" },
+ });
+ expect(screen.getByTestId("create-spec")).toBeDisabled();
+
+ fireEvent.change(screen.getByTestId("new-spec-new-epic-title"), {
+ target: { value: "Fresh epic" },
+ });
+ fireEvent.change(screen.getByTestId("guided-name"), {
+ target: { value: "Passwordless auth" },
+ });
+ expect(screen.getByTestId("create-spec")).toBeEnabled();
+
+ fireEvent.click(screen.getByTestId("create-spec"));
+
+ await waitFor(() =>
+ expect(client.createEpic).toHaveBeenCalledWith(
+ expect.objectContaining({ title: "Fresh epic" }),
+ ),
+ );
+ await waitFor(() =>
+ expect(client.createSpec).toHaveBeenCalledWith(
+ expect.objectContaining({ epic_id: "e-new", name: "Passwordless auth" }),
+ ),
+ );
+ await waitFor(() => expect(onCreated).toHaveBeenCalledWith("s-new"));
+ });
+ });
+});
diff --git a/apps/web/src/components/spec-studio/new-spec-page.tsx b/apps/web/src/components/spec-studio/new-spec-page.tsx
new file mode 100644
index 00000000..f710fede
--- /dev/null
+++ b/apps/web/src/components/spec-studio/new-spec-page.tsx
@@ -0,0 +1,269 @@
+"use client";
+
+import { useRouter, useSearchParams } from "next/navigation";
+import { useMemo, useState } from "react";
+
+import { Button } from "@/components/ui/button";
+import { ApiError, apiClient, type ForgeApiClient } from "@/lib/api/client";
+import { useCreateEpic, useEpics } from "@/lib/api/hooks";
+import { useCreateSpec } from "@/lib/api/spec";
+import type { SpecDraft, SpecManifest } from "@/lib/api/types";
+import { applySpecTemplate, SPEC_TEMPLATES, type SpecTemplateId } from "@/lib/spec-studio/templates";
+import { cn } from "@/lib/utils";
+
+import { AiDraftPanel } from "./ai-draft-panel";
+import { GuidedMode } from "./guided-mode";
+
+type EntryMode = "scratch" | "ai";
+
+/** Sentinel `
- {selected ? (
+
+
+ {selected ? (
) : null}
- ) : null}
+ ) : null}
+
@@ -222,7 +235,7 @@ export function SpecDashboard({
{selected.name}
-
+
@@ -241,6 +254,7 @@ export function SpecDashboard({
{tab === "constitution" ? (
) : null}
+ {tab === "studio" ? : null}
@@ -503,6 +517,12 @@ function EmptyList() {
Create a spec from an epic to start the SDD lifecycle — draft,
clarify, approve, then validate.
+
);
}
diff --git a/apps/web/src/components/spec/spec-meta.test.ts b/apps/web/src/components/spec/spec-meta.test.ts
index 79920404..4d5a6c0b 100644
--- a/apps/web/src/components/spec/spec-meta.test.ts
+++ b/apps/web/src/components/spec/spec-meta.test.ts
@@ -7,26 +7,13 @@ import {
formatCoverage,
gateSummary,
isApprovable,
- stageIndex,
- stageState,
+ PLAIN_LIFECYCLE_STEPS,
+ plainCurrentStep,
+ plainStepCompletion,
+ plainStepState,
traceSealed,
} from "./spec-meta";
-describe("stageIndex / stageState", () => {
- it("orders the lifecycle and defaults unknown status to draft", () => {
- expect(stageIndex("draft")).toBe(0);
- expect(stageIndex("validated")).toBe(4);
- expect(stageIndex(undefined)).toBe(0);
- });
-
- it("classifies nodes relative to the current stage", () => {
- // current = approved (index 2)
- expect(stageState(0, "approved")).toBe("done");
- expect(stageState(2, "approved")).toBe("current");
- expect(stageState(4, "approved")).toBe("upcoming");
- });
-});
-
describe("coverage helpers", () => {
it("normalises a 0–1 fraction to a percent", () => {
expect(coveragePercent(0.87)).toBe(87);
@@ -53,6 +40,91 @@ describe("isApprovable", () => {
});
});
+describe("plain-language lifecycle stepper", () => {
+ it("has five steps whose actions match the /spec engine calls", () => {
+ expect(PLAIN_LIFECYCLE_STEPS.map((s) => s.label)).toEqual([
+ "Describe",
+ "Refine",
+ "Approve",
+ "Build",
+ "Verify",
+ ]);
+ expect(PLAIN_LIFECYCLE_STEPS.map((s) => s.actionLabel)).toEqual([
+ "Clarify",
+ "Plan",
+ "Approve",
+ "Generate tasks",
+ "Validate",
+ ]);
+ });
+
+ it("marks nothing done for a fresh draft, current = Describe", () => {
+ const completion = plainStepCompletion({ status: "draft" });
+ expect(completion).toEqual([false, false, false, false, false]);
+ expect(plainCurrentStep(completion)).toBe(0);
+ });
+
+ it("marks Describe done once clarified, current = Refine", () => {
+ const completion = plainStepCompletion({ status: "clarifying" });
+ expect(completion).toEqual([true, false, false, false, false]);
+ expect(plainCurrentStep(completion)).toBe(1);
+ });
+
+ it("marks Refine done once a plan exists, independent of status", () => {
+ const completion = plainStepCompletion({ status: "clarifying", plan_ref: "plan.md" });
+ expect(completion).toEqual([true, true, false, false, false]);
+ expect(plainCurrentStep(completion)).toBe(2);
+ });
+
+ it("marks Approve done once the spec is approved (or beyond)", () => {
+ const completion = plainStepCompletion({
+ status: "approved",
+ plan_ref: "plan.md",
+ });
+ expect(completion).toEqual([true, true, true, false, false]);
+ expect(plainCurrentStep(completion)).toBe(3);
+ });
+
+ it("marks Build done once tasks are generated", () => {
+ const completion = plainStepCompletion({
+ status: "approved",
+ plan_ref: "plan.md",
+ tasks_ref: "tasks.md",
+ });
+ expect(completion).toEqual([true, true, true, true, false]);
+ expect(plainCurrentStep(completion)).toBe(4);
+ });
+
+ it("marks Verify done once validated status or a passing report lands", () => {
+ const byStatus = plainStepCompletion({
+ status: "validated",
+ plan_ref: "plan.md",
+ tasks_ref: "tasks.md",
+ });
+ expect(byStatus).toEqual([true, true, true, true, true]);
+ expect(plainCurrentStep(byStatus)).toBe(4);
+
+ const byReport = plainStepCompletion({
+ status: "approved",
+ plan_ref: "plan.md",
+ tasks_ref: "tasks.md",
+ validation: { passed: true },
+ });
+ expect(byReport[4]).toBe(true);
+ });
+
+ it("falls back to draft-like state for an unknown status", () => {
+ expect(plainStepCompletion({})).toEqual([false, false, false, false, false]);
+ });
+
+ it("classifies nodes as done/current/upcoming relative to the current step", () => {
+ const completion = [true, false, false, false, false];
+ expect(plainStepState(0, completion, 1)).toBe("done");
+ expect(plainStepState(1, completion, 1)).toBe("current");
+ expect(plainStepState(4, completion, 1)).toBe("upcoming");
+ });
+});
+
describe("traceSealed", () => {
it("requires both satisfaction and at least one test", () => {
expect(traceSealed({ requirement_id: "R1", satisfied: true, test_refs: ["t1"] })).toBe(true);
diff --git a/apps/web/src/components/spec/spec-meta.ts b/apps/web/src/components/spec/spec-meta.ts
index a89942f0..1b8450fb 100644
--- a/apps/web/src/components/spec/spec-meta.ts
+++ b/apps/web/src/components/spec/spec-meta.ts
@@ -11,45 +11,11 @@ import type {
RequirementTrace,
SpecOverview,
SpecStatus,
+ ValidationReport,
} from "@/lib/api/types";
-export interface StageMeta {
- status: SpecStatus;
- label: string;
- /** One-line description of what reaching this stage means. */
- blurb: string;
-}
-
-/** The SDD lifecycle in order — the spine of the forge heat rail. */
-export const LIFECYCLE_STAGES: readonly StageMeta[] = [
- { status: "draft", label: "Draft", blurb: "Requirements captured" },
- { status: "clarifying", label: "Clarifying", blurb: "Questions resolved" },
- { status: "approved", label: "Approved", blurb: "Human gate passed" },
- { status: "implementing", label: "Implementing", blurb: "Tasks in flight" },
- { status: "validated", label: "Validated", blurb: "Traceability sealed" },
- { status: "closed", label: "Closed", blurb: "Shipped & archived" },
-];
-
-/** Zero-based position of a status in the lifecycle (defaults to draft). */
-export function stageIndex(status: SpecStatus | undefined): number {
- if (!status) return 0;
- const index = SPEC_STATUSES.indexOf(status);
- return index < 0 ? 0 : index;
-}
-
export type StageState = "done" | "current" | "upcoming";
-/** Where a lifecycle node sits relative to the spec's current stage. */
-export function stageState(
- nodeIndex: number,
- status: SpecStatus | undefined,
-): StageState {
- const current = stageIndex(status);
- if (nodeIndex < current) return "done";
- if (nodeIndex === current) return "current";
- return "upcoming";
-}
-
export const STATUS_LABELS: Record = {
draft: "Draft",
clarifying: "Clarifying",
@@ -137,3 +103,71 @@ export function gateSummary(spec: SpecOverview): GateSummary {
export function traceSealed(trace: RequirementTrace): boolean {
return Boolean(trace.satisfied) && (trace.test_refs?.length ?? 0) > 0;
}
+
+// --------------------------------------------------------------------------- //
+// Plain-language lifecycle stepper (ss-lifecycle) //
+// //
+// The SDD lifecycle wired inline as five everyday verbs, each backed by one //
+// `/spec` engine action: Describe<-Clarify, Refine<-Plan, Approve<-Approve, //
+// Build<-Generate tasks, Verify<-Validate. `SpecStatus` alone can't place a //
+// spec on this rail (the engine never sets an "implementing"/"planned" //
+// status — `plan`/`tasks` just populate `plan_ref`/`tasks_ref`), so //
+// completion is read straight off the manifest fields each action produces. //
+// --------------------------------------------------------------------------- //
+
+export interface PlainStepMeta {
+ id: string;
+ label: string;
+ blurb: string;
+ /** The `/spec` engine action this step's inline button runs. */
+ actionLabel: string;
+}
+
+export const PLAIN_LIFECYCLE_STEPS: readonly PlainStepMeta[] = [
+ { id: "describe", label: "Describe", blurb: "Requirements captured", actionLabel: "Clarify" },
+ { id: "refine", label: "Refine", blurb: "Questions & plan resolved", actionLabel: "Plan" },
+ { id: "approve", label: "Approve", blurb: "Human gate passed", actionLabel: "Approve" },
+ { id: "build", label: "Build", blurb: "Tasks generated", actionLabel: "Generate tasks" },
+ { id: "verify", label: "Verify", blurb: "Traceability sealed", actionLabel: "Validate" },
+];
+
+/** The manifest fields the stepper needs to place a spec on the rail. */
+export interface PlainStepInput {
+ status?: SpecStatus;
+ plan_ref?: string | null;
+ tasks_ref?: string | null;
+ validation?: ValidationReport | null;
+}
+
+function statusAtLeast(status: SpecStatus | undefined, floor: SpecStatus): boolean {
+ if (!status) return false;
+ return SPEC_STATUSES.indexOf(status) >= SPEC_STATUSES.indexOf(floor);
+}
+
+/** Whether each of the five plain steps' underlying action has run. */
+export function plainStepCompletion(spec: PlainStepInput): boolean[] {
+ const describeDone = statusAtLeast(spec.status, "clarifying");
+ const refineDone = Boolean(spec.plan_ref);
+ const approveDone = statusAtLeast(spec.status, "approved");
+ const buildDone = Boolean(spec.tasks_ref);
+ const verifyDone =
+ spec.status === "validated" || spec.status === "closed" || spec.validation?.passed === true;
+ return [describeDone, refineDone, approveDone, buildDone, verifyDone];
+}
+
+/** The first not-yet-complete step, or the last step once everything is done. */
+export function plainCurrentStep(completion: boolean[]): number {
+ const index = completion.findIndex((done) => !done);
+ return index === -1 ? completion.length - 1 : index;
+}
+
+/** Where a plain-language node sits relative to the stepper's current step. */
+export function plainStepState(
+ index: number,
+ completion: boolean[],
+ current: number,
+): StageState {
+ if (completion[index]) return "done";
+ if (index === current) return "current";
+ return "upcoming";
+}
diff --git a/apps/web/src/lib/api/client-spec.test.tsx b/apps/web/src/lib/api/client-spec.test.tsx
new file mode 100644
index 00000000..ef989673
--- /dev/null
+++ b/apps/web/src/lib/api/client-spec.test.tsx
@@ -0,0 +1,179 @@
+import { describe, expect, it, vi } from "vitest";
+
+import { ForgeApiClient } from "./client";
+
+function json(data: unknown, status = 200): Response {
+ return new Response(JSON.stringify(data), {
+ status,
+ headers: { "content-type": "application/json" },
+ });
+}
+
+function text(body: string, status = 200): Response {
+ return new Response(body, {
+ status,
+ headers: { "content-type": "text/plain; charset=utf-8" },
+ });
+}
+
+/**
+ * Covers the ss-endpoints spec-engine client surface: creating a spec, then
+ * editing it via both first-class formats (spec.md and manifest.yaml), plus
+ * the lifecycle actions and constitution read.
+ */
+describe("ForgeApiClient spec-engine surface", () => {
+ it("createSpec posts to /spec/specs", async () => {
+ const fetchImpl = vi.fn((_input: RequestInfo | URL, _init?: RequestInit) =>
+ Promise.resolve(json({ id: "SPEC-1", name: "Customer search", status: "draft" })),
+ );
+ const client = new ForgeApiClient({ fetch: fetchImpl as unknown as typeof fetch });
+
+ const manifest = await client.createSpec({
+ epic_id: "epic-1",
+ name: "Customer search",
+ requirements: [{ id: "R1", text: "Search customers by name" }],
+ });
+
+ expect(manifest.name).toBe("Customer search");
+ const [url, init] = fetchImpl.mock.calls[0];
+ expect(String(url)).toContain("/spec/specs");
+ expect(init?.method).toBe("POST");
+ expect(JSON.parse(init?.body as string)).toMatchObject({ name: "Customer search" });
+ });
+
+ it("reads and writes a spec via its spec.md prose serialization", async () => {
+ const fetchImpl = vi.fn((input: RequestInfo | URL, init?: RequestInit) => {
+ const url = String(input);
+ if (url.includes("/markdown") && init?.method === "GET") {
+ return Promise.resolve(text("---\nid: SPEC-1\n---\n\n## Goal\n\nCustomer search\n"));
+ }
+ if (url.includes("/markdown") && init?.method === "PUT") {
+ return Promise.resolve(json({ id: "SPEC-1", name: "Customer search", status: "draft" }));
+ }
+ throw new Error(`unexpected request: ${url}`);
+ });
+ const client = new ForgeApiClient({ fetch: fetchImpl as unknown as typeof fetch });
+
+ const md = await client.getSpecMarkdown("spec-uuid-1");
+ expect(md).toContain("Customer search");
+
+ const updated = await client.putSpecMarkdown("spec-uuid-1", md);
+ expect(updated.id).toBe("SPEC-1");
+ const [, putInit] = fetchImpl.mock.calls[1];
+ expect(JSON.parse(putInit?.body as string)).toEqual({ content: md });
+ });
+
+ it("creates and edits a spec via its manifest.yaml serialization", async () => {
+ const yamlText = "id: SPEC-99\nname: Billing v2\nstatus: draft\n";
+ const fetchImpl = vi.fn((input: RequestInfo | URL, init?: RequestInit) => {
+ const url = String(input);
+ if (url.includes("/manifest") && init?.method === "PUT") {
+ return Promise.resolve(json({ id: "SPEC-99", name: "Billing v2", status: "draft" }));
+ }
+ if (url.includes("/manifest")) {
+ return Promise.resolve(text(yamlText));
+ }
+ throw new Error(`unexpected request: ${url}`);
+ });
+ const client = new ForgeApiClient({ fetch: fetchImpl as unknown as typeof fetch });
+
+ const created = await client.putSpecManifestYaml("spec-uuid-99", yamlText);
+ expect(created.name).toBe("Billing v2");
+
+ const yaml = await client.getSpecManifestYaml("spec-uuid-99");
+ expect(yaml).toContain("Billing v2");
+ });
+
+ it("putSpecManifest persists the full manifest (Guided mode save path)", async () => {
+ const fetchImpl = vi.fn((_input: RequestInfo | URL, init?: RequestInit) => {
+ expect(init?.method).toBe("PUT");
+ return Promise.resolve(json({ id: "SPEC-1", name: "Renamed", status: "draft" }));
+ });
+ const client = new ForgeApiClient({ fetch: fetchImpl as unknown as typeof fetch });
+
+ const updated = await client.putSpecManifest("spec-uuid-1", {
+ id: "SPEC-1",
+ name: "Renamed",
+ status: "draft",
+ });
+
+ expect(updated.name).toBe("Renamed");
+ const [url, init] = fetchImpl.mock.calls[0];
+ expect(String(url)).toContain("/spec/specs/spec-uuid-1");
+ expect(JSON.parse(init?.body as string)).toMatchObject({ name: "Renamed" });
+ });
+
+ it("drives clarify -> plan -> approve -> generateTasks -> validateTask", async () => {
+ const fetchImpl = vi.fn((input: RequestInfo | URL) => {
+ const url = String(input);
+ if (url.includes("/clarify")) {
+ return Promise.resolve(json({ id: "SPEC-1", name: "x", status: "clarifying" }));
+ }
+ if (url.includes("/plan")) {
+ return Promise.resolve(json({ id: "SPEC-1", name: "x", status: "clarifying" }));
+ }
+ if (url.includes("/approve")) {
+ return Promise.resolve(json({ id: "SPEC-1", name: "x", status: "approved" }));
+ }
+ if (url.includes("/validate")) {
+ return Promise.resolve(json({ task_id: "t1", passed: true }));
+ }
+ if (url.includes("/tasks")) {
+ return Promise.resolve(json([{ id: "t1", title: "Implement", status: "todo" }]));
+ }
+ throw new Error(`unexpected request: ${url}`);
+ });
+ const client = new ForgeApiClient({ fetch: fetchImpl as unknown as typeof fetch });
+
+ expect((await client.clarifySpec("spec-1")).status).toBe("clarifying");
+ expect((await client.planSpec("spec-1")).status).toBe("clarifying");
+ expect((await client.approveSpec("spec-1")).status).toBe("approved");
+ const tasks = await client.generateTasks("spec-1");
+ expect(tasks).toHaveLength(1);
+ const report = await client.validateTask("t1");
+ expect(report.passed).toBe(true);
+ });
+
+ it("getConstitution reads /spec/constitution/{project_id}", async () => {
+ const fetchImpl = vi.fn((_input: RequestInfo | URL, _init?: RequestInit) =>
+ Promise.resolve(json({ project_id: "proj-1", principles: ["Ship small"] })),
+ );
+ const client = new ForgeApiClient({ fetch: fetchImpl as unknown as typeof fetch });
+
+ const constitution = await client.getConstitution("proj-1");
+
+ expect(constitution.project_id).toBe("proj-1");
+ const [url] = fetchImpl.mock.calls[0];
+ expect(String(url)).toContain("/spec/constitution/proj-1");
+ });
+
+ it("importSpec posts content (+ optional source_format) to /spec/import", async () => {
+ const fetchImpl = vi.fn((_input: RequestInfo | URL, _init?: RequestInit) =>
+ Promise.resolve(
+ json({
+ source_format: "markdown",
+ spec_md: "---\nid: SPEC-IMPORT\n---\n\n## Goal\n\nImported feature\n",
+ manifest: { id: "SPEC-IMPORT", name: "Imported feature" },
+ normalized: true,
+ }),
+ ),
+ );
+ const client = new ForgeApiClient({ fetch: fetchImpl as unknown as typeof fetch });
+
+ const result = await client.importSpec({
+ content: "# Imported feature\n",
+ source_format: "markdown",
+ });
+
+ expect(result.source_format).toBe("markdown");
+ expect(result.normalized).toBe(true);
+ expect(result.manifest?.name).toBe("Imported feature");
+ const [url, init] = fetchImpl.mock.calls[0];
+ expect(String(url)).toContain("/spec/import");
+ expect(init?.method).toBe("POST");
+ expect(JSON.parse(String(init?.body))).toEqual({
+ content: "# Imported feature\n",
+ source_format: "markdown",
+ });
+ });
+});
diff --git a/apps/web/src/lib/api/client.ts b/apps/web/src/lib/api/client.ts
index d37ac173..2ad8d8e6 100644
--- a/apps/web/src/lib/api/client.ts
+++ b/apps/web/src/lib/api/client.ts
@@ -27,6 +27,7 @@ import type {
ChainVerifyResult,
BurndownSeries,
CompleteSprintRequest,
+ Constitution,
DeploymentDecisionRequest,
DeploymentDetail,
DeploymentListQuery,
@@ -68,6 +69,7 @@ import type {
ProjectTeamAccessInput,
ProjectVisibilityInput,
RemediationPlanView,
+ Requirement,
RetrievedChunk,
RoleConfigListResponse,
RoleConfigOut,
@@ -85,12 +87,18 @@ import type {
TeamMemberInput,
TeamRole,
SpecDashboard,
+ SpecDraft,
+ SpecImport,
SpecManifest,
+ SpecVersionDetail,
+ SpecVersionDiff,
+ SpecVersionSummary,
Sprint,
SprintDTO,
SprintReport,
TaskDTO,
TaskStatus,
+ ValidationReport,
VelocityDashboard,
HrdDiscoverRequest,
HrdDiscoverResponse,
@@ -260,6 +268,11 @@ export class ForgeApiClient {
return this.request("/board/epics", { query });
}
+ /** Create an epic (e.g. the standalone `/specs/new` entry, which creates its own epic). */
+ createEpic(epic: EpicDTO): Promise {
+ return this.request("/board/epics", { method: "POST", body: epic });
+ }
+
listSprints(query?: RequestOptions["query"]): Promise {
return this.request("/board/sprints", { query });
}
@@ -399,6 +412,112 @@ export class ForgeApiClient {
);
}
+ /**
+ * Persist a full spec manifest (Spec Studio's Guided mode save path).
+ * Re-renders both `spec.md` and `manifest.yaml` to match, same as the
+ * markdown/YAML save endpoints.
+ */
+ putSpecManifest(specId: string, manifest: SpecManifest): Promise {
+ return this.request(
+ `/spec/specs/${encodeURIComponent(specId)}`,
+ { method: "PUT", body: manifest },
+ );
+ }
+
+ /** Create a draft spec for an epic (SDD lifecycle entry point). */
+ createSpec(body: {
+ epic_id: string;
+ name: string;
+ requirements?: Requirement[];
+ }): Promise {
+ return this.request("/spec/specs", { method: "POST", body });
+ }
+
+ /**
+ * Read a spec's ``spec.md`` prose serialization — one of the two
+ * first-class editable formats (kept in sync with `manifest.yaml`).
+ */
+ getSpecMarkdown(specId: string): Promise {
+ return this.request(
+ `/spec/specs/${encodeURIComponent(specId)}/markdown`,
+ );
+ }
+
+ /** Save a spec edited as ``spec.md`` prose; re-renders `manifest.yaml` to match. */
+ putSpecMarkdown(specId: string, content: string): Promise {
+ return this.request(
+ `/spec/specs/${encodeURIComponent(specId)}/markdown`,
+ { method: "PUT", body: { content } },
+ );
+ }
+
+ /**
+ * Read a spec's ``manifest.yaml`` serialization — the precise machine/CI/agent
+ * format (kept in sync with `spec.md`).
+ */
+ getSpecManifestYaml(specId: string): Promise {
+ return this.request(
+ `/spec/specs/${encodeURIComponent(specId)}/manifest`,
+ );
+ }
+
+ /**
+ * Save a spec edited (or created) as ``manifest.yaml``; re-renders `spec.md`
+ * to match. Both formats are first-class: a spec can be created and edited
+ * from either.
+ */
+ putSpecManifestYaml(specId: string, content: string): Promise {
+ return this.request(
+ `/spec/specs/${encodeURIComponent(specId)}/manifest`,
+ { method: "PUT", body: { content } },
+ );
+ }
+
+ /**
+ * List a spec's version history, newest first. A version is recorded on
+ * every save (Guided / Markdown / YAML), so this reflects every edit ever
+ * made to the spec, not just lifecycle transitions.
+ */
+ listSpecVersions(specId: string): Promise {
+ return this.request(
+ `/spec/specs/${encodeURIComponent(specId)}/versions`,
+ );
+ }
+
+ /** Read one version's full snapshot (manifest + both serializations). */
+ getSpecVersion(specId: string, versionNumber: number): Promise {
+ return this.request(
+ `/spec/specs/${encodeURIComponent(specId)}/versions/${versionNumber}`,
+ );
+ }
+
+ /** Diff two versions of a spec: line-level markdown + structured manifest. */
+ diffSpecVersions(
+ specId: string,
+ fromVersion: number,
+ toVersion: number,
+ ): Promise {
+ return this.request(
+ `/spec/specs/${encodeURIComponent(specId)}/versions/${fromVersion}/diff/${toVersion}`,
+ );
+ }
+
+ /** Run the clarification pass: surface + resolve open questions. */
+ clarifySpec(specId: string): Promise {
+ return this.request(
+ `/spec/specs/${encodeURIComponent(specId)}/clarify`,
+ { method: "POST" },
+ );
+ }
+
+ /** Generate the technical plan + ADRs. */
+ planSpec(specId: string): Promise {
+ return this.request(
+ `/spec/specs/${encodeURIComponent(specId)}/plan`,
+ { method: "POST" },
+ );
+ }
+
/** Approve a spec — the human gate that advances it out of clarification. */
approveSpec(specId: string): Promise {
return this.request(
@@ -407,6 +526,57 @@ export class ForgeApiClient {
);
}
+ /** Generate implementation tasks from an *approved* spec (409 if not). */
+ generateTasks(specId: string): Promise {
+ return this.request(
+ `/spec/specs/${encodeURIComponent(specId)}/tasks`,
+ { method: "POST" },
+ );
+ }
+
+ /** Validate a task against its spec (requirement-to-test traceability). */
+ validateTask(taskId: string): Promise {
+ return this.request(
+ `/spec/tasks/${encodeURIComponent(taskId)}/validate`,
+ { method: "POST" },
+ );
+ }
+
+ /**
+ * BYOK AI spec drafting (`ss-draft` / `ss-ai-panel`): draft a `spec.md` from
+ * a one-line goal via the workspace's model router + `ModelClient`, seeded
+ * with the project constitution when `project_id` is given. Draft-only —
+ * nothing is persisted; the caller streams `spec_md` into the Guided or
+ * Markdown editor for a human to refine and save.
+ */
+ draftSpec(body: {
+ goal: string;
+ epic_id?: string;
+ project_id?: string;
+ }): Promise {
+ return this.request("/spec/draft", { method: "POST", body });
+ }
+
+ /**
+ * `ss-import`: import an existing markdown or YAML spec (uploaded/pasted from
+ * outside Forge) as a `spec.md` draft. Parse/normalize only — no model call.
+ * Draft-only, like `draftSpec` — nothing is persisted; the caller reviews the
+ * result in the Markdown/Guided editor before saving.
+ */
+ importSpec(body: {
+ content: string;
+ source_format?: "markdown" | "yaml" | "auto";
+ }): Promise {
+ return this.request("/spec/import", { method: "POST", body });
+ }
+
+ /** Read a project's constitution (404 if it was never initialised). */
+ getConstitution(projectId: string): Promise {
+ return this.request(
+ `/spec/constitution/${encodeURIComponent(projectId)}`,
+ );
+ }
+
// --- Onboarding / guided walkthrough ------------------------------------ //
/**
diff --git a/apps/web/src/lib/api/hooks.test.tsx b/apps/web/src/lib/api/hooks.test.tsx
index 3ad018d4..b47eb33e 100644
--- a/apps/web/src/lib/api/hooks.test.tsx
+++ b/apps/web/src/lib/api/hooks.test.tsx
@@ -4,8 +4,8 @@ import type { ReactNode } from "react";
import { describe, expect, it, vi } from "vitest";
import type { ForgeApiClient } from "./client";
-import { queryKeys, useSetTaskStatus } from "./hooks";
-import type { TaskDTO } from "./types";
+import { queryKeys, useCreateEpic, useSetTaskStatus } from "./hooks";
+import type { EpicDTO, TaskDTO } from "./types";
function makeWrapper(client: QueryClient) {
return function Wrapper({ children }: { children: ReactNode }) {
@@ -79,3 +79,29 @@ describe("useSetTaskStatus (optimistic)", () => {
expect(tasks?.[0].status).toBe("backlog");
});
});
+
+describe("useCreateEpic", () => {
+ it("creates the epic and invalidates the epics list", async () => {
+ const queryClient = new QueryClient({
+ defaultOptions: { mutations: { retry: false }, queries: { retry: false } },
+ });
+ const created: EpicDTO = { id: "e-new", title: "New epic" };
+ const client = {
+ createEpic: vi.fn(() => Promise.resolve(created)),
+ } as unknown as ForgeApiClient;
+
+ const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries");
+ const { result } = renderHook(() => useCreateEpic(client), {
+ wrapper: makeWrapper(queryClient),
+ });
+
+ act(() => {
+ result.current.mutate({ title: "New epic" });
+ });
+
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+ expect(client.createEpic).toHaveBeenCalledWith({ title: "New epic" });
+ expect(result.current.data).toEqual(created);
+ expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: queryKeys.epics() });
+ });
+});
diff --git a/apps/web/src/lib/api/hooks.ts b/apps/web/src/lib/api/hooks.ts
index 2d191c94..63cae801 100644
--- a/apps/web/src/lib/api/hooks.ts
+++ b/apps/web/src/lib/api/hooks.ts
@@ -77,6 +77,23 @@ export function useEpics(
});
}
+/**
+ * Create an epic. Used by the standalone `/specs/new` entry point when the
+ * author starts from the `/specs` dashboard empty state with no epic yet to
+ * pick — it creates the epic, then the spec underneath it.
+ */
+export function useCreateEpic(
+ client: ForgeApiClient = apiClient,
+): UseMutationResult {
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: (epic: EpicDTO) => client.createEpic(epic),
+ onSuccess: () => {
+ void queryClient.invalidateQueries({ queryKey: queryKeys.epics() });
+ },
+ });
+}
+
export function useIncidents(
client: ForgeApiClient = apiClient,
): UseQueryResult {
diff --git a/apps/web/src/lib/api/spec-studio.test.tsx b/apps/web/src/lib/api/spec-studio.test.tsx
new file mode 100644
index 00000000..e19ffdde
--- /dev/null
+++ b/apps/web/src/lib/api/spec-studio.test.tsx
@@ -0,0 +1,120 @@
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { renderHook, waitFor } from "@testing-library/react";
+import type { ReactNode } from "react";
+import { describe, expect, it, vi } from "vitest";
+
+import type { ForgeApiClient } from "./client";
+import { useDraftSpec, useImportSpec } from "./spec-studio";
+import type { SpecDraft, SpecImport } from "./types";
+
+function makeWrapper(client: QueryClient) {
+ return function Wrapper({ children }: { children: ReactNode }) {
+ return {children};
+ };
+}
+
+describe("useDraftSpec", () => {
+ it("posts the goal (+ optional epic/project) to the client and returns the draft", async () => {
+ const result: SpecDraft = {
+ goal: "Search orders by name",
+ model: "claude-opus-4-8",
+ spec_md: "---\nid: SPEC-DRAFT\n---\n\n## Goal\n\nSearch orders by name\n",
+ manifest: { id: "SPEC-DRAFT", name: "Search orders by name" },
+ usage: { cost_usd: 0.01 },
+ };
+ const client = { draftSpec: vi.fn(() => Promise.resolve(result)) } as unknown as ForgeApiClient;
+ const queryClient = new QueryClient({
+ defaultOptions: { mutations: { retry: false }, queries: { retry: false } },
+ });
+
+ const { result: hook } = renderHook(() => useDraftSpec(client), {
+ wrapper: makeWrapper(queryClient),
+ });
+
+ hook.current.mutate({ goal: "Search orders by name", epic_id: "e1", project_id: "p1" });
+
+ await waitFor(() => expect(hook.current.isSuccess).toBe(true));
+ expect(hook.current.data).toEqual(result);
+ expect(client.draftSpec).toHaveBeenCalledWith({
+ goal: "Search orders by name",
+ epic_id: "e1",
+ project_id: "p1",
+ });
+ });
+
+ it("nothing is persisted or cached — a draft is never written to a query key", async () => {
+ const client = {
+ draftSpec: vi.fn(() =>
+ Promise.resolve({
+ goal: "g",
+ model: "m",
+ spec_md: "---\nid: SPEC-DRAFT\n---\n\n## Goal\n\ng\n",
+ } as SpecDraft),
+ ),
+ } as unknown as ForgeApiClient;
+ const queryClient = new QueryClient({
+ defaultOptions: { mutations: { retry: false }, queries: { retry: false } },
+ });
+
+ const { result: hook } = renderHook(() => useDraftSpec(client), {
+ wrapper: makeWrapper(queryClient),
+ });
+ hook.current.mutate({ goal: "g" });
+ await waitFor(() => expect(hook.current.isSuccess).toBe(true));
+
+ expect(queryClient.getQueryCache().getAll()).toHaveLength(0);
+ });
+});
+
+describe("useImportSpec", () => {
+ it("posts the content (+ optional source_format) to the client and returns the import", async () => {
+ const result: SpecImport = {
+ source_format: "markdown",
+ spec_md: "---\nid: SPEC-IMPORT\n---\n\n## Goal\n\nImported feature\n",
+ manifest: { id: "SPEC-IMPORT", name: "Imported feature" },
+ normalized: true,
+ };
+ const client = {
+ importSpec: vi.fn(() => Promise.resolve(result)),
+ } as unknown as ForgeApiClient;
+ const queryClient = new QueryClient({
+ defaultOptions: { mutations: { retry: false }, queries: { retry: false } },
+ });
+
+ const { result: hook } = renderHook(() => useImportSpec(client), {
+ wrapper: makeWrapper(queryClient),
+ });
+
+ hook.current.mutate({ content: "# Imported feature\n", source_format: "markdown" });
+
+ await waitFor(() => expect(hook.current.isSuccess).toBe(true));
+ expect(hook.current.data).toEqual(result);
+ expect(client.importSpec).toHaveBeenCalledWith({
+ content: "# Imported feature\n",
+ source_format: "markdown",
+ });
+ });
+
+ it("nothing is persisted or cached — an import is never written to a query key", async () => {
+ const client = {
+ importSpec: vi.fn(() =>
+ Promise.resolve({
+ source_format: "yaml",
+ spec_md: "---\nid: SPEC-IMPORT\n---\n\n## Goal\n\ng\n",
+ normalized: false,
+ } as SpecImport),
+ ),
+ } as unknown as ForgeApiClient;
+ const queryClient = new QueryClient({
+ defaultOptions: { mutations: { retry: false }, queries: { retry: false } },
+ });
+
+ const { result: hook } = renderHook(() => useImportSpec(client), {
+ wrapper: makeWrapper(queryClient),
+ });
+ hook.current.mutate({ content: "id: x\nname: g\n" });
+ await waitFor(() => expect(hook.current.isSuccess).toBe(true));
+
+ expect(queryClient.getQueryCache().getAll()).toHaveLength(0);
+ });
+});
diff --git a/apps/web/src/lib/api/spec-studio.ts b/apps/web/src/lib/api/spec-studio.ts
new file mode 100644
index 00000000..a1b0910d
--- /dev/null
+++ b/apps/web/src/lib/api/spec-studio.ts
@@ -0,0 +1,153 @@
+"use client";
+
+/**
+ * TanStack Query hooks for Spec Studio — the spec-authoring surface over one
+ * canonical `SpecManifest`, editable from four modes (Guided / Markdown /
+ * YAML / Read; see `components/spec-studio/spec-studio.tsx`). Mirrors the
+ * `lib/api/spec.ts` convention: dedicated query keys, an injectable client.
+ *
+ * `spec.md` and `manifest.yaml` are lazily fetched (only once their mode is
+ * visited) and share one invariant: saving *any* of the three editable
+ * surfaces re-renders the other two on the backend, so a successful save
+ * invalidates the sibling queries rather than trusting stale cached text.
+ */
+
+import {
+ useMutation,
+ useQuery,
+ useQueryClient,
+ type UseMutationResult,
+ type UseQueryResult,
+} from "@tanstack/react-query";
+
+import { apiClient, type ForgeApiClient } from "./client";
+import { specVersionKeys } from "./spec-versions";
+import type { SpecDraft, SpecImport, SpecManifest } from "./types";
+
+export const specStudioKeys = {
+ manifest: (specId: string) => ["spec-studio", "manifest", specId] as const,
+ markdown: (specId: string) => ["spec-studio", "markdown", specId] as const,
+ yaml: (specId: string) => ["spec-studio", "yaml", specId] as const,
+};
+
+export function useSpecStudioManifest(
+ specId: string,
+ client: ForgeApiClient = apiClient,
+): UseQueryResult {
+ return useQuery({
+ queryKey: specStudioKeys.manifest(specId),
+ queryFn: () => client.getSpecManifest(specId),
+ enabled: Boolean(specId),
+ });
+}
+
+export function useSpecStudioMarkdown(
+ specId: string,
+ enabled: boolean,
+ client: ForgeApiClient = apiClient,
+): UseQueryResult {
+ return useQuery({
+ queryKey: specStudioKeys.markdown(specId),
+ queryFn: () => client.getSpecMarkdown(specId),
+ enabled: Boolean(specId) && enabled,
+ });
+}
+
+export function useSpecStudioYaml(
+ specId: string,
+ enabled: boolean,
+ client: ForgeApiClient = apiClient,
+): UseQueryResult {
+ return useQuery({
+ queryKey: specStudioKeys.yaml(specId),
+ queryFn: () => client.getSpecManifestYaml(specId),
+ enabled: Boolean(specId) && enabled,
+ });
+}
+
+/** After any save, the manifest cache gets the fresh value; siblings just refetch. */
+function useSyncAfterSave(specId: string) {
+ const queryClient = useQueryClient();
+ return (updated: SpecManifest, savedFrom: "guided" | "markdown" | "yaml") => {
+ queryClient.setQueryData(specStudioKeys.manifest(specId), updated);
+ if (savedFrom !== "markdown") {
+ void queryClient.invalidateQueries({ queryKey: specStudioKeys.markdown(specId) });
+ }
+ if (savedFrom !== "yaml") {
+ void queryClient.invalidateQueries({ queryKey: specStudioKeys.yaml(specId) });
+ }
+ // ss-versioning: every save records a new version; refresh the history list.
+ void queryClient.invalidateQueries({ queryKey: specVersionKeys.list(specId) });
+ };
+}
+
+export function useSaveGuidedManifest(
+ specId: string,
+ client: ForgeApiClient = apiClient,
+): UseMutationResult {
+ const sync = useSyncAfterSave(specId);
+ return useMutation({
+ mutationFn: (manifest: SpecManifest) => client.putSpecManifest(specId, manifest),
+ onSuccess: (updated) => sync(updated, "guided"),
+ });
+}
+
+export function useSaveSpecMarkdown(
+ specId: string,
+ client: ForgeApiClient = apiClient,
+): UseMutationResult {
+ const sync = useSyncAfterSave(specId);
+ return useMutation({
+ mutationFn: (content: string) => client.putSpecMarkdown(specId, content),
+ onSuccess: (updated) => sync(updated, "markdown"),
+ });
+}
+
+export function useSaveSpecManifestYaml(
+ specId: string,
+ client: ForgeApiClient = apiClient,
+): UseMutationResult {
+ const sync = useSyncAfterSave(specId);
+ return useMutation({
+ mutationFn: (content: string) => client.putSpecManifestYaml(specId, content),
+ onSuccess: (updated) => sync(updated, "yaml"),
+ });
+}
+
+export interface DraftSpecVariables {
+ goal: string;
+ epic_id?: string;
+ project_id?: string;
+}
+
+/**
+ * `ss-ai-panel`: draft a `spec.md` from a one-line goal (`POST /spec/draft`).
+ * Draft-only — nothing is persisted or cached; the caller (`AiDraftPanel`)
+ * owns streaming the result into the Guided/Markdown editor.
+ */
+export function useDraftSpec(
+ client: ForgeApiClient = apiClient,
+): UseMutationResult {
+ return useMutation({
+ mutationFn: (body: DraftSpecVariables) => client.draftSpec(body),
+ });
+}
+
+export interface ImportSpecVariables {
+ content: string;
+ source_format?: "markdown" | "yaml" | "auto";
+}
+
+/**
+ * `ss-import`: import an existing markdown or YAML spec (pasted/uploaded from
+ * outside Forge) as a `spec.md` draft (`POST /spec/import`). Draft-only —
+ * nothing is persisted or cached; the caller reviews/refines the result in the
+ * Markdown or Guided editor before saving, mirroring `useDraftSpec`.
+ */
+export function useImportSpec(
+ client: ForgeApiClient = apiClient,
+): UseMutationResult {
+ return useMutation({
+ mutationFn: (body: ImportSpecVariables) => client.importSpec(body),
+ });
+}
diff --git a/apps/web/src/lib/api/spec-versions.ts b/apps/web/src/lib/api/spec-versions.ts
new file mode 100644
index 00000000..8c4a8d16
--- /dev/null
+++ b/apps/web/src/lib/api/spec-versions.ts
@@ -0,0 +1,64 @@
+"use client";
+
+/**
+ * TanStack Query hooks for Spec Studio's version history + diff (ss-versioning).
+ *
+ * A version is recorded on every save (Guided / Markdown / YAML — see
+ * `lib/api/spec-studio.ts`'s `useSyncAfterSave`), so the history list here
+ * invalidates whenever any of those three saves succeeds. Kept as its own
+ * module (mirrors `spec.ts` / `spec-studio.ts`'s "own query keys" convention)
+ * rather than folded into `spec-studio.ts`, since version history is a
+ * read-only surface with no editor state to coordinate.
+ */
+
+import { useQuery, type UseQueryResult } from "@tanstack/react-query";
+
+import { apiClient, type ForgeApiClient } from "./client";
+import type { SpecVersionDetail, SpecVersionDiff, SpecVersionSummary } from "./types";
+
+export const specVersionKeys = {
+ list: (specId: string) => ["spec-versions", "list", specId] as const,
+ detail: (specId: string, version: number) =>
+ ["spec-versions", "detail", specId, version] as const,
+ diff: (specId: string, from: number, to: number) =>
+ ["spec-versions", "diff", specId, from, to] as const,
+};
+
+/** A spec's version history, newest first. */
+export function useSpecVersions(
+ specId: string,
+ client: ForgeApiClient = apiClient,
+): UseQueryResult {
+ return useQuery({
+ queryKey: specVersionKeys.list(specId),
+ queryFn: () => client.listSpecVersions(specId),
+ enabled: Boolean(specId),
+ });
+}
+
+/** One version's full snapshot (manifest + both serializations). */
+export function useSpecVersion(
+ specId: string,
+ versionNumber: number | null,
+ client: ForgeApiClient = apiClient,
+): UseQueryResult {
+ return useQuery({
+ queryKey: specVersionKeys.detail(specId, versionNumber ?? -1),
+ queryFn: () => client.getSpecVersion(specId, versionNumber as number),
+ enabled: Boolean(specId) && versionNumber !== null,
+ });
+}
+
+/** The diff between two versions of a spec. */
+export function useSpecVersionDiff(
+ specId: string,
+ fromVersion: number | null,
+ toVersion: number | null,
+ client: ForgeApiClient = apiClient,
+): UseQueryResult {
+ return useQuery({
+ queryKey: specVersionKeys.diff(specId, fromVersion ?? -1, toVersion ?? -1),
+ queryFn: () => client.diffSpecVersions(specId, fromVersion as number, toVersion as number),
+ enabled: Boolean(specId) && fromVersion !== null && toVersion !== null,
+ });
+}
diff --git a/apps/web/src/lib/api/spec.test.tsx b/apps/web/src/lib/api/spec.test.tsx
index fb758ffa..b4230233 100644
--- a/apps/web/src/lib/api/spec.test.tsx
+++ b/apps/web/src/lib/api/spec.test.tsx
@@ -4,8 +4,17 @@ import type { ReactNode } from "react";
import { describe, expect, it, vi } from "vitest";
import type { ForgeApiClient } from "./client";
-import { specKeys, useApproveSpec, useSpecOverview } from "./spec";
-import type { SpecDashboard, SpecManifest } from "./types";
+import {
+ specKeys,
+ useApproveSpec,
+ useClarifySpec,
+ useCreateSpec,
+ useGenerateTasks,
+ usePlanSpec,
+ useSpecOverview,
+ useValidateSpec,
+} from "./spec";
+import type { SpecDashboard, SpecManifest, TaskDTO, ValidationReport } from "./types";
function makeWrapper(client: QueryClient) {
return function Wrapper({ children }: { children: ReactNode }) {
@@ -98,3 +107,228 @@ describe("useApproveSpec (optimistic)", () => {
expect(data?.specs.find((s) => s.id === "s1")?.status).toBe("clarifying");
});
});
+
+describe("useClarifySpec (optimistic, Describe step)", () => {
+ it("flips the spec's status to clarifying before the request resolves", async () => {
+ const queryClient = new QueryClient({
+ defaultOptions: { mutations: { retry: false }, queries: { retry: false } },
+ });
+ queryClient.setQueryData(specKeys.overview("p1"), {
+ ...dashboard,
+ specs: [{ id: "s1", name: "Passwordless auth", status: "draft" as const }],
+ });
+
+ let resolve!: (value: SpecManifest) => void;
+ const pending = new Promise((r) => {
+ resolve = r;
+ });
+ const client = {
+ clarifySpec: vi.fn(() => pending),
+ } as unknown as ForgeApiClient;
+
+ const { result } = renderHook(() => useClarifySpec(client), {
+ wrapper: makeWrapper(queryClient),
+ });
+
+ act(() => {
+ result.current.mutate({ specId: "s1" });
+ });
+
+ await waitFor(() => {
+ const data = queryClient.getQueryData(specKeys.overview("p1"));
+ expect(data?.specs.find((s) => s.id === "s1")?.status).toBe("clarifying");
+ });
+
+ act(() => {
+ resolve({ id: "s1", name: "Passwordless auth", status: "clarifying" });
+ });
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+ expect(client.clarifySpec).toHaveBeenCalledWith("s1");
+ });
+
+ it("rolls the dashboard back when clarification fails", async () => {
+ const queryClient = new QueryClient({
+ defaultOptions: { mutations: { retry: false }, queries: { retry: false } },
+ });
+ queryClient.setQueryData(specKeys.overview("p1"), dashboard);
+ const client = {
+ clarifySpec: vi.fn(() => Promise.reject(new Error("boom"))),
+ } as unknown as ForgeApiClient;
+
+ const { result } = renderHook(() => useClarifySpec(client), {
+ wrapper: makeWrapper(queryClient),
+ });
+
+ act(() => {
+ result.current.mutate({ specId: "s1" });
+ });
+
+ await waitFor(() => expect(result.current.isError).toBe(true));
+ const data = queryClient.getQueryData(specKeys.overview("p1"));
+ expect(data?.specs.find((s) => s.id === "s1")?.status).toBe("clarifying");
+ });
+});
+
+describe("usePlanSpec (Refine step, not optimistic)", () => {
+ it("calls planSpec and invalidates the spec caches on settle", async () => {
+ const planned: SpecManifest = {
+ id: "s1",
+ name: "Passwordless auth",
+ status: "clarifying",
+ plan_ref: "plan.md",
+ };
+ const client = {
+ planSpec: vi.fn(() => Promise.resolve(planned)),
+ } as unknown as ForgeApiClient;
+ const queryClient = new QueryClient({
+ defaultOptions: { mutations: { retry: false }, queries: { retry: false } },
+ });
+ const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries");
+
+ const { result } = renderHook(() => usePlanSpec(client), {
+ wrapper: makeWrapper(queryClient),
+ });
+
+ act(() => {
+ result.current.mutate({ specId: "s1" });
+ });
+
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+ expect(client.planSpec).toHaveBeenCalledWith("s1");
+ expect(result.current.data).toEqual(planned);
+ expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: specKeys.all() });
+ });
+});
+
+describe("useGenerateTasks (Build step, not optimistic)", () => {
+ it("calls generateTasks and returns the task list", async () => {
+ const tasks: TaskDTO[] = [{ id: "t1", title: "Implement R1" }];
+ const client = {
+ generateTasks: vi.fn(() => Promise.resolve(tasks)),
+ } as unknown as ForgeApiClient;
+ const queryClient = new QueryClient({
+ defaultOptions: { mutations: { retry: false }, queries: { retry: false } },
+ });
+
+ const { result } = renderHook(() => useGenerateTasks(client), {
+ wrapper: makeWrapper(queryClient),
+ });
+
+ act(() => {
+ result.current.mutate({ specId: "s1" });
+ });
+
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+ expect(client.generateTasks).toHaveBeenCalledWith("s1");
+ expect(result.current.data).toEqual(tasks);
+ });
+});
+
+describe("useValidateSpec (Verify step, not optimistic)", () => {
+ it("regenerates tasks to resolve a task id, then validates it", async () => {
+ const tasks: TaskDTO[] = [{ id: "t1", title: "Implement R1" }];
+ const report: ValidationReport = { task_id: "t1", passed: true };
+ const client = {
+ generateTasks: vi.fn(() => Promise.resolve(tasks)),
+ validateTask: vi.fn(() => Promise.resolve(report)),
+ } as unknown as ForgeApiClient;
+ const queryClient = new QueryClient({
+ defaultOptions: { mutations: { retry: false }, queries: { retry: false } },
+ });
+
+ const { result } = renderHook(() => useValidateSpec(client), {
+ wrapper: makeWrapper(queryClient),
+ });
+
+ act(() => {
+ result.current.mutate({ specId: "s1" });
+ });
+
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+ expect(client.generateTasks).toHaveBeenCalledWith("s1");
+ expect(client.validateTask).toHaveBeenCalledWith("t1");
+ expect(result.current.data).toEqual(report);
+ });
+
+ it("fails with a clear message when there are no tasks to validate", async () => {
+ const client = {
+ generateTasks: vi.fn(() => Promise.resolve([])),
+ validateTask: vi.fn(),
+ } as unknown as ForgeApiClient;
+ const queryClient = new QueryClient({
+ defaultOptions: { mutations: { retry: false }, queries: { retry: false } },
+ });
+
+ const { result } = renderHook(() => useValidateSpec(client), {
+ wrapper: makeWrapper(queryClient),
+ });
+
+ act(() => {
+ result.current.mutate({ specId: "s1" });
+ });
+
+ await waitFor(() => expect(result.current.isError).toBe(true));
+ expect(client.validateTask).not.toHaveBeenCalled();
+ });
+});
+
+describe("useCreateSpec", () => {
+ it("creates a spec for an epic and invalidates the overview cache", async () => {
+ const created: SpecManifest = { id: "s3", name: "New spec", status: "draft" };
+ const client = {
+ createSpec: vi.fn(() => Promise.resolve(created)),
+ } as unknown as ForgeApiClient;
+ const queryClient = new QueryClient({
+ defaultOptions: { mutations: { retry: false }, queries: { retry: false } },
+ });
+ const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries");
+
+ const { result } = renderHook(() => useCreateSpec(client), {
+ wrapper: makeWrapper(queryClient),
+ });
+
+ act(() => {
+ result.current.mutate({ epic_id: "e1", name: "New spec" });
+ });
+
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+ expect(client.createSpec).toHaveBeenCalledWith({ epic_id: "e1", name: "New spec" });
+ expect(result.current.data).toEqual(created);
+ expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: specKeys.overviews() });
+ });
+
+ it("follows up with a PUT to persist Guided-mode fields the create endpoint can't take", async () => {
+ const created: SpecManifest = { id: "s3", name: "New spec", status: "draft" };
+ const saved: SpecManifest = {
+ ...created,
+ acceptance_criteria: [{ id: "AC1", text: "Given a, When b, Then c" }],
+ };
+ const client = {
+ createSpec: vi.fn(() => Promise.resolve(created)),
+ putSpecManifest: vi.fn(() => Promise.resolve(saved)),
+ } as unknown as ForgeApiClient;
+ const queryClient = new QueryClient({
+ defaultOptions: { mutations: { retry: false }, queries: { retry: false } },
+ });
+
+ const { result } = renderHook(() => useCreateSpec(client), {
+ wrapper: makeWrapper(queryClient),
+ });
+
+ act(() => {
+ result.current.mutate({
+ epic_id: "e1",
+ name: "New spec",
+ acceptance_criteria: saved.acceptance_criteria,
+ });
+ });
+
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+ expect(client.createSpec).toHaveBeenCalledWith({ epic_id: "e1", name: "New spec" });
+ expect(client.putSpecManifest).toHaveBeenCalledWith(
+ "s3",
+ expect.objectContaining({ acceptance_criteria: saved.acceptance_criteria }),
+ );
+ expect(result.current.data).toEqual(saved);
+ });
+});
diff --git a/apps/web/src/lib/api/spec.ts b/apps/web/src/lib/api/spec.ts
index e17d8d81..01bff70a 100644
--- a/apps/web/src/lib/api/spec.ts
+++ b/apps/web/src/lib/api/spec.ts
@@ -21,7 +21,18 @@ import {
} from "@tanstack/react-query";
import { apiClient, type ForgeApiClient } from "./client";
-import type { SpecDashboard, SpecManifest } from "./types";
+import { specStudioKeys } from "./spec-studio";
+import type {
+ ADR,
+ AcceptanceCriterion,
+ ExecutionMode,
+ OpenQuestion,
+ Requirement,
+ SpecDashboard,
+ SpecManifest,
+ TaskDTO,
+ ValidationReport,
+} from "./types";
export const specKeys = {
all: () => ["specs"] as const,
@@ -41,6 +52,86 @@ export function useSpecOverview(
});
}
+export interface CreateSpecVariables {
+ epic_id: string;
+ name: string;
+ requirements?: Requirement[];
+ /**
+ * The rest of the Guided-mode form (Acceptance Criteria, Constraints,
+ * Advanced section). `POST /spec/specs` only accepts
+ * `epic_id`/`name`/`requirements`, so when any of these are set the
+ * mutation follows up with a `PUT /spec/specs/{id}` to persist them —
+ * otherwise anything the author filled in beyond requirements before
+ * hitting "Create spec" would be silently dropped.
+ */
+ acceptance_criteria?: AcceptanceCriterion[];
+ open_questions?: OpenQuestion[];
+ constraints?: string[];
+ decisions?: ADR[];
+ execution_mode?: ExecutionMode;
+ constitution_refs?: string[];
+ repos?: string[];
+}
+
+function hasExtraGuidedFields(body: CreateSpecVariables): boolean {
+ return Boolean(
+ body.acceptance_criteria?.length ||
+ body.open_questions?.length ||
+ body.constraints?.length ||
+ body.decisions?.length ||
+ body.execution_mode ||
+ body.constitution_refs?.length ||
+ body.repos?.length,
+ );
+}
+
+/**
+ * Create a draft spec for an epic — the `/specs/new` entry point into the SDD
+ * lifecycle. Guided mode collects the *whole* manifest (acceptance criteria,
+ * constraints, execution mode, constitution refs, repos, decisions) before
+ * the spec exists, but the create endpoint only takes
+ * `epic_id`/`name`/`requirements`; when any of those extra fields are set,
+ * this mutation follows the create with a `PUT /spec/specs/{id}` so nothing
+ * the author drafted is lost.
+ */
+export function useCreateSpec(
+ client: ForgeApiClient = apiClient,
+): UseMutationResult {
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: async (body: CreateSpecVariables) => {
+ const {
+ acceptance_criteria,
+ open_questions,
+ constraints,
+ decisions,
+ execution_mode,
+ constitution_refs,
+ repos,
+ ...createBody
+ } = body;
+ const created = await client.createSpec(createBody);
+ if (!hasExtraGuidedFields(body)) {
+ return created;
+ }
+ const fullManifest: SpecManifest = {
+ ...created,
+ acceptance_criteria,
+ open_questions,
+ constraints,
+ decisions,
+ execution_mode,
+ constitution_refs,
+ repos,
+ };
+ return client.putSpecManifest(created.id, fullManifest);
+ },
+ onSuccess: () => {
+ void queryClient.invalidateQueries({ queryKey: specKeys.overviews() });
+ },
+ });
+}
+
export interface ApproveSpecVariables {
specId: string;
}
@@ -58,9 +149,19 @@ interface ApproveSpecContext {
*/
export function useApproveSpec(
client: ForgeApiClient = apiClient,
-): UseMutationResult {
+): UseMutationResult<
+ SpecManifest,
+ Error,
+ ApproveSpecVariables,
+ ApproveSpecContext
+> {
const queryClient = useQueryClient();
- return useMutation({
+ return useMutation<
+ SpecManifest,
+ Error,
+ ApproveSpecVariables,
+ ApproveSpecContext
+ >({
mutationFn: ({ specId }) => client.approveSpec(specId),
onMutate: async ({ specId }) => {
await queryClient.cancelQueries({ queryKey: specKeys.overviews() });
@@ -94,3 +195,134 @@ export function useApproveSpec(
},
});
}
+
+// --------------------------------------------------------------------------- //
+// ss-lifecycle: the plain-language stepper's inline actions //
+// //
+// Describe->Refine->Approve->Build->Verify, each backed by one existing //
+// `/spec` engine call (Clarify/Plan/Approve/Generate tasks/Validate — see //
+// `components/spec/spec-meta.ts`). Clarify is a simple, single-field status //
+// flip like Approve, so it gets the same optimistic treatment; Plan/Generate //
+// tasks/Validate touch richer manifest state (plans, tasks, gated validation) //
+// that would be unsafe to fake, so those settle from the engine's response. //
+// --------------------------------------------------------------------------- //
+
+interface SpecIdVariables {
+ specId: string;
+}
+
+interface OptimisticStatusContext {
+ previous: [readonly unknown[], SpecDashboard | undefined][];
+}
+
+/** Snapshot every cached dashboard and flip one spec's status ahead of the request. */
+function optimisticallySetStatus(
+ queryClient: ReturnType,
+ specId: string,
+ status: SpecManifest["status"],
+): OptimisticStatusContext["previous"] {
+ const previous = queryClient.getQueriesData({
+ queryKey: specKeys.overviews(),
+ });
+ queryClient.setQueriesData(
+ { queryKey: specKeys.overviews() },
+ (old) =>
+ old
+ ? {
+ ...old,
+ specs: old.specs.map((spec) => (spec.id === specId ? { ...spec, status } : spec)),
+ }
+ : old,
+ );
+ return previous;
+}
+
+function rollbackStatus(
+ queryClient: ReturnType,
+ context: OptimisticStatusContext | undefined,
+) {
+ if (!context) return;
+ for (const [key, data] of context.previous) {
+ queryClient.setQueryData(key, data);
+ }
+}
+
+/** After any lifecycle action settles, the studio's cached surfaces are stale. */
+function invalidateSpecCaches(queryClient: ReturnType, specId: string) {
+ void queryClient.invalidateQueries({ queryKey: specKeys.all() });
+ void queryClient.invalidateQueries({ queryKey: specStudioKeys.manifest(specId) });
+ void queryClient.invalidateQueries({ queryKey: specStudioKeys.markdown(specId) });
+ void queryClient.invalidateQueries({ queryKey: specStudioKeys.yaml(specId) });
+}
+
+/**
+ * **Describe** step: run the clarification pass (`POST /spec/{id}/clarify`).
+ * Optimistic — flips the spec to `clarifying` immediately, like `useApproveSpec`.
+ */
+export function useClarifySpec(
+ client: ForgeApiClient = apiClient,
+): UseMutationResult {
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: ({ specId }) => client.clarifySpec(specId),
+ onMutate: async ({ specId }) => {
+ await queryClient.cancelQueries({ queryKey: specKeys.overviews() });
+ return { previous: optimisticallySetStatus(queryClient, specId, "clarifying") };
+ },
+ onError: (_error, _variables, context) => rollbackStatus(queryClient, context),
+ onSettled: (_data, _error, { specId }) => invalidateSpecCaches(queryClient, specId),
+ });
+}
+
+/**
+ * **Refine** step: generate the technical plan + ADRs (`POST /spec/{id}/plan`).
+ * Not optimistic — `plan_ref`/`decisions` are new manifest content, not a status
+ * flip, so the UI waits for the engine's response.
+ */
+export function usePlanSpec(
+ client: ForgeApiClient = apiClient,
+): UseMutationResult {
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: ({ specId }: SpecIdVariables) => client.planSpec(specId),
+ onSettled: (_data, _error, { specId }) => invalidateSpecCaches(queryClient, specId),
+ });
+}
+
+/**
+ * **Build** step: generate implementation tasks from an approved spec
+ * (`POST /spec/{id}/tasks`, 409 if not yet approved). Not optimistic — the
+ * response is the task list, not the manifest, and the action is gated.
+ */
+export function useGenerateTasks(
+ client: ForgeApiClient = apiClient,
+): UseMutationResult {
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: ({ specId }: SpecIdVariables) => client.generateTasks(specId),
+ onSettled: (_data, _error, { specId }) => invalidateSpecCaches(queryClient, specId),
+ });
+}
+
+/**
+ * **Verify** step: validate the spec's (deterministic) generated tasks
+ * (`POST /spec/tasks/{task_id}/validate`). Task generation is idempotent, so
+ * this re-runs `generateTasks` to resolve a task id rather than requiring the
+ * Build step to have run first in this session.
+ */
+export function useValidateSpec(
+ client: ForgeApiClient = apiClient,
+): UseMutationResult {
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: async ({ specId }: SpecIdVariables) => {
+ const tasks = await client.generateTasks(specId);
+ const taskId = tasks.find((task) => task.id)?.id;
+ if (!taskId) {
+ throw new Error("No tasks to validate yet — generate tasks first.");
+ }
+ return client.validateTask(taskId);
+ },
+ onSettled: (_data, _error, { specId }) => invalidateSpecCaches(queryClient, specId),
+ });
+}
diff --git a/apps/web/src/lib/api/types.ts b/apps/web/src/lib/api/types.ts
index 605098c6..ac057c2e 100644
--- a/apps/web/src/lib/api/types.ts
+++ b/apps/web/src/lib/api/types.ts
@@ -428,6 +428,111 @@ export interface SpecDashboard {
specs: SpecOverview[];
}
+// --- ss-versioning: spec version history + diff --------------------------- //
+
+/** One row of a spec's version history (GET /spec/specs/{id}/versions). */
+export interface SpecVersionSummary {
+ version_number: number;
+ name: string;
+ status: string;
+ created_at: string;
+ created_by?: string | null;
+}
+
+/** A single version's full snapshot (GET /spec/specs/{id}/versions/{n}). */
+export interface SpecVersionDetail extends SpecVersionSummary {
+ manifest: SpecManifest;
+ spec_md: string;
+ manifest_yaml: string;
+}
+
+/** One line of a unified line-diff between two `spec.md` texts. */
+export interface TextDiffLine {
+ op: "equal" | "insert" | "delete";
+ text: string;
+}
+
+/** One id-keyed add/remove/modify entry within a manifest list field. */
+export interface ListItemChange {
+ id: string;
+ change: "added" | "removed" | "modified";
+ before?: Record | null;
+ after?: Record | null;
+}
+
+/** A changed top-level scalar field (e.g. `name`, `status`). */
+export interface ScalarFieldChange {
+ field: string;
+ before: unknown;
+ after: unknown;
+}
+
+/** The structured diff between two spec manifest snapshots. */
+export interface ManifestDiff {
+ scalar_changes: ScalarFieldChange[];
+ requirements: ListItemChange[];
+ acceptance_criteria: ListItemChange[];
+ open_questions: ListItemChange[];
+ decisions: ListItemChange[];
+ constraints_added: string[];
+ constraints_removed: string[];
+}
+
+/** The diff between two versions of a spec (GET .../versions/{a}/diff/{b}). */
+export interface SpecVersionDiff {
+ from_version: number;
+ to_version: number;
+ markdown: TextDiffLine[];
+ manifest: ManifestDiff;
+}
+
+/**
+ * Token/cost accounting for one model call (`forge_agent.providers`'s
+ * `UsageAccumulator.to_artifact` shape — mirrored here, not reimplemented).
+ */
+export interface ModelUsage {
+ input_tokens?: number;
+ output_tokens?: number;
+ cost_usd?: number;
+ calls?: number;
+ cache_read_input_tokens?: number;
+}
+
+/**
+ * The draft-only result of `POST /spec/draft` (ss-draft / ss-ai-panel): a BYOK
+ * model turns a one-line goal into a `spec.md`, seeded with the project
+ * constitution. Nothing is persisted — `manifest` is a parsed *preview* (or
+ * `null` with `parse_error` set when the drafted markdown didn't parse) for a
+ * human to refine before saving via the normal spec-editing endpoints.
+ */
+export interface SpecDraft {
+ goal: string;
+ epic_id?: string | null;
+ model: string;
+ spec_md: string;
+ manifest?: SpecManifest | null;
+ parse_error?: string | null;
+ usage?: ModelUsage;
+}
+
+/**
+ * The draft-only result of `POST /spec/import` (`ss-import`): an existing
+ * markdown or YAML spec pasted/uploaded from outside Forge, parsed or
+ * best-effort normalized into a `spec.md` draft. No model call — `normalized`
+ * is `true` when the source needed loose-shape mapping (arbitrary headings,
+ * alternate YAML keys) rather than parsing directly as a canonical Forge
+ * document. `manifest` is `null` (with `parse_error` set) only for genuinely
+ * unparseable content. Nothing is persisted — a human refines the result via
+ * the normal spec-editing endpoints.
+ */
+export interface SpecImport {
+ source_format: "markdown" | "yaml";
+ spec_md: string;
+ manifest?: SpecManifest | null;
+ parse_error?: string | null;
+ normalized: boolean;
+}
+
// --- Observability: run traces -------------------------------------------- //
// Mirrors forge_api.observability.trace.RunTrace + forge_contracts.Step, the
// response shape of GET /observability/runs/{run_id}/trace.
diff --git a/apps/web/src/lib/spec-studio/markdown-parse.test.ts b/apps/web/src/lib/spec-studio/markdown-parse.test.ts
new file mode 100644
index 00000000..bde00580
--- /dev/null
+++ b/apps/web/src/lib/spec-studio/markdown-parse.test.ts
@@ -0,0 +1,162 @@
+import { describe, expect, it } from "vitest";
+
+import { hasMarkdownErrors, parseSpecMarkdown } from "./markdown-parse";
+
+const VALID = `---
+id: SPEC-1
+status: draft
+constitution_refs: []
+repos: []
+execution_mode: single_agent
+skill_profile: null
+plan_ref: null
+tasks_ref: null
+validation_ref: null
+---
+
+## Goal
+
+Passwordless auth
+
+## Requirements
+
+- **R1**: Users can sign in without a password
+
+## Acceptance Criteria
+
+- **AC1** (R1): Given a valid magic link, when clicked, then the user is signed in
+
+## Constraints
+
+- Must work offline
+
+## Open Questions
+
+- **Q1**: Should magic links expire?
+ - Resolution: After 15 minutes
+
+## Decisions
+
+### ADR-1 — Use signed tokens
+
+- Status: accepted
+- Context: Need a stateless link
+- Decision: Sign the token
+- Consequences: Requires a secret key
+`;
+
+describe("parseSpecMarkdown", () => {
+ it("parses a fully valid spec.md with no issues", () => {
+ const { manifest, issues } = parseSpecMarkdown(VALID);
+ expect(issues).toEqual([]);
+ expect(manifest.id).toBe("SPEC-1");
+ expect(manifest.name).toBe("Passwordless auth");
+ expect(manifest.requirements).toEqual([{ id: "R1", text: "Users can sign in without a password" }]);
+ expect(manifest.acceptance_criteria).toEqual([
+ {
+ id: "AC1",
+ text: "Given a valid magic link, when clicked, then the user is signed in",
+ req_refs: ["R1"],
+ spec_ref: null,
+ },
+ ]);
+ expect(manifest.constraints).toEqual(["Must work offline"]);
+ expect(manifest.open_questions).toEqual([
+ { id: "Q1", text: "Should magic links expire?", resolution: "After 15 minutes" },
+ ]);
+ expect(manifest.decisions).toEqual([
+ {
+ id: "ADR-1",
+ title: "Use signed tokens",
+ status: "accepted",
+ context: "Need a stateless link",
+ decision: "Sign the token",
+ consequences: "Requires a secret key",
+ },
+ ]);
+ expect(hasMarkdownErrors(issues)).toBe(false);
+ });
+
+ it("requires a leading '---' frontmatter block", () => {
+ const { issues } = parseSpecMarkdown("## Goal\n\nSomething\n");
+ expect(hasMarkdownErrors(issues)).toBe(true);
+ expect(issues[0].line).toBe(1);
+ expect(issues[0].message).toMatch(/frontmatter/i);
+ });
+
+ it("flags an unterminated frontmatter block", () => {
+ const { issues } = parseSpecMarkdown("---\nid: SPEC-1\n");
+ expect(hasMarkdownErrors(issues)).toBe(true);
+ expect(issues.some((i) => /unterminated/i.test(i.message))).toBe(true);
+ });
+
+ it("requires the 'id' frontmatter key", () => {
+ const { issues } = parseSpecMarkdown("---\nstatus: draft\n---\n\n## Goal\n\nX\n");
+ expect(issues.some((i) => /'id'/.test(i.message))).toBe(true);
+ });
+
+ it("requires a '## Goal' section", () => {
+ const { issues } = parseSpecMarkdown("---\nid: SPEC-1\n---\n\n## Requirements\n\n- **R1**: text\n");
+ expect(issues.some((i) => /Goal/.test(i.message))).toBe(true);
+ });
+
+ it("line-anchors a malformed requirement bullet", () => {
+ const text = "---\nid: SPEC-1\n---\n\n## Goal\n\nX\n\n## Requirements\n\n- not a bullet\n";
+ const { issues } = parseSpecMarkdown(text);
+ const issue = issues.find((i) => /requirement must be/.test(i.message));
+ expect(issue).toBeDefined();
+ expect(issue?.line).toBe(text.split("\n").findIndex((l) => l === "- not a bullet") + 1);
+ });
+
+ it("line-anchors a malformed acceptance criterion bullet", () => {
+ const text = "---\nid: SPEC-1\n---\n\n## Goal\n\nX\n\n## Acceptance Criteria\n\nnope\n";
+ const { issues } = parseSpecMarkdown(text);
+ expect(issues.some((i) => /acceptance criterion must be/.test(i.message))).toBe(true);
+ });
+
+ it("folds 2-space continuation lines into a multi-line checklist criterion", () => {
+ const text =
+ "---\nid: SPEC-1\n---\n\n## Goal\n\nX\n\n## Acceptance Criteria\n\n" +
+ "- **AC1** (R1): - [ ] Email validates\n - [x] Password masked\n";
+ const { manifest, issues } = parseSpecMarkdown(text);
+ expect(hasMarkdownErrors(issues)).toBe(false);
+ expect(manifest.acceptance_criteria).toEqual([
+ { id: "AC1", text: "- [ ] Email validates\n- [x] Password masked", req_refs: ["R1"], spec_ref: null },
+ ]);
+ });
+
+ it("flags an acceptance continuation line before any criterion", () => {
+ const text = "---\nid: SPEC-1\n---\n\n## Goal\n\nX\n\n## Acceptance Criteria\n\n - [ ] orphan\n";
+ const { issues } = parseSpecMarkdown(text);
+ expect(issues.some((i) => /continuation line before any criterion/.test(i.message))).toBe(true);
+ });
+
+ it("flags a resolution with no preceding open question", () => {
+ const text = "---\nid: SPEC-1\n---\n\n## Goal\n\nX\n\n## Open Questions\n\n - Resolution: orphan\n";
+ const { issues } = parseSpecMarkdown(text);
+ expect(issues.some((i) => /no preceding open question/.test(i.message))).toBe(true);
+ });
+
+ it("flags an unknown section as a warning, not an error", () => {
+ const text = "---\nid: SPEC-1\n---\n\n## Goal\n\nX\n\n## Nonsense\n\nsomething\n";
+ const { issues, manifest } = parseSpecMarkdown(text);
+ const issue = issues.find((i) => /unknown section/.test(i.message));
+ expect(issue?.severity).toBe("warning");
+ expect(hasMarkdownErrors(issues)).toBe(false);
+ expect(manifest.name).toBe("X");
+ });
+
+ it("parses a decision heading and rejects a malformed one", () => {
+ const text =
+ "---\nid: SPEC-1\n---\n\n## Goal\n\nX\n\n## Decisions\n\n### ADR-1 no separator\n\n- Status: accepted\n";
+ const { issues } = parseSpecMarkdown(text);
+ expect(issues.some((i) => /decision heading must be/.test(i.message))).toBe(true);
+ });
+
+ it("is best-effort: still returns a manifest alongside issues", () => {
+ const { manifest, issues } = parseSpecMarkdown("nonsense");
+ expect(hasMarkdownErrors(issues)).toBe(true);
+ expect(manifest.id).toBe("");
+ expect(manifest.requirements).toEqual([]);
+ });
+});
diff --git a/apps/web/src/lib/spec-studio/markdown-parse.ts b/apps/web/src/lib/spec-studio/markdown-parse.ts
new file mode 100644
index 00000000..32e954eb
--- /dev/null
+++ b/apps/web/src/lib/spec-studio/markdown-parse.ts
@@ -0,0 +1,362 @@
+/**
+ * Client-side, best-effort port of `forge_spec.markdown.parse_spec_md` for
+ * Spec Studio's Markdown mode live preview.
+ *
+ * `spec.md` is one of the two first-class, round-tripping serializations of a
+ * `SpecManifest` (the other being `manifest.yaml`; see
+ * `forge_spec.markdown`/`forge_spec.manifest` on the backend). This module
+ * gives the Markdown editor a *fast, offline* structural parse — with
+ * line-anchored errors mirroring the backend's `SpecParseError` — so the
+ * Structure/Preview/Traceability panes and error list update as the user
+ * types. Unlike the backend parser (which raises on the first problem), this
+ * collects *every* issue it can find and always returns a best-effort
+ * `SpecManifest` (defaulting fields it couldn't parse) so the panes stay
+ * useful mid-edit. `PUT /spec/specs/{id}/markdown` remains the authoritative
+ * parser — this never replaces it.
+ */
+
+import { parse as parseYaml } from "yaml";
+
+import type { ADR, AcceptanceCriterion, OpenQuestion, Requirement, SpecManifest, SpecStatus } from "@/lib/api/types";
+
+export type MarkdownIssueSeverity = "error" | "warning";
+
+export interface MarkdownIssue {
+ /** 1-indexed line number the issue anchors to. */
+ line: number;
+ message: string;
+ severity: MarkdownIssueSeverity;
+}
+
+export interface ParsedSpecMarkdown {
+ /** Best-effort manifest — always populated, even alongside issues. */
+ manifest: SpecManifest;
+ issues: MarkdownIssue[];
+}
+
+const H2 = "## ";
+const H3 = "### ";
+const ADR_SEP = " — ";
+
+const BOLD_BULLET = /^- \*\*([^*]+)\*\*:\s?(.*)$/;
+const ACCEPT_BULLET = /^- \*\*([^*]+)\*\*(?: \(([^)]*)\))?:\s?(.*)$/;
+const RESOLUTION = /^ {2}- Resolution:\s?(.*)$/;
+const ADR_FIELD = /^- (Status|Context|Decision|Consequences):\s?(.*)$/;
+
+const ADR_FIELD_ATTR: Record = {
+ Status: "status",
+ Context: "context",
+ Decision: "decision",
+ Consequences: "consequences",
+};
+
+interface Section {
+ title: string;
+ headerLine: number;
+ lines: [number, string][];
+}
+
+function nonBlank(section: Section): [number, string][] {
+ return section.lines.filter(([, text]) => text.trim() !== "");
+}
+
+function splitFrontmatter(
+ lines: string[],
+ issues: MarkdownIssue[],
+): { data: Record; bodyStart: number } {
+ let idx = 0;
+ const n = lines.length;
+ while (idx < n && lines[idx].trim() === "") idx += 1;
+ if (idx >= n || lines[idx].trim() !== "---") {
+ issues.push({
+ line: idx + 1,
+ message: "spec.md must begin with a '---' YAML frontmatter block",
+ severity: "error",
+ });
+ return { data: {}, bodyStart: n };
+ }
+ const openLine = idx + 1;
+ idx += 1;
+ const fmBody: string[] = [];
+ while (idx < n && lines[idx].trim() !== "---") {
+ fmBody.push(lines[idx]);
+ idx += 1;
+ }
+ if (idx >= n) {
+ issues.push({ line: openLine, message: "unterminated frontmatter: missing closing '---'", severity: "error" });
+ return { data: {}, bodyStart: n };
+ }
+ try {
+ const parsed = parseYaml(fmBody.join("\n"));
+ if (parsed == null) return { data: {}, bodyStart: idx + 1 };
+ if (typeof parsed !== "object" || Array.isArray(parsed)) {
+ issues.push({ line: openLine + 1, message: "frontmatter must be a YAML mapping", severity: "error" });
+ return { data: {}, bodyStart: idx + 1 };
+ }
+ return { data: parsed as Record, bodyStart: idx + 1 };
+ } catch (error) {
+ issues.push({
+ line: openLine + 1,
+ message: `invalid YAML frontmatter: ${error instanceof Error ? error.message : String(error)}`,
+ severity: "error",
+ });
+ return { data: {}, bodyStart: idx + 1 };
+ }
+}
+
+function collectSections(lines: string[], start: number, issues: MarkdownIssue[]): Section[] {
+ const sections: Section[] = [];
+ let current: Section | null = null;
+ for (let offset = start; offset < lines.length; offset += 1) {
+ const raw = lines[offset];
+ const lineNo = offset + 1;
+ if (raw.startsWith(H2)) {
+ current = { title: raw.slice(H2.length).trim(), headerLine: lineNo, lines: [] };
+ sections.push(current);
+ continue;
+ }
+ if (current === null) {
+ if (raw.trim() === "") continue;
+ issues.push({ line: lineNo, message: "unexpected content before first '##' section", severity: "error" });
+ continue;
+ }
+ current.lines.push([lineNo, raw]);
+ }
+ return sections;
+}
+
+function parseGoal(section: Section, issues: MarkdownIssue[]): string {
+ const body = section.lines
+ .map(([, text]) => text)
+ .join("\n")
+ .trim();
+ if (!body) {
+ issues.push({ line: section.headerLine, message: "## Goal section is empty", severity: "error" });
+ }
+ return body;
+}
+
+function parseRequirements(section: Section, issues: MarkdownIssue[]): Requirement[] {
+ const out: Requirement[] = [];
+ for (const [lineNo, text] of nonBlank(section)) {
+ const match = BOLD_BULLET.exec(text);
+ if (!match) {
+ issues.push({ line: lineNo, message: "requirement must be '- **ID**: text'", severity: "error" });
+ continue;
+ }
+ out.push({ id: match[1].trim(), text: match[2].trim() });
+ }
+ return out;
+}
+
+function parseRefs(refs: string | undefined): { reqRefs: string[]; specRef: string | null } {
+ if (refs === undefined) return { reqRefs: [], specRef: null };
+ let reqRefs: string[] = [];
+ let specRef: string | null = null;
+ for (const part of refs.split(";")) {
+ const chunk = part.trim();
+ if (!chunk) continue;
+ if (chunk.startsWith("spec=")) {
+ specRef = chunk.slice("spec=".length).trim() || null;
+ } else {
+ reqRefs = chunk
+ .split(",")
+ .map((r) => r.trim())
+ .filter(Boolean);
+ }
+ }
+ return { reqRefs, specRef };
+}
+
+function parseAcceptance(section: Section, issues: MarkdownIssue[]): AcceptanceCriterion[] {
+ const out: AcceptanceCriterion[] = [];
+ for (const [lineNo, text] of nonBlank(section)) {
+ if (text.startsWith(" ")) {
+ // 2-space continuation line — folds into the preceding criterion's text
+ // (e.g. a multi-line checklist criterion's `- [ ] item` entries).
+ if (out.length === 0) {
+ issues.push({ line: lineNo, message: "acceptance continuation line before any criterion", severity: "error" });
+ continue;
+ }
+ const prev = out[out.length - 1];
+ out[out.length - 1] = { ...prev, text: `${prev.text}\n${text.slice(2)}` };
+ continue;
+ }
+ const match = ACCEPT_BULLET.exec(text);
+ if (!match) {
+ issues.push({
+ line: lineNo,
+ message: "acceptance criterion must be '- **ID** (refs): text'",
+ severity: "error",
+ });
+ continue;
+ }
+ const { reqRefs, specRef } = parseRefs(match[2]);
+ out.push({ id: match[1].trim(), text: match[3].trim(), req_refs: reqRefs, spec_ref: specRef });
+ }
+ return out;
+}
+
+function parseConstraints(section: Section, issues: MarkdownIssue[]): string[] {
+ const out: string[] = [];
+ for (const [lineNo, text] of nonBlank(section)) {
+ if (!text.startsWith("- ")) {
+ issues.push({ line: lineNo, message: "constraint must be a '- ' bullet", severity: "error" });
+ continue;
+ }
+ out.push(text.slice(2).trim());
+ }
+ return out;
+}
+
+function parseOpenQuestions(section: Section, issues: MarkdownIssue[]): OpenQuestion[] {
+ const out: OpenQuestion[] = [];
+ for (const [lineNo, text] of nonBlank(section)) {
+ const resolution = RESOLUTION.exec(text);
+ if (resolution) {
+ if (out.length === 0) {
+ issues.push({ line: lineNo, message: "resolution has no preceding open question", severity: "error" });
+ continue;
+ }
+ out[out.length - 1] = { ...out[out.length - 1], resolution: resolution[1].trim() };
+ continue;
+ }
+ const match = BOLD_BULLET.exec(text);
+ if (!match) {
+ issues.push({ line: lineNo, message: "open question must be '- **ID**: text'", severity: "error" });
+ continue;
+ }
+ out.push({ id: match[1].trim(), text: match[2].trim() });
+ }
+ return out;
+}
+
+function parseDecisions(section: Section, issues: MarkdownIssue[]): ADR[] {
+ const out: ADR[] = [];
+ let fields: Record = {};
+ let header: { id: string; title: string } | null = null;
+
+ const flush = () => {
+ if (header === null) return;
+ out.push({ id: header.id, title: header.title, ...fields } as ADR);
+ fields = {};
+ header = null;
+ };
+
+ for (const [lineNo, text] of nonBlank(section)) {
+ if (text.startsWith(H3)) {
+ flush();
+ const body = text.slice(H3.length);
+ if (!body.includes(ADR_SEP)) {
+ issues.push({ line: lineNo, message: "decision heading must be '### ID — Title'", severity: "error" });
+ continue;
+ }
+ const sepIdx = body.indexOf(ADR_SEP);
+ header = { id: body.slice(0, sepIdx).trim(), title: body.slice(sepIdx + ADR_SEP.length).trim() };
+ continue;
+ }
+ const field = ADR_FIELD.exec(text);
+ if (!field) {
+ issues.push({
+ line: lineNo,
+ message: "decision field must be '- Status|Context|Decision|Consequences: text'",
+ severity: "error",
+ });
+ continue;
+ }
+ if (header === null) {
+ issues.push({ line: lineNo, message: "decision field before any '### ID — Title'", severity: "error" });
+ continue;
+ }
+ fields[ADR_FIELD_ATTR[field[1]]] = field[2].trim();
+ }
+ flush();
+ return out;
+}
+
+const FRONTMATTER_STRING_ARRAY_FIELDS = ["constitution_refs", "repos"] as const;
+const FRONTMATTER_NULLABLE_STRING_FIELDS = ["plan_ref", "tasks_ref", "validation_ref", "skill_profile"] as const;
+
+/**
+ * Parse `spec.md` `text` into a best-effort `SpecManifest` plus every
+ * line-anchored issue found. Never throws — a malformed document still
+ * yields a (possibly empty) manifest so callers can keep rendering.
+ */
+export function parseSpecMarkdown(text: string): ParsedSpecMarkdown {
+ const issues: MarkdownIssue[] = [];
+ const lines = text.split("\n");
+ const { data, bodyStart } = splitFrontmatter(lines, issues);
+
+ const id = typeof data.id === "string" ? data.id : "";
+ if (!("id" in data)) {
+ issues.push({ line: 1, message: "frontmatter is missing required key 'id'", severity: "error" });
+ }
+
+ let name: string | null = null;
+ let requirements: Requirement[] = [];
+ let acceptanceCriteria: AcceptanceCriterion[] = [];
+ let constraints: string[] = [];
+ let openQuestions: OpenQuestion[] = [];
+ let decisions: ADR[] = [];
+
+ for (const section of collectSections(lines, bodyStart, issues)) {
+ switch (section.title) {
+ case "Goal":
+ name = parseGoal(section, issues);
+ break;
+ case "Requirements":
+ requirements = parseRequirements(section, issues);
+ break;
+ case "Acceptance Criteria":
+ acceptanceCriteria = parseAcceptance(section, issues);
+ break;
+ case "Constraints":
+ constraints = parseConstraints(section, issues);
+ break;
+ case "Open Questions":
+ openQuestions = parseOpenQuestions(section, issues);
+ break;
+ case "Decisions":
+ decisions = parseDecisions(section, issues);
+ break;
+ default:
+ issues.push({
+ line: section.headerLine,
+ message: `unknown section '## ${section.title}'`,
+ severity: "warning",
+ });
+ }
+ }
+
+ if (name === null) {
+ issues.push({ line: 1, message: "spec.md is missing a '## Goal' section (the spec name)", severity: "error" });
+ }
+
+ const manifest: SpecManifest = {
+ id,
+ name: name ?? "",
+ requirements,
+ acceptance_criteria: acceptanceCriteria,
+ constraints,
+ open_questions: openQuestions,
+ decisions,
+ };
+ if (typeof data.status === "string") manifest.status = data.status as SpecStatus;
+ if (typeof data.execution_mode === "string") {
+ manifest.execution_mode = data.execution_mode as SpecManifest["execution_mode"];
+ }
+ for (const field of FRONTMATTER_STRING_ARRAY_FIELDS) {
+ if (Array.isArray(data[field])) manifest[field] = data[field] as string[];
+ }
+ for (const field of FRONTMATTER_NULLABLE_STRING_FIELDS) {
+ if (typeof data[field] === "string" || data[field] === null) {
+ manifest[field] = data[field] as string | null;
+ }
+ }
+
+ return { manifest, issues };
+}
+
+export function hasMarkdownErrors(issues: MarkdownIssue[]): boolean {
+ return issues.some((issue) => issue.severity === "error");
+}
diff --git a/apps/web/src/lib/spec-studio/templates.test.ts b/apps/web/src/lib/spec-studio/templates.test.ts
new file mode 100644
index 00000000..0cd51079
--- /dev/null
+++ b/apps/web/src/lib/spec-studio/templates.test.ts
@@ -0,0 +1,73 @@
+import { describe, expect, it } from "vitest";
+
+import type { SpecManifest } from "@/lib/api/types";
+
+import { SPEC_TEMPLATES, applySpecTemplate, specTemplate } from "./templates";
+
+describe("SPEC_TEMPLATES", () => {
+ it("exposes exactly the feature/bugfix/spike starter templates", () => {
+ expect(SPEC_TEMPLATES.map((t) => t.id)).toEqual(["feature", "bugfix", "spike"]);
+ });
+
+ it("each template seeds at least one requirement and one linked acceptance criterion", () => {
+ for (const template of SPEC_TEMPLATES) {
+ expect(template.requirements.length).toBeGreaterThan(0);
+ expect(template.acceptanceCriteria.length).toBeGreaterThan(0);
+ for (const ac of template.acceptanceCriteria) {
+ expect(ac.req_refs?.length ?? 0).toBeGreaterThan(0);
+ }
+ }
+ });
+});
+
+describe("specTemplate", () => {
+ it("looks a template up by id", () => {
+ expect(specTemplate("bugfix").label).toBe("Bugfix");
+ });
+
+ it("throws on an unknown id", () => {
+ // @ts-expect-error deliberate bad id for the runtime guard
+ expect(() => specTemplate("nope")).toThrow(/Unknown spec template/);
+ });
+});
+
+describe("applySpecTemplate", () => {
+ const blank: SpecManifest = { id: "", name: "" };
+
+ it("seeds requirements, acceptance criteria and constraints from the template", () => {
+ const seeded = applySpecTemplate("feature", blank);
+ expect(seeded.requirements).toEqual(specTemplate("feature").requirements);
+ expect(seeded.acceptance_criteria).toEqual(specTemplate("feature").acceptanceCriteria);
+ expect(seeded.constraints).toEqual([]);
+ });
+
+ it("seeds bugfix constraints", () => {
+ const seeded = applySpecTemplate("bugfix", blank);
+ expect(seeded.constraints).toEqual(specTemplate("bugfix").constraints);
+ });
+
+ it("never clobbers requirements the author already drafted", () => {
+ const drafted: SpecManifest = {
+ id: "",
+ name: "My spec",
+ requirements: [{ id: "R1", text: "Already written" }],
+ };
+ const seeded = applySpecTemplate("feature", drafted);
+ expect(seeded.requirements).toEqual(drafted.requirements);
+ // Untouched fields still get seeded.
+ expect(seeded.acceptance_criteria).toEqual(specTemplate("feature").acceptanceCriteria);
+ });
+
+ it("preserves the name and any other existing draft fields", () => {
+ const drafted: SpecManifest = { id: "", name: "My spec", execution_mode: "single_agent" };
+ const seeded = applySpecTemplate("spike", drafted);
+ expect(seeded.name).toBe("My spec");
+ expect(seeded.execution_mode).toBe("single_agent");
+ });
+
+ it("returns fresh arrays, not the template's own arrays (no shared mutation)", () => {
+ const seeded = applySpecTemplate("feature", blank);
+ seeded.requirements!.push({ id: "R2", text: "mutated" });
+ expect(specTemplate("feature").requirements).toHaveLength(1);
+ });
+});
diff --git a/apps/web/src/lib/spec-studio/templates.ts b/apps/web/src/lib/spec-studio/templates.ts
new file mode 100644
index 00000000..4cce21df
--- /dev/null
+++ b/apps/web/src/lib/spec-studio/templates.ts
@@ -0,0 +1,101 @@
+/**
+ * Starter templates for `/specs/new` (ss-entry). Each seeds a skeleton
+ * requirement + acceptance criterion (and, for bugfix/spike, a constraint)
+ * onto a fresh draft manifest — a starting shape for the Guided-mode form,
+ * not a locked-in answer. Applying a template never clobbers anything the
+ * author has already typed into requirements / acceptance criteria /
+ * constraints; it only fills in what's still empty.
+ */
+
+import type { AcceptanceCriterion, Requirement, SpecManifest } from "@/lib/api/types";
+
+export type SpecTemplateId = "feature" | "bugfix" | "spike";
+
+export interface SpecTemplateSeed {
+ id: SpecTemplateId;
+ label: string;
+ description: string;
+ requirements: Requirement[];
+ acceptanceCriteria: AcceptanceCriterion[];
+ constraints: string[];
+}
+
+export const SPEC_TEMPLATES: readonly SpecTemplateSeed[] = [
+ {
+ id: "feature",
+ label: "Feature",
+ description: "A new capability end-to-end, from requirement to acceptance criteria.",
+ requirements: [{ id: "R1", text: "Describe the new capability the user gains." }],
+ acceptanceCriteria: [
+ {
+ id: "AC1",
+ text: "Given , when , then .",
+ req_refs: ["R1"],
+ },
+ ],
+ constraints: [],
+ },
+ {
+ id: "bugfix",
+ label: "Bugfix",
+ description: "Pin down a regression with a reproducing case and the expected behavior.",
+ requirements: [
+ { id: "R1", text: "Describe the incorrect behavior and the behavior expected instead." },
+ ],
+ acceptanceCriteria: [
+ {
+ id: "AC1",
+ text:
+ "Given the steps that reproduce the bug, when they're applied, then the expected behavior occurs (a regression test is added).",
+ req_refs: ["R1"],
+ },
+ ],
+ constraints: ["Scoped to the regression — no unrelated behavior changes."],
+ },
+ {
+ id: "spike",
+ label: "Spike",
+ description: "A time-boxed investigation that answers an open question before committing.",
+ requirements: [{ id: "R1", text: "State the question this spike must answer." }],
+ acceptanceCriteria: [
+ {
+ id: "AC1",
+ text:
+ "Given the investigation is complete, when findings are written up, then a recommended approach and its tradeoffs are documented.",
+ req_refs: ["R1"],
+ },
+ ],
+ constraints: ["Time-boxed — produces a decision/recommendation, not production code."],
+ },
+];
+
+export function specTemplate(id: SpecTemplateId): SpecTemplateSeed {
+ const found = SPEC_TEMPLATES.find((t) => t.id === id);
+ if (!found) {
+ throw new Error(`Unknown spec template: ${id}`);
+ }
+ return found;
+}
+
+/**
+ * Seed `draft` with `templateId`'s starter requirement/acceptance
+ * criterion/constraints. Fields the author already populated are left
+ * untouched — a template only fills in what's still empty, so switching
+ * templates (or picking one after typing) never destroys drafted work.
+ */
+export function applySpecTemplate(
+ templateId: SpecTemplateId,
+ draft: SpecManifest,
+): SpecManifest {
+ const template = specTemplate(templateId);
+ return {
+ ...draft,
+ requirements: draft.requirements?.length
+ ? draft.requirements
+ : template.requirements.map((r) => ({ ...r })),
+ acceptance_criteria: draft.acceptance_criteria?.length
+ ? draft.acceptance_criteria
+ : template.acceptanceCriteria.map((a) => ({ ...a })),
+ constraints: draft.constraints?.length ? draft.constraints : [...template.constraints],
+ };
+}
diff --git a/apps/web/src/lib/spec-studio/yaml-schema.test.ts b/apps/web/src/lib/spec-studio/yaml-schema.test.ts
new file mode 100644
index 00000000..cbc9a4bb
--- /dev/null
+++ b/apps/web/src/lib/spec-studio/yaml-schema.test.ts
@@ -0,0 +1,93 @@
+import { describe, expect, it } from "vitest";
+
+import { hasErrors, validateManifestYaml } from "./yaml-schema";
+
+const VALID_MANIFEST = `id: SPEC-1
+name: Passwordless auth
+status: draft
+constitution_refs: []
+repos: []
+requirements:
+ - id: R1
+ text: Users can sign in without a password
+acceptance_criteria:
+ - id: AC1
+ text: Given a valid magic link, when clicked, then the user is signed in
+ req_refs: [R1]
+constraints: []
+open_questions: []
+decisions: []
+execution_mode: single_agent
+skill_profile: null
+plan_ref: null
+tasks_ref: null
+validation_ref: null
+`;
+
+describe("validateManifestYaml", () => {
+ it("has no issues for a fully valid manifest", () => {
+ expect(validateManifestYaml(VALID_MANIFEST)).toEqual([]);
+ });
+
+ it("flags an empty document", () => {
+ const issues = validateManifestYaml(" \n");
+ expect(hasErrors(issues)).toBe(true);
+ expect(issues[0].message).toMatch(/empty/i);
+ });
+
+ it("line-anchors a YAML syntax error", () => {
+ const text = `id: SPEC-1\nname: [unterminated\n`;
+ const issues = validateManifestYaml(text);
+ expect(hasErrors(issues)).toBe(true);
+ expect(issues[0].line).toBeGreaterThanOrEqual(2);
+ });
+
+ it("requires 'id' and 'name'", () => {
+ const issues = validateManifestYaml("status: draft\n");
+ const messages = issues.map((i) => i.message);
+ expect(messages.some((m) => /'id'/.test(m))).toBe(true);
+ expect(messages.some((m) => /'name'/.test(m))).toBe(true);
+ });
+
+ it("rejects an invalid status value with a line number", () => {
+ const text = `id: SPEC-1\nname: X\nstatus: not-a-status\n`;
+ const issues = validateManifestYaml(text);
+ const statusIssue = issues.find((i) => /status/.test(i.message));
+ expect(statusIssue).toBeDefined();
+ expect(statusIssue?.line).toBe(3);
+ });
+
+ it("rejects an invalid execution_mode", () => {
+ const text = `id: SPEC-1\nname: X\nexecution_mode: yolo\n`;
+ const issues = validateManifestYaml(text);
+ expect(issues.some((i) => /execution_mode/.test(i.message))).toBe(true);
+ });
+
+ it("flags a requirement missing 'text' with a line-anchored error", () => {
+ const text = `id: SPEC-1\nname: X\nrequirements:\n - id: R1\n`;
+ const issues = validateManifestYaml(text);
+ const issue = issues.find((i) => /requirements\[0\]/.test(i.message));
+ expect(issue).toBeDefined();
+ expect(issue?.line).toBe(4);
+ });
+
+ it("flags requirements that isn't a list", () => {
+ const text = `id: SPEC-1\nname: X\nrequirements: not-a-list\n`;
+ const issues = validateManifestYaml(text);
+ expect(issues.some((i) => /'requirements' must be a list/.test(i.message))).toBe(true);
+ });
+
+ it("warns when an acceptance criterion references an unknown requirement", () => {
+ const text = `id: SPEC-1\nname: X\nrequirements:\n - id: R1\n text: A\nacceptance_criteria:\n - id: AC1\n text: B\n req_refs: [R9]\n`;
+ const issues = validateManifestYaml(text);
+ const warning = issues.find((i) => /unknown requirement/.test(i.message));
+ expect(warning).toBeDefined();
+ expect(warning?.severity).toBe("warning");
+ });
+
+ it("flags a non-string entry in a string-array field", () => {
+ const text = `id: SPEC-1\nname: X\nconstraints:\n - 42\n`;
+ const issues = validateManifestYaml(text);
+ expect(issues.some((i) => /constraints\[0\]/.test(i.message))).toBe(true);
+ });
+});
diff --git a/apps/web/src/lib/spec-studio/yaml-schema.ts b/apps/web/src/lib/spec-studio/yaml-schema.ts
new file mode 100644
index 00000000..21934df2
--- /dev/null
+++ b/apps/web/src/lib/spec-studio/yaml-schema.ts
@@ -0,0 +1,299 @@
+/**
+ * Schema-aware validation for the Spec Studio YAML manifest mode.
+ *
+ * `manifest.yaml` is one of the two first-class, round-tripping
+ * serializations of a `SpecManifest` (the other being `spec.md`; see
+ * `forge_spec.markdown`/`forge_spec.manifest` on the backend). This module
+ * gives the YAML editor *client-side* structural + shape validation with
+ * line-anchored errors, so a mistyped or malformed manifest is caught before
+ * it ever reaches `PUT /spec/specs/{id}/manifest` — the backend
+ * (`forge_spec.FileSpecEngine.save_manifest_yaml`) remains the authoritative
+ * parser; this is a fast, offline first pass mirroring its shape.
+ */
+
+import { isMap, isPair, isScalar, isSeq, parseDocument, type Document, type ParsedNode } from "yaml";
+
+import { SPEC_STATUSES, type SpecStatus } from "@/lib/api/types";
+
+export type YamlIssueSeverity = "error" | "warning";
+
+export interface YamlIssue {
+ /** 1-indexed line number the issue anchors to. */
+ line: number;
+ /** 1-indexed column, when known. */
+ column?: number;
+ message: string;
+ severity: YamlIssueSeverity;
+}
+
+const EXECUTION_MODES = ["single_agent", "supervised_multi_agent"] as const;
+
+const STRING_ARRAY_FIELDS = ["constitution_refs", "repos", "constraints"] as const;
+const NULLABLE_STRING_FIELDS = ["plan_ref", "tasks_ref", "validation_ref", "skill_profile"] as const;
+
+/** Required scalar id/text shape shared by requirements, ACs, questions, ADRs. */
+function offsetToLine(text: string, offset: number): { line: number; column: number } {
+ let line = 1;
+ let lastNewline = -1;
+ for (let i = 0; i < offset && i < text.length; i += 1) {
+ if (text[i] === "\n") {
+ line += 1;
+ lastNewline = i;
+ }
+ }
+ return { line, column: offset - lastNewline };
+}
+
+function nodeStart(node: ParsedNode | null | undefined): number | undefined {
+ return node?.range ? node.range[0] : undefined;
+}
+
+/** Resolve the item at `path` (dot/bracket-free, `[key, index, key]`) within the YAML AST. */
+function resolveNode(doc: Document.Parsed, path: (string | number)[]): ParsedNode | null {
+ const node = doc.getIn(path, true);
+ if (isPair(node)) {
+ return (node.value as ParsedNode | null) ?? (node.key as ParsedNode | null);
+ }
+ return (node as ParsedNode | null) ?? null;
+}
+
+function pushIssue(
+ issues: YamlIssue[],
+ text: string,
+ node: ParsedNode | null | undefined,
+ message: string,
+ severity: YamlIssueSeverity = "error",
+): void {
+ const offset = nodeStart(node);
+ const { line, column } = offset !== undefined ? offsetToLine(text, offset) : { line: 1, column: 1 };
+ issues.push({ line, column, message, severity });
+}
+
+function checkStringList(
+ doc: Document.Parsed,
+ text: string,
+ key: string,
+ issues: YamlIssue[],
+): void {
+ const node = resolveNode(doc, [key]);
+ if (node == null) return;
+ if (!isSeq(node)) {
+ pushIssue(issues, text, node, `'${key}' must be a list of strings`);
+ return;
+ }
+ node.items.forEach((item, index) => {
+ const itemNode = item as ParsedNode;
+ if (!isScalar(itemNode) || typeof itemNode.value !== "string") {
+ pushIssue(issues, text, itemNode, `${key}[${index}] must be a string`);
+ }
+ });
+}
+
+interface ItemFieldSpec {
+ key: string;
+ required: boolean;
+ kind: "string" | "string-array";
+}
+
+function checkItemList(
+ doc: Document.Parsed,
+ text: string,
+ key: string,
+ fields: ItemFieldSpec[],
+ issues: YamlIssue[],
+ knownIds?: Set,
+ collectIds?: Set,
+): void {
+ const node = resolveNode(doc, [key]);
+ if (node == null) return;
+ if (!isSeq(node)) {
+ pushIssue(issues, text, node, `'${key}' must be a list`);
+ return;
+ }
+ node.items.forEach((item, index) => {
+ const itemNode = item as ParsedNode;
+ if (!isMap(itemNode)) {
+ pushIssue(issues, text, itemNode, `${key}[${index}] must be a mapping`);
+ return;
+ }
+ for (const field of fields) {
+ const fieldNode = resolveNode(doc, [key, index, field.key]);
+ if (fieldNode == null) {
+ if (field.required) {
+ pushIssue(issues, text, itemNode, `${key}[${index}] is missing required field '${field.key}'`);
+ }
+ continue;
+ }
+ if (field.kind === "string") {
+ if (!isScalar(fieldNode) || typeof fieldNode.value !== "string" || fieldNode.value === "") {
+ pushIssue(issues, text, fieldNode, `${key}[${index}].${field.key} must be a non-empty string`);
+ } else if (field.key === "id" && collectIds) {
+ collectIds.add(String(fieldNode.value));
+ }
+ } else if (field.kind === "string-array") {
+ if (!isSeq(fieldNode)) {
+ pushIssue(issues, text, fieldNode, `${key}[${index}].${field.key} must be a list of strings`);
+ } else if (knownIds) {
+ fieldNode.items.forEach((refItem) => {
+ const refNode = refItem as ParsedNode;
+ if (isScalar(refNode) && typeof refNode.value === "string" && !knownIds.has(refNode.value)) {
+ pushIssue(
+ issues,
+ text,
+ refNode,
+ `${key}[${index}].${field.key} references unknown requirement '${refNode.value}'`,
+ "warning",
+ );
+ }
+ });
+ }
+ }
+ }
+ });
+}
+
+/**
+ * Validate `manifest.yaml` text against the `SpecManifest` shape.
+ *
+ * Returns parse errors first (line-anchored, from the YAML parser itself),
+ * then structural/shape issues once the document parses. Empty on a fully
+ * valid manifest.
+ */
+export function validateManifestYaml(text: string): YamlIssue[] {
+ const issues: YamlIssue[] = [];
+ if (text.trim() === "") {
+ return [{ line: 1, message: "Manifest is empty", severity: "error" }];
+ }
+
+ const doc = parseDocument(text);
+
+ for (const err of doc.errors) {
+ const pos = err.linePos?.[0];
+ issues.push({
+ line: pos?.line ?? 1,
+ column: pos?.col,
+ message: err.message,
+ severity: "error",
+ });
+ }
+ for (const warn of doc.warnings) {
+ const pos = warn.linePos?.[0];
+ issues.push({
+ line: pos?.line ?? 1,
+ column: pos?.col,
+ message: warn.message,
+ severity: "warning",
+ });
+ }
+ if (doc.errors.length > 0) {
+ return issues;
+ }
+
+ const root = doc.contents;
+ if (root == null || !isMap(root)) {
+ issues.push({ line: 1, message: "Manifest must be a YAML mapping (object)", severity: "error" });
+ return issues;
+ }
+
+ const idNode = resolveNode(doc, ["id"]);
+ if (idNode == null || !isScalar(idNode) || typeof idNode.value !== "string" || idNode.value === "") {
+ pushIssue(issues, text, idNode ?? root, "'id' is required and must be a non-empty string");
+ }
+
+ const nameNode = resolveNode(doc, ["name"]);
+ if (nameNode == null || !isScalar(nameNode) || typeof nameNode.value !== "string" || nameNode.value === "") {
+ pushIssue(issues, text, nameNode ?? root, "'name' is required and must be a non-empty string");
+ }
+
+ const statusNode = resolveNode(doc, ["status"]);
+ if (statusNode != null) {
+ const value = isScalar(statusNode) ? statusNode.value : undefined;
+ if (typeof value !== "string" || !SPEC_STATUSES.includes(value as SpecStatus)) {
+ pushIssue(
+ issues,
+ text,
+ statusNode,
+ `'status' must be one of: ${SPEC_STATUSES.join(", ")}`,
+ );
+ }
+ }
+
+ const executionModeNode = resolveNode(doc, ["execution_mode"]);
+ if (executionModeNode != null) {
+ const value = isScalar(executionModeNode) ? executionModeNode.value : undefined;
+ if (typeof value !== "string" || !EXECUTION_MODES.includes(value as (typeof EXECUTION_MODES)[number])) {
+ pushIssue(
+ issues,
+ text,
+ executionModeNode,
+ `'execution_mode' must be one of: ${EXECUTION_MODES.join(", ")}`,
+ );
+ }
+ }
+
+ for (const field of NULLABLE_STRING_FIELDS) {
+ const node = resolveNode(doc, [field]);
+ if (node != null && (!isScalar(node) || (node.value !== null && typeof node.value !== "string"))) {
+ pushIssue(issues, text, node, `'${field}' must be a string or null`);
+ }
+ }
+
+ for (const field of STRING_ARRAY_FIELDS) {
+ checkStringList(doc, text, field, issues);
+ }
+
+ const requirementIds = new Set();
+ checkItemList(
+ doc,
+ text,
+ "requirements",
+ [
+ { key: "id", required: true, kind: "string" },
+ { key: "text", required: true, kind: "string" },
+ ],
+ issues,
+ undefined,
+ requirementIds,
+ );
+
+ checkItemList(
+ doc,
+ text,
+ "acceptance_criteria",
+ [
+ { key: "id", required: true, kind: "string" },
+ { key: "text", required: true, kind: "string" },
+ { key: "req_refs", required: false, kind: "string-array" },
+ ],
+ issues,
+ requirementIds,
+ );
+
+ checkItemList(
+ doc,
+ text,
+ "open_questions",
+ [
+ { key: "id", required: true, kind: "string" },
+ { key: "text", required: true, kind: "string" },
+ ],
+ issues,
+ );
+
+ checkItemList(
+ doc,
+ text,
+ "decisions",
+ [
+ { key: "id", required: true, kind: "string" },
+ { key: "title", required: true, kind: "string" },
+ ],
+ issues,
+ );
+
+ return issues;
+}
+
+export function hasErrors(issues: YamlIssue[]): boolean {
+ return issues.some((issue) => issue.severity === "error");
+}
diff --git a/docs/ADAPTIVE_SPEC_PROGRESS.md b/docs/ADAPTIVE_SPEC_PROGRESS.md
index 7257547c..30975498 100644
--- a/docs/ADAPTIVE_SPEC_PROGRESS.md
+++ b/docs/ADAPTIVE_SPEC_PROGRESS.md
@@ -7,12 +7,17 @@ green gate (ruff + ruff-format + mypy + full pytest on real pgvector + bandit +
gitleaks + web lint/build/test/typecheck).
**Phase 1 — Adaptive Orchestration: shipped, all six slices committed to
-`main`.** **Phase 2 — Spec Studio (dual-format `spec.md` round-trip,
-BYOK spec draft, real-time co-editing): not yet started** — design-approved
-and written up in `docs/spec-studio/DESIGN.md` for the next build phase.
+`main`.** **Phase 2 — Spec Studio: shipped, all fourteen slices committed to
+`main`** — dual-format `spec.md`↔`manifest.yaml` round-trip, the five-mode
+Spec Studio web editor (Guided/Markdown/YAML/Read/History), BYOK AI drafting,
+external import, acceptance-criterion styles, and version history + diff all
+landed. **Real-time co-editing is still design-only** (Yjs chosen, nothing
+wired) — see "What's parked" below. Full design: `docs/spec-studio/DESIGN.md`.
## Slice ledger
+### Phase 1 — Adaptive Orchestration
+
| id | phase | refuted | repaired | decision | commit |
|---|---|---|---|---|---|
| ao-config | Adaptive Orchestration | 0 | no | committed | `ba33d6d` |
@@ -37,6 +42,39 @@ at 3863 tests before landing.
**Committed:** all six. **Reverted:** none.
+### Phase 2 — Spec Studio
+
+| id | refuted | repaired | decision | commit | parked |
+|---|---|---|---|---|---|
+| ss-parser | 0 | no | committed | `19d165a` | — |
+| ss-engine | 1 | no | committed | `dee72e1` | — |
+| ss-endpoints | 3 | yes | committed | `26c938f` | — |
+| ss-yaml | 1 | no | committed | `899a5e9` | — |
+| ss-draft | 3 | yes | committed | `bec9813` | — |
+| ss-guided | 2 | yes | committed | `13ae1ce` | — |
+| ss-markdown | 1 | no | committed | `08af69e` | — |
+| ss-read | 1 | no | committed | `d8bfb51` | reject/request-changes have no backend persistence |
+| ss-lifecycle | 2 | yes | committed | `a9f381f` | kubeconform network tests (pre-existing, unrelated) |
+| ss-ai-panel | 2 | yes | committed | `777e51f` | — |
+| ss-entry | 1 | no | committed | `c081a3c` | — |
+| ss-versioning | 2 | yes | committed | `24f5601` | final full-repo pytest confirmation |
+| ss-import | 2 | yes | committed | `7f1bebc` | final full-repo pytest confirmation |
+| ss-criteria | 0 | no | committed | `804a840` | — |
+
+`refuted`/`repaired` are taken verbatim from each slice's own completion
+report at build time (21 findings raised across the 14 slices; 7 slices had
+at least one repaired before commit — `ss-endpoints`, `ss-draft`,
+`ss-guided`, `ss-lifecycle`, `ss-ai-panel`, `ss-versioning`, `ss-import` — the
+other 7 had findings investigated and held as not requiring a change, or none
+raised). **Committed:** all fourteen. **Reverted:** none. The two
+"final full-repo pytest confirmation" parked items (`ss-versioning`,
+`ss-import`) asked for an uncontaminated full-suite rerun after a background
+run hadn't finished before their own report was due — that rerun is the one
+recorded in "Gate confirmation" below, performed for *this* report with
+nothing else concurrently touching the test database; see that section for
+the result. The `ss-read` and `ss-lifecycle` parked items are unresolved by
+design/scope respectively — carried forward, see "What's parked" below.
+
## What shipped — Adaptive Orchestration
A policy sizes a task/spec into `{tier: junior|medior|senior, strategy:
@@ -146,97 +184,222 @@ path. The `ao-observability` slice's schema/API/dashboard are ready to receive
that wires `ExecutionPlan.for_role(...).tier`/`.strategy` into the
`ModelUsage` built at each model-client call site.
-## What's parked — Spec Studio (not yet started)
-
-Design is written up in full in `docs/spec-studio/DESIGN.md`; nothing in this
-section has code yet. Repo-evidence check performed for this report: no
-`parse_spec_md`, no `POST /spec/draft` route/schema/service, no
-`spec-studio`-named web component, and no websocket/CRDT dependency exist
-anywhere in the tree (`git log --all` has no `spec-studio`, `co-editing`, or
-`websocket` commit either — this phase has not begun on any branch).
-
-- **Dual-format spec authoring** — `manifest.yaml` round-trip
- (`dump_manifest`/`load_manifest`) is shipped and has been for several
- phases; `spec.md` rendering (`render_spec_md`) is shipped but **one-way**
- (manifest → markdown only, and without the frontmatter/`## Goal`/
- Given-When-Then/`## Decisions` shape the approved design calls for).
- `parse_spec_md` (markdown → manifest) does not exist, so editing `spec.md`
- today does not update the canonical `SpecManifest` or re-render
- `manifest.yaml`. Unblock: the `spec-md-roundtrip` slice in
- `docs/spec-studio/DESIGN.md` §2.2.
-- **`POST /spec/draft` (BYOK AI draft)** — no route, schema, or service
- exists. Unblock: the `spec-draft-api` slice (§2.2), which resolves the
- `spec_author` role through the Adaptive Orchestration router built above
- and streams through the existing BYOK `ModelClient` (mocked in tests, per
- the approved design — no live key in CI).
-- **Spec Studio web UI** — `apps/web` has a read-only validation dashboard
- (`components/spec/spec-dashboard.tsx`) but no editor. Unblock:
- `spec-studio-ui` (§2.2).
-- **Real-time co-editing** — no `/ws` route, no CRDT/OT dependency anywhere
- in `apps/api` or `apps/web`. **Library choice (design decision, not yet
- wired): Yjs** — CRDT, no central sequencing server, mature markdown/
- text-editor bindings, zero-runtime-dep core; rejected Automerge (heavier
- WASM payload for this use case), Operational Transform (needs a central
- sequencing server that conflicts with the stateless API/worker split and
- with an agent editing the same file outside the OT server's view), and
- vendor real-time services (external network dependency incompatible with
- the self-hosted/BYOK deploy story). Full rationale and the relay-transport
+## What shipped — Spec Studio
+
+The dual-format design (`SpecManifest` canonical, `spec.md`/`manifest.yaml`
+both first-class editable views) is now fully wired end-to-end, plus the web
+editor, BYOK drafting, external import, criterion styles, and version
+history:
+
+- **`ss-parser`** (`19d165a`) — `forge_spec.markdown.parse_spec_md`: parses
+ the frontmatter + `## Goal`/`## Requirements`/`## Acceptance Criteria`
+ (Given/When/Then)/`## Constraints`/`## Open Questions`/`## Decisions`
+ shape back into a `SpecManifest`, completing the round-trip the design
+ called for (`render_spec_md` already emitted this shape). `SpecParseError`
+ reports malformed input.
+- **`ss-engine`** (`dee72e1`) — `FileSpecEngine` gains `save_spec_md`/
+ `read_spec_md`/`save_manifest_yaml`/`read_manifest_yaml`: editing either
+ serialization parses it back to a `SpecManifest`, then re-renders and
+ writes *both* files from that single manifest, so `spec.md` and
+ `manifest.yaml` never drift apart. Legacy manifest-only specs still load.
+- **`ss-endpoints`** (`26c938f`) — `apps/api` `GET/PUT /spec/specs/{id}` (raw
+ manifest), `GET/PUT /spec/specs/{id}/markdown`, and
+ `GET/PUT /spec/specs/{id}/manifest` — the HTTP surface for
+ create/edit-from-either-format, plus a typed web API client
+ (`apps/web/src/lib/api/client.ts`).
+- **`ss-yaml`** (`899a5e9`) — the first cut of the Spec Studio web component
+ (`apps/web/src/components/spec-studio`): a mode-switching shell plus the
+ YAML editor mode with client-side schema validation
+ (`lib/spec-studio/yaml-schema.ts`).
+- **`ss-draft`** (`bec9813`) — `POST /spec/draft`: resolves the
+ `spec_author` role through the Adaptive Orchestration model router built
+ in Phase 1, streams a constitution-seeded draft through the BYOK
+ `ModelClient`, and returns a parsed `SpecManifest` preview plus token/cost
+ accounting. Draft-only — nothing is persisted until a human saves it
+ through the normal editing endpoints. `ModelClient` is mocked in tests.
+- **`ss-guided`** (`13ae1ce`) — the Guided-mode form editor (structured
+ requirement/AC/constraint fields, no raw text), `/specs/new` and
+ `/specs/{id}` pages, and the `spec-studio-page` wrapper that wires the
+ editor to a real spec id.
+- **`ss-markdown`** (`08af69e`) — the Markdown editor mode plus
+ `lib/spec-studio/markdown-parse.ts` (the web-side mirror of
+ `parse_spec_md`, used for client-side live preview/validation before the
+ save round-trips through the API).
+- **`ss-read`** (`d8bfb51`) — the Read mode: a rendered, non-editable view
+ of a spec with a keyboard-driven approval gate (`a`/`x`/`r` for
+ approve/reject/request-changes + a note). Approve calls the real
+ `POST /spec/specs/{id}/approve`; reject/request-changes are recorded
+ locally only (see "What's parked").
+- **`ss-lifecycle`** (`a9f381f`) — replaced `lifecycle-rail` with
+ `lifecycle-stepper`, a clearer draft→clarified→planned→approved status
+ stepper wired into `spec-dashboard` and the Spec Studio page header.
+- **`ss-ai-panel`** (`777e51f`) — the `AiDraftPanel`: a streaming "typing"
+ reveal of a `POST /spec/draft` response with an accept action that seeds
+ the Guided/Markdown editor from the draft, wired into `/specs/new`.
+- **`ss-entry`** (`c081a3c`) — the `/specs/new` entry flow: choose an epic
+ (or create one inline), pick a starter template (feature/bugfix/spike —
+ `lib/spec-studio/templates.ts`) or start from an AI draft, then land in
+ Guided mode with the seed applied.
+- **`ss-versioning`** (`24f5601`) — `spec_version` table (migration `0032`,
+ additive/reversible): every save through the editing endpoints records an
+ immutable snapshot (manifest + both serializations). `GET
+ /spec/specs/{id}/versions`, `.../versions/{n}`, and
+ `.../versions/{from}/diff/{to}` (line-level markdown diff +
+ id-keyed structured manifest diff, `forge_spec.diff`) back the web
+ `VersionHistory` panel.
+- **`ss-import`** (`7f1bebc`) — `POST /spec/import`: turns an
+ externally-authored markdown or YAML spec into a `spec.md` draft via
+ direct parse → best-effort normalize → graceful-failure-with-`parse_error`
+ fallback, so existing docs can enter the SDD lifecycle without retyping.
+ Draft-only, same contract as `ss-draft`.
+- **`ss-criteria`** (`804a840`) — `forge_spec.criteria`: acceptance criteria
+ can be authored in three styles — Gherkin (Given/When/Then, the default),
+ a plain declarative assertion, or a `- [ ]`/`- [x]` checklist — all
+ encoded losslessly in the existing `AcceptanceCriterion.text` field
+ (style is derived via `classify_criterion`, never stored, so the frozen
+ contract and `req_refs` linking are untouched). Wired into Guided mode and
+ `spec.md` rendering/parsing.
+
+Net result: a spec can be created from scratch, a template, an AI draft, or
+an external import; edited in Guided, Markdown, or YAML mode with both files
+always in sync; reviewed in Read mode; approved through the real gate; and
+every save is a recoverable, diffable version.
+
+### Known limitation, by design (not a gap)
+
+`POST /spec/draft` and `POST /spec/import` are both **draft-only** — neither
+persists anything. This matches the approved design exactly (a human always
+refines and explicitly saves through the normal spec-editing endpoints), not
+an oversight.
+
+## What's parked — Spec Studio
+
+- **Reject / Request-changes have no backend persistence.** Read mode's
+ approval gate is fully keyboard-driven (`a`/`x`/`r`) and calls optional
+ `onReject`/`onRequestChanges` callbacks, but records the decision + note
+ only in the browser — `forge_spec.FileSpecEngine` exposes `approve_spec`
+ (wired to the real `POST /spec/specs/{id}/approve`) with no
+ `reject_spec`/`request_changes` counterpart, and the frozen `SpecStatus`
+ enum has no such values. Unblock: add
+ `POST /spec/specs/{id}/reject` and `.../request-changes` to
+ `forge_spec/engine.py` + `apps/api/forge_api/routers/spec.py` (mirroring
+ `approve_spec`), or wire the existing F36 `/approvals` generic gate
+ (`gate_type='spec'`) once `ApprovalSummary`/`ApprovalRequest` gain a way to
+ resolve the pending gate for a given `spec_id`.
+- **Real-time co-editing — still design-only, nothing wired.** Repo-evidence
+ check performed for this report: no `yjs`/`y-websocket` dependency in
+ `apps/web/package.json`, no `/ws` route or CRDT/OT dependency anywhere in
+ `apps/api`. **Library choice (design decision, unchanged from Phase 1):
+ Yjs** — CRDT, no central sequencing server, mature markdown/text-editor
+ bindings, zero-runtime-dep core; rejected Automerge (heavier WASM payload
+ for this use case), Operational Transform (needs a central sequencing
+ server that conflicts with the stateless API/worker split and with an
+ agent editing the same file outside the OT server's view), and vendor
+ real-time services (external network dependency incompatible with the
+ self-hosted/BYOK deploy story). Full rationale and the relay-transport
shape: `docs/spec-studio/DESIGN.md` §4. This is the same deferred `/ws`
websocket noted in `docs/MORNING_SUMMARY-2026-07-08.md` as the "`rt-ws`
- real-time slice" — one relay substrate serves both that public-readiness
- item and Spec Studio co-editing. Unblock: `spec-studio-realtime` (§2.2),
- sequenced after `spec-md-roundtrip` and `spec-studio-ui` exist to co-edit.
+ real-time slice" — one relay substrate would serve both that
+ public-readiness item and Spec Studio co-editing. Unblock:
+ `spec-studio-realtime` (`docs/spec-studio/DESIGN.md` §2.2) — the editor it
+ co-edits (Guided/Markdown/YAML modes, `parse_spec_md` round-trip) now
+ exists, so this slice is unblocked and ready to start.
+- **Historical note, now resolved for this environment** (carried from
+ `ss-lifecycle`'s own report): that report saw
+ `deploy/helm/tests/test_render_contract.py::test_kubeconform_conformance[...]`
+ fail in its sandbox for lack of network access to fetch k8s JSON schemas.
+ Re-run explicitly for this report's gate confirmation, those same tests
+ **passed** (`kubeconform` was on `PATH` and reached its schema store here)
+ — not touched by any Spec Studio slice either way; flagged only so the
+ discrepancy between the two sandboxes' network posture is on record.
## Gate confirmation
-Full green-gate run performed for this report (2026-07-08, working tree
-clean at `1c048d8`):
+### Phase 1 (Adaptive Orchestration) — as originally recorded
+
+Full green-gate run performed at the time (2026-07-08, working tree clean at
+`1c048d8`): `uv run ruff check .` clean; `uv run ruff format --check .` clean
+(953 files); `make typecheck` 0 errors across 486 source files;
+full pytest **3868 passed, 53 skipped, 0 failed** in 814.37s; `bandit` exit
+0; `gitleaks` no leaks (169 commits); `pnpm lint` 0 errors (6 pre-existing
+warnings); `pnpm build` 19 routes; `pnpm test` 507 passed (66 files); `pnpm
+typecheck` clean; no hardcoded hex/rgb in the Adaptive Orchestration web
+files.
+
+### Phase 2 (Spec Studio) — this report, whole repo re-verified
+
+Full green-gate run performed for **this** report, working tree clean at
+`804a840` (all 14 `ss-*` slices):
- `uv run ruff check .` — clean.
-- `uv run ruff format --check .` — clean (953 files already formatted).
-- `make typecheck` (mypy, all 18 first-party packages) — 0 errors across 486
+- `uv run ruff format --check .` — clean (967 files already formatted).
+- `make typecheck` (mypy, all 18 first-party packages) — 0 errors across 493
source files.
- `FORGE_TEST_DATABASE_URL=postgresql+psycopg://forge:forge@localhost:5433/forge
- uv run pytest -q` — full suite against real pgvector on `:5433`: **3868
- passed, 53 skipped, 0 failed, 23 warnings in 814.37s (13m34s)**. Every skip
- is a documented opt-in/live-cred/virtualization-gated lane (e.g.
- `FORGE_RUN_SOAK`/`FORGE_RUN_PERF`/`FORGE_BUILD_INTEGRATION_TESTS`,
- live GitHub/Slack/MCP/reranker/model-provider creds, gVisor/Firecracker
- kernel-boundary tests, `promtool`/`amtool` not on `PATH`) — none are
- Adaptive Orchestration or Spec Studio related.
+ uv run pytest -q` — full suite against real pgvector on `:5433`, run
+ standalone (nothing else touching the DB concurrently) to avoid the
+ DB-contention artifacts noted in the `ss-versioning` slice's own report:
+ **3980 passed, 53 skipped, 0 failed, 23 warnings in 942.73s (15m42s)**.
+ This resolves the `ss-versioning`/`ss-import` parked "final full-repo
+ pytest confirmation" items. Every skip is a documented opt-in/live-cred/
+ virtualization-gated lane (e.g. `FORGE_RUN_SOAK`/`FORGE_RUN_PERF`/
+ `FORGE_BUILD_INTEGRATION_TESTS`, live GitHub/Slack/MCP/reranker/
+ model-provider creds, gVisor/Firecracker kernel-boundary tests,
+ `promtool`/`amtool` not on `PATH`) — none are Spec Studio related. The
+ `deploy/helm` kubeconform tests `ss-lifecycle`'s own report carried
+ forward as network-blocked in a different sandbox were re-run explicitly
+ in *this* environment (`pytest deploy/helm/tests/test_render_contract.py
+ -k kubeconform`) and **passed** (3 passed) — `kubeconform` is on `PATH`
+ and reached its schema store here, so that note no longer applies to this
+ gate run (kept in "What's parked" only as historical context).
- `uv run bandit -c pyproject.toml -r packages apps --severity-level high -q`
— exit 0.
- `gitleaks detect --source . --config .gitleaks.toml --no-banner --redact` —
- no leaks (169 commits scanned).
-- `pnpm --filter @forge/web lint` — 0 errors (6 pre-existing warnings,
- unrelated to Adaptive Orchestration files).
-- `pnpm --filter @forge/web build` — succeeds (19 routes, including
- `/settings/models`).
-- `pnpm --filter @forge/web test` — 507 passed (66 test files).
+ no leaks (188 commits scanned).
+- `pnpm --filter @forge/web lint` — 0 errors (12 pre-existing warnings,
+ unrelated to Spec Studio files — `pm-integrations-view.tsx`,
+ `members-panel.tsx`, `step-meta.ts`, `walkthrough-view.tsx`,
+ `workflow-canvas.tsx`).
+- `pnpm --filter @forge/web build` — succeeds (20 routes, including
+ `/specs`, `/specs/new`, `/specs/[id]`).
+- `pnpm --filter @forge/web test` — 673 passed (81 test files).
- `pnpm --filter @forge/web typecheck` — clean.
-- No hardcoded hex/rgb color literals in the Adaptive Orchestration web files
- (`ao-settings-view.tsx`, `settings/models/page.tsx`, `lib/api/ao-settings.ts`)
- — design tokens only.
+- No hardcoded hex/rgb color literals across the full Spec Studio web diff
+ (all `apps/web/src/components/spec-studio/**`, `apps/web/src/lib/
+ spec-studio/**`, and the touched `spec`/`lib/api` files) — design tokens
+ only.
+- Migration `0032_ss_versioning_spec_version` (the one new migration
+ introduced across all 14 slices) applies and reverses cleanly against real
+ Postgres on `:5433`: verified in an isolated scratch database on the same
+ server (`forge_migration_check`, dropped after) so the shared test DB's
+ fixture-managed state was never touched — `alembic upgrade
+ 0031_ao_observability_cost_tier` (full baseline chain, 0001→0031) then
+ `upgrade head` created `spec_version` with its indexes/unique constraint/
+ FK exactly as declared; `downgrade 0031_ao_observability_cost_tier`
+ dropped it cleanly (`\d spec_version` → "did not find any relation"); a
+ final `upgrade head` re-created it, confirming a full round-trip.
`git log --oneline | head`:
```
-1c048d8 feat(ao-observability): Adaptive Orchestration
-1cedd4e feat(ao-settings-ui): Adaptive Orchestration
-213a1b4 feat(ao-settings-api): per-role model+effort settings endpoints, store, migration + web client
-37428c2 feat(ao-effort): Adaptive Orchestration
-b539082 feat(ao-policy): Adaptive Orchestration
-ba33d6d feat(ao-config): Adaptive Orchestration
-98bfab7 docs: progress summary — public-readiness merged, hard finalise starting
-4a2dac0 feat: public-readiness — under-dev banner, honest status, live spec dashboard (#30)
-2afb8f7 chore(deps): bump astral-sh/setup-uv from 5.4.2 to 8.3.1 (#24)
-25732b9 chore(deps): bump actions/checkout from 4.2.2 to 7.0.0 (#25)
+804a840 feat(ss-criteria): Spec Studio
+7f1bebc feat(ss-import): Spec Studio
+24f5601 feat(ss-versioning): Spec Studio
+c081a3c feat(ss-entry): Spec Studio
+777e51f feat(ss-ai-panel): Spec Studio
+a9f381f feat(ss-lifecycle): Spec Studio
+d8bfb51 feat(ss-read): Spec Studio
+08af69e feat(ss-markdown): Spec Studio
+13ae1ce feat(ss-guided): Spec Studio
+bec9813 feat(ss-draft): Spec Studio
```
-## Next steps (Phase 2)
+## Next steps
-In priority order, per `docs/spec-studio/DESIGN.md` §2.2: `spec-md-roundtrip`
-→ `spec-draft-api` → `spec-studio-ui` → `spec-studio-realtime`. The `rt-ws`
-real-time slice noted as deferred in `docs/MORNING_SUMMARY-2026-07-08.md`
-should land as the same relay substrate `spec-studio-realtime` needs, not as
-a second websocket implementation.
+Phase 2's remaining item, per `docs/spec-studio/DESIGN.md` §2.2/§4:
+`spec-studio-realtime` (Yjs co-editing over the shared `/ws` relay — the
+same substrate as the `rt-ws` slice noted as deferred in
+`docs/MORNING_SUMMARY-2026-07-08.md`, so it should land once, serving both).
+Its prerequisites (`spec-md-roundtrip`, the Guided/Markdown/YAML editor) are
+now shipped, so it is unblocked. Independently: the `ss-read` reject/
+request-changes backend persistence gap above.
diff --git a/docs/MORNING_SUMMARY-2026-07-08.md b/docs/MORNING_SUMMARY-2026-07-08.md
index 016f353a..1d2c635f 100644
--- a/docs/MORNING_SUMMARY-2026-07-08.md
+++ b/docs/MORNING_SUMMARY-2026-07-08.md
@@ -1,23 +1,29 @@
# Forge — Progress Summary (2026-07-08)
-_Autonomous run. Priority #1 = finalise the full solution for distribution._
+_Autonomous finalise run. Priority #1 = finish the full solution for distribution._
-## ✅ Landed on `main` (green in CI)
-- **CI is real and green.** The GitHub Actions gate had never actually run (a `secrets`-in-`if:` crash at 0s); found + fixed the whole first-run tail plus the gate dimensions the swarms never enforced (mypy 135→0, eslint, ruff-format, semgrep, bandit). All blocking checks pass.
-- **PR #28 merged** — persistence (Postgres repos) + every CI fix.
-- **PR #30 merged — PUBLIC-READINESS.** ⚠️ Under-development banner + honest README Status (15 screens shipped, ~3,700 tests green), live CI badge; **live spec dashboard** (`GET /projects/{id}/specs`); adaptive-orchestration foundation (complexity sizing + model router).
-- **→ The repo is SAFE TO MARK PUBLIC now** (with the under-development notice, as intended).
+## ✅ Landed on `main` (green in CI) — repo is now **PUBLIC**
+- **CI is real and green**; the whole GitHub Actions gate was fixed and now runs (incl. CodeQL/code-scanning on the public repo).
+- **PR #28** — persistence (Postgres repos) + CI fixes.
+- **PR #30 — public-readiness**: under-development banner + honest README status + live spec dashboard (`GET /projects/{id}/specs`). **→ You marked the repo public.** 🎉
+- **PR #32 — Adaptive Orchestration**: automatic model routing (Anthropic junior=Haiku / medior=Sonnet / senior=Opus, provider-agnostic), per-role effort levels, a "Models & Effort" settings API + UI with live routing preview, and cost-by-tier observability. Local gate: mypy 0, **3,868 tests**, web lint/build/test clean.
- Dependabot #25 auto-merged when green.
-## ▶ In progress — the hard finalise (chunked across the weekly limit)
-Resuming the adaptive-spec build (Spec Studio, adaptive orchestration, real-time/CRDT). It **delivers the deferred public-readiness items properly**: the server-side `/ws` websocket (as the `rt-ws` real-time slice) and the "coming soon" UI labels (frontend-UX phase). Then: F40 backlog → IaC → frontend-UX (ui-ux-pro) → docs + real screenshots. One swarm at a time; each chunk synced via PR + auto-merged when green; resumes across each weekly-limit reset.
+## ▶ In progress — the hard finalise, chunk by chunk
+- **Spec Studio** (building now, ~14 slices): dual-format `spec.md` ⇄ `manifest.yaml` round-trip, the Guided/Markdown/YAML/Read modes, BYOK AI draft (`POST /spec/draft`), the full SDD lifecycle, versioning/diff, import. Design doc: `docs/spec-studio/DESIGN.md`.
+- **Then:** Realtime co-editing (delivers the real `/ws` websocket + Yjs CRDT) → F40 backlog → IaC (OpenTofu) → frontend-UX pass (ui-ux-pro, incl. the deferred "coming soon" labels) → docs site + real screenshots.
-## ⚠️ Notes / lessons
-- A two-swarm race and a seams-gatekeeper misfire were caught and recovered with no damage (main stayed green). Iron rule now enforced: only one swarm on `main` at a time.
-- **Deferred (banner-covered), being built properly in the finalise:** `/ws` live real-time push; a few gated-UI "coming soon" labels.
+## ⚠️ Pipeline lessons (all fixed, main stayed green)
+- Spurious green-slice reverts → gate now treats a green full suite as authoritative.
+- Workflow `args` don't pass → slice filter hardcoded in the script.
+- A verifier `git stash`'d WIP → verify prompt forbids touching git state; recovered via `git stash pop`.
+- One two-swarm race + a seams-gatekeeper misfire → caught, no damage; iron rule = one swarm at a time.
+
+## 📌 Tracked follow-up
+- **Wire `ExecutionPlan` tier/strategy into `ModelUsage`** at the live model-client call sites — the cost-by-tier observability is built and ready but not yet populated from real agent runs (pre-existing gap, documented in `docs/ADAPTIVE_SPEC_PROGRESS.md`).
## ❓ Open questions
-- None blocking. The finalise is compute-bound by the weekly limit and will land in chunks over the next day(s).
+- None blocking. The finalise is compute-bound by the weekly limit and lands in chunks over the next day(s).
## Honest ceiling (cannot close autonomously)
- Cred-gated live integrations (GitHub App / model BYOK / reranker / MCP / Slack) — code + tests + runbooks exist; need your keys to verify live.
diff --git a/docs/spec-studio/DESIGN.md b/docs/spec-studio/DESIGN.md
index 20e84e2e..4b76dc25 100644
--- a/docs/spec-studio/DESIGN.md
+++ b/docs/spec-studio/DESIGN.md
@@ -1,12 +1,15 @@
# Spec Studio + Adaptive Orchestration — Design
-Status: **Adaptive Orchestration is built and merged** (see
-`docs/ADAPTIVE_SPEC_PROGRESS.md` for the slice-by-slice ledger). **Spec Studio**
-(dual-format spec authoring UI, `spec.md` round-trip, and real-time
-co-editing) is **design-approved but not yet implemented** — this document is
-the design the next build phase implements against. It records the decisions
-so implementation can proceed in independently-shippable slices, the same way
-Adaptive Orchestration did.
+Status: **Adaptive Orchestration is built and merged**, and **Spec Studio's
+dual-format authoring + web editor are now also built and merged** — `spec.md`
+↔ `manifest.yaml` round-trip (`parse_spec_md`/`render_spec_md`), the
+Guided/Markdown/YAML/Read/History editor, BYOK AI drafting
+(`POST /spec/draft`), external import (`POST /spec/import`), acceptance
+criterion styles, and version history + diff all shipped across 14 slices
+(see `docs/ADAPTIVE_SPEC_PROGRESS.md` for the full ledger of both phases).
+**Real-time co-editing (§4) remains design-approved but not yet
+implemented** — this document (§4 particularly) is still the design that
+follow-up slice implements against.
## 1. Why one document for two features
@@ -88,32 +91,21 @@ upgrades the file to the new round-trippable shape on first save.
|---|---|
| `SpecManifest` canonical DTO | **Shipped** (`forge_contracts`) |
| `manifest.yaml` round-trip (`dump_manifest`/`load_manifest`) | **Shipped** (`forge_spec.manifest`) |
-| `spec.md` rendering (`render_spec_md`) | **Shipped, but one-way** — manifest → markdown only; no frontmatter, no `## Goal`, no Given/When/Then AC phrasing, no `## Decisions` section |
-| `spec.md` **parsing** (`parse_spec_md`) | **Not implemented** — no code path reads edits back out of `spec.md` |
-| Round-trip sync (edit either → both stay current) | **Not implemented** |
-| `POST /spec/draft` (BYOK AI draft from a one-line goal) | **Not implemented** — no route, schema, or service exists |
-| Spec Studio web UI (split/synced editor) | **Not implemented** — `apps/web` has a read-only `spec-dashboard` (validation view), not an editor |
+| `spec.md` rendering (`render_spec_md`) | **Shipped**, full shape — frontmatter, `## Goal`, Given/When/Then (+ assertion/checklist styles, `ss-criteria`), `## Decisions` |
+| `spec.md` **parsing** (`parse_spec_md`) | **Shipped** (`ss-parser`) — `forge_spec.markdown.parse_spec_md` |
+| Round-trip sync (edit either → both stay current) | **Shipped** (`ss-engine`) — `FileSpecEngine.save_spec_md`/`save_manifest_yaml` re-render the other file from one `SpecManifest` |
+| `POST /spec/draft` (BYOK AI draft from a one-line goal) | **Shipped** (`ss-draft`) |
+| `POST /spec/import` (external markdown/YAML → draft) | **Shipped** (`ss-import`, not originally scoped below — added during the build) |
+| Spec Studio web UI (Guided/Markdown/YAML/Read/History editor) | **Shipped** (`ss-yaml`, `ss-guided`, `ss-markdown`, `ss-read`, `ss-versioning`) — `apps/web/src/components/spec-studio` |
+| Version history + diff | **Shipped** (`ss-versioning`) — `spec_version` table + diff endpoints |
| Real-time co-editing | **Not implemented** — no CRDT/OT dependency, no `/ws` route exists anywhere in `apps/api` |
-The gap is intentionally scoped as follow-up slices:
-
-- **`spec-md-roundtrip`** — add `parse_spec_md(text) -> SpecManifest`
- (frontmatter + section parser, tolerant of the legacy one-way shape) and
- extend `render_spec_md` to emit the full section set above; wire both
- through `FileSpecEngine` so `save_spec_md`/`save_manifest` converge on the
- same `SpecManifest.model_dump()` before writing either file, matching the
- existing `_write` pattern in `engine.py`.
-- **`spec-draft-api`** — `POST /spec/draft` takes `{goal: str, project_id}`,
- resolves the `spec_author` role via the Adaptive Orchestration model
- router (§3), streams a constitution-seeded draft through the BYOK
- `ModelClient`, and returns a `spec.md` draft (never auto-saved — a human
- must accept it through the normal spec-engine write path). Tests mock
- `ModelClient`; no live key is exercised in CI.
-- **`spec-studio-ui`** — a two-pane (rendered `spec.md` / editable form over
- the same fields) editor in `apps/web`, reusing `spec-dashboard`'s
- validation-report rendering for inline AC/requirement lint feedback.
-- **`spec-studio-realtime`** — real-time co-editing (§4) once the editor
- above exists to co-edit.
+The table above reflects `docs/ADAPTIVE_SPEC_PROGRESS.md`'s Phase 2 ledger
+(14 `ss-*` slices, all committed). Only real-time co-editing remains:
+
+- **`spec-studio-realtime`** — real-time co-editing (§4), now unblocked: the
+ editor it co-edits (Guided/Markdown/YAML modes, `parse_spec_md` round-trip)
+ is built.
### 2.3 Round-trip contract
@@ -209,11 +201,10 @@ None of the above is implemented yet; this section is the target design the
## 5. Open questions carried into implementation
-- **Q1**: Should `parse_spec_md` accept partial edits (e.g., a human deletes
- the `## Open Questions` section entirely) as "no open questions" or as
- invalid input requiring the section header to stay present with `_None_`?
- Leaning: absent section = empty list, matching `_bullets([])` already
- rendering `_None_` today.
+- **Q1** — **Resolved by `ss-parser`**: `parse_spec_md` accepts partial
+ edits; a missing `## Open Questions` (or any other list) section parses as
+ an empty list rather than a validation error, matching the "absent
+ section = empty list" leaning above.
- **Q2**: Presence/typing indicators for co-editing (who else is viewing) —
Yjs's awareness protocol (`y-protocols/awareness`) covers this for free
once the transport lands; not a separate build item, just needs enabling.
diff --git a/packages/agent-runtime/forge_agent/execution_plan.py b/packages/agent-runtime/forge_agent/execution_plan.py
index 4491ad71..6c2798d0 100644
--- a/packages/agent-runtime/forge_agent/execution_plan.py
+++ b/packages/agent-runtime/forge_agent/execution_plan.py
@@ -37,15 +37,6 @@
from typing import Literal, cast
from uuid import UUID
-from forge_orchestration_policy import (
- ComplexitySizing,
- SizingSignals,
- Strategy,
- Tier,
- score_complexity,
-)
-from forge_orchestration_policy.role_config import resolve_effective_config
-
from forge_agent.providers.config import ProviderName
from forge_agent.providers.router import ModelRouter
from forge_contracts.orchestration_config import (
@@ -54,6 +45,14 @@
RoleConfigSource,
RoleConfigStore,
)
+from forge_orchestration_policy import (
+ ComplexitySizing,
+ SizingSignals,
+ Strategy,
+ Tier,
+ score_complexity,
+)
+from forge_orchestration_policy.role_config import resolve_effective_config
__all__ = [
"ExecutionPlan",
diff --git a/packages/agent-runtime/forge_agent/providers/router.py b/packages/agent-runtime/forge_agent/providers/router.py
index 776c336f..92c94873 100644
--- a/packages/agent-runtime/forge_agent/providers/router.py
+++ b/packages/agent-runtime/forge_agent/providers/router.py
@@ -25,10 +25,9 @@
from dataclasses import dataclass, field
from typing import Any
-from forge_orchestration_policy import ComplexitySizing, Tier, candidate_tiers
-
from forge_agent.providers.config import ModelClientConfig, ProviderName
from forge_contracts import ModelClient, ModelMessage, ModelRequest
+from forge_orchestration_policy import ComplexitySizing, Tier, candidate_tiers
__all__ = [
"DEFAULT_TIER_MODELS",
diff --git a/packages/agent-runtime/tests/test_execution_plan.py b/packages/agent-runtime/tests/test_execution_plan.py
index 11789a71..97e92a07 100644
--- a/packages/agent-runtime/tests/test_execution_plan.py
+++ b/packages/agent-runtime/tests/test_execution_plan.py
@@ -11,12 +11,11 @@
import uuid
from dataclasses import dataclass, field
-from forge_orchestration_policy import SizingSignals, score_complexity
-
from forge_agent import ExecutionPlan, ModelRouter, ProviderName, plan_execution
from forge_agent.execution_plan import plan_role_execution
from forge_contracts import Priority, TaskKind
from forge_contracts.orchestration_config import AgentRole, Effort, RoleConfigOverride
+from forge_orchestration_policy import SizingSignals, score_complexity
WORKSPACE = uuid.uuid4()
PROJECT = uuid.uuid4()
diff --git a/packages/agent-runtime/tests/test_providers_router.py b/packages/agent-runtime/tests/test_providers_router.py
index 62036efd..db2edb5d 100644
--- a/packages/agent-runtime/tests/test_providers_router.py
+++ b/packages/agent-runtime/tests/test_providers_router.py
@@ -11,7 +11,6 @@
from collections.abc import Iterator
import pytest
-from forge_orchestration_policy import ComplexitySizing, SizingSignals, score_complexity
from forge_agent.providers import (
DEFAULT_TIER_MODELS,
@@ -32,6 +31,7 @@
TaskKind,
TokenUsage,
)
+from forge_orchestration_policy import ComplexitySizing, SizingSignals, score_complexity
# --------------------------------------------------------------------------- #
diff --git a/packages/db/forge_db/models/__init__.py b/packages/db/forge_db/models/__init__.py
index 5363f0f7..0337e7a5 100644
--- a/packages/db/forge_db/models/__init__.py
+++ b/packages/db/forge_db/models/__init__.py
@@ -134,6 +134,7 @@
from forge_db.models.runs import AgentRun, ApprovalRequest, SubAgentRun, WorkflowRun
from forge_db.models.sandbox import SandboxInstance
from forge_db.models.secret import Secret
+from forge_db.models.spec_version import SpecVersion
from forge_db.models.sprint_velocity import (
SprintBurndownSnapshot,
SprintScopeEvent,
@@ -286,6 +287,7 @@
"SkillProfile",
"SpecDocument",
"SpecStatus",
+ "SpecVersion",
"Sprint",
"SprintBurndownSnapshot",
"SprintScopeEvent",
diff --git a/packages/db/forge_db/models/spec_version.py b/packages/db/forge_db/models/spec_version.py
new file mode 100644
index 00000000..75a8c961
--- /dev/null
+++ b/packages/db/forge_db/models/spec_version.py
@@ -0,0 +1,47 @@
+"""``SpecVersion`` — an immutable snapshot of a spec taken on every save.
+
+(ss-versioning) Spec Studio (F02's ``FileSpecEngine``) is filesystem-backed and
+keeps no history: every ``write_manifest`` / ``save_spec_md`` /
+``save_manifest_yaml`` overwrites ``manifest.yaml`` and ``spec.md`` in place, so
+a spec's prior states are lost the moment it is edited. ``SpecVersion`` is the
+DB-backed history: the API layer appends one row per save (see
+``forge_api.routers.spec``'s ``_record_version``) carrying a full snapshot of
+the manifest plus both rendered serializations, so the web Spec Studio can list
+a spec's version history and diff any two versions.
+
+Keyed by the engine's own deterministic ``spec_id`` (not a FK to
+``spec_document``: that table is a separate, not-yet-wired projection — see
+``forge_db.models.planning`` — and the engine is the actual source of truth
+today). ``version_number`` is a per-``(workspace_id, spec_id)`` sequence
+assigned by the recording service, 1-based and gapless.
+"""
+
+from __future__ import annotations
+
+import uuid
+from typing import Any
+
+from sqlalchemy import Index, Integer, String, Text, UniqueConstraint, Uuid
+from sqlalchemy.orm import Mapped, mapped_column
+
+from forge_db.base import WorkspaceScopedModel, json_type
+
+
+class SpecVersion(WorkspaceScopedModel):
+ """One immutable snapshot of a spec's manifest, taken on save."""
+
+ __tablename__ = "spec_version"
+ __table_args__ = (
+ UniqueConstraint("workspace_id", "spec_id", "version_number", name="uq_spec_version_seq"),
+ Index("ix_spec_version_spec_id", "spec_id"),
+ )
+
+ spec_id: Mapped[uuid.UUID] = mapped_column(Uuid(as_uuid=True), nullable=False)
+ spec_key: Mapped[str] = mapped_column(String(64), nullable=False)
+ version_number: Mapped[int] = mapped_column(Integer, nullable=False)
+ name: Mapped[str] = mapped_column(String(512), nullable=False)
+ status: Mapped[str] = mapped_column(String(32), nullable=False)
+ manifest: Mapped[dict[str, Any]] = mapped_column(json_type(), default=dict, nullable=False)
+ spec_md: Mapped[str] = mapped_column(Text, nullable=False)
+ manifest_yaml: Mapped[str] = mapped_column(Text, nullable=False)
+ created_by: Mapped[uuid.UUID | None] = mapped_column(Uuid(as_uuid=True), nullable=True)
diff --git a/packages/db/migrations/versions/0032_ss_versioning_spec_version.py b/packages/db/migrations/versions/0032_ss_versioning_spec_version.py
new file mode 100644
index 00000000..03a8ef9f
--- /dev/null
+++ b/packages/db/migrations/versions/0032_ss_versioning_spec_version.py
@@ -0,0 +1,47 @@
+"""ss-versioning: spec_version table (Spec Studio version history + diff)
+
+Creates ``spec_version``: an append-per-save, immutable snapshot table backing
+Spec Studio's version history + diff view. One row per save of a spec (via
+``write_manifest`` / ``save_spec_md`` / ``save_manifest_yaml``), keyed by
+``(workspace_id, spec_id, version_number)``, carrying the full manifest
+snapshot (JSONB) plus both rendered serializations (``spec.md``,
+``manifest.yaml``) so the UI can render a version's content or diff two
+versions without recomputing anything from the (mutable, filesystem-backed)
+``FileSpecEngine`` state.
+
+Metadata-driven like 0014 (F23 traceability): the table is created wholesale
+from the live ``SpecVersion`` model so cross-dialect column variants (JSONB on
+Postgres) apply automatically. Purely additive/new table: reversible via a
+plain drop.
+
+Revision ID: 0032_ss_versioning
+Revises: 0031_ao_observability_cost_tier
+Create Date: 2026-07-09
+"""
+
+from __future__ import annotations
+
+from collections.abc import Sequence
+
+from alembic import op
+
+import forge_db.models # noqa: F401 (registers all models on Base.metadata)
+from forge_db.base import Base
+
+# revision identifiers, used by Alembic.
+revision: str = "0032_ss_versioning"
+down_revision: str | None = "0031_ao_observability_cost_tier"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+_TABLE = "spec_version"
+
+
+def upgrade() -> None:
+ table = Base.metadata.tables[_TABLE]
+ table.create(bind=op.get_bind(), checkfirst=True)
+
+
+def downgrade() -> None:
+ table = Base.metadata.tables[_TABLE]
+ table.drop(bind=op.get_bind(), checkfirst=True)
diff --git a/packages/db/tests/test_ao_config_role_config.py b/packages/db/tests/test_ao_config_role_config.py
index b55c6397..3d43865d 100644
--- a/packages/db/tests/test_ao_config_role_config.py
+++ b/packages/db/tests/test_ao_config_role_config.py
@@ -16,7 +16,6 @@
from collections.abc import Iterator
import pytest
-from forge_orchestration_policy import resolve_effective_config
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session, sessionmaker
@@ -25,6 +24,7 @@
from forge_db.base import Base
from forge_db.models import AgentRoleConfig, Project, Workspace
from forge_db.role_config import SqlRoleConfigStore
+from forge_orchestration_policy import resolve_effective_config
pytestmark = pytest.mark.usefixtures("pg_engine")
diff --git a/packages/db/tests/test_models.py b/packages/db/tests/test_models.py
index c1b6133e..e721e3d2 100644
--- a/packages/db/tests/test_models.py
+++ b/packages/db/tests/test_models.py
@@ -153,6 +153,9 @@
# ao-settings-api: workspace-wide Adaptive Orchestration settings
# (auto-route toggle, tier-model overrides, complexity thresholds).
"AoWorkspaceSettings",
+ # ss-versioning: immutable per-save spec snapshot (Spec Studio version
+ # history + diff), keyed by the FileSpecEngine's own deterministic spec_id.
+ "SpecVersion",
]
# Tables that are NOT the tenant root and therefore must carry a workspace FK.
diff --git a/packages/knowledge-core/forge_knowledge/chunking.py b/packages/knowledge-core/forge_knowledge/chunking.py
index 2e0586d4..d5de3c6b 100644
--- a/packages/knowledge-core/forge_knowledge/chunking.py
+++ b/packages/knowledge-core/forge_knowledge/chunking.py
@@ -56,7 +56,10 @@
#: embedding context window. Code chunks follow AST units and are never split.
DEFAULT_MAX_CHARS: int = 1200
-_HEADING_RE = re.compile(r"^\s{0,3}(#{1,6})\s+(.*?)\s*#*\s*$")
+# Greedy capture to end-of-line (no lazy `.*?` + trailing `\s*` overlap, which
+# CodeQL flags as polynomial/ReDoS); the closing ATX `#`s + spaces are stripped
+# from group(2) in code below, which is linear.
+_HEADING_RE = re.compile(r"^[ ]{0,3}(#{1,6})[ \t]+(\S.*)$")
_FENCE_RE = re.compile(r"^\s*(`{3,}|~{3,})")
# File extensions treated as source code for path classification.
@@ -385,7 +388,8 @@ def chunk_markdown(
if heading_match is not None:
level = len(heading_match.group(1))
- text = heading_match.group(2).strip()
+ # Strip the optional closing ATX run of `#`s and surrounding spaces.
+ text = heading_match.group(2).strip().rstrip("#").strip()
while heading_stack and heading_stack[-1][0] >= level:
heading_stack.pop()
heading_stack.append((level, text))
diff --git a/packages/multi-agent-coordinator/tests/test_ao_policy_wiring.py b/packages/multi-agent-coordinator/tests/test_ao_policy_wiring.py
index 32abe993..979c1cd9 100644
--- a/packages/multi-agent-coordinator/tests/test_ao_policy_wiring.py
+++ b/packages/multi-agent-coordinator/tests/test_ao_policy_wiring.py
@@ -14,11 +14,11 @@
from pathlib import Path
from _helpers import AgentScript, ScriptingHub, make_objective, obj_parent
-from forge_orchestration_policy import SizingSignals
from forge_agent import ModelRouter, ProviderName, plan_execution
from forge_contracts import AcceptanceCriterion
from forge_contracts.orchestration_config import AgentRole, RoleConfigOverride
+from forge_orchestration_policy import SizingSignals
class _DefaultsOnlyStore:
diff --git a/packages/orchestration-policy/tests/test_ao_config_resolver.py b/packages/orchestration-policy/tests/test_ao_config_resolver.py
index 31a58c8f..9e08ec1b 100644
--- a/packages/orchestration-policy/tests/test_ao_config_resolver.py
+++ b/packages/orchestration-policy/tests/test_ao_config_resolver.py
@@ -12,14 +12,13 @@
import uuid
from dataclasses import dataclass, field
-from forge_orchestration_policy import resolve_effective_config
-
from forge_contracts.orchestration_config import (
DEFAULT_ROLE_CONFIG,
AgentRole,
Effort,
RoleConfigOverride,
)
+from forge_orchestration_policy import resolve_effective_config
WORKSPACE = uuid.uuid4()
PROJECT = uuid.uuid4()
diff --git a/packages/orchestration-policy/tests/test_complexity.py b/packages/orchestration-policy/tests/test_complexity.py
index c165f34f..9c6b43ec 100644
--- a/packages/orchestration-policy/tests/test_complexity.py
+++ b/packages/orchestration-policy/tests/test_complexity.py
@@ -3,8 +3,6 @@
from __future__ import annotations
import pytest
-from forge_orchestration_policy import ComplexitySizing, SizingSignals, score_complexity
-from forge_orchestration_policy.complexity import signals_from_spec
from forge_contracts import (
AcceptanceCriterion,
@@ -14,6 +12,8 @@
SpecManifest,
TaskKind,
)
+from forge_orchestration_policy import ComplexitySizing, SizingSignals, score_complexity
+from forge_orchestration_policy.complexity import signals_from_spec
def _sizing(**kwargs: object) -> ComplexitySizing:
diff --git a/packages/spec-engine/forge_spec/__init__.py b/packages/spec-engine/forge_spec/__init__.py
index 99784242..05bb6069 100644
--- a/packages/spec-engine/forge_spec/__init__.py
+++ b/packages/spec-engine/forge_spec/__init__.py
@@ -15,6 +15,19 @@
from __future__ import annotations
+from forge_spec.criteria import (
+ ASSERTION,
+ CHECKLIST,
+ GHERKIN,
+ ChecklistItem,
+ CriterionStyle,
+ GivenWhenThen,
+ classify_criterion,
+ compose_checklist,
+ compose_gherkin,
+ parse_checklist,
+ parse_gherkin,
+)
from forge_spec.dashboard import (
build_criterion_links,
build_requirement_rows,
@@ -37,12 +50,20 @@
ValidationStatus,
)
from forge_spec.dashboard_service import DashboardService
+from forge_spec.diff import (
+ ListItemChange,
+ ManifestDiff,
+ ScalarFieldChange,
+ TextDiffLine,
+ diff_manifest,
+ diff_markdown,
+)
from forge_spec.engine import (
DEFAULT_GUARDRAILS,
DEFAULT_PRINCIPLES,
FileSpecEngine,
)
-from forge_spec.errors import SpecNotFoundError
+from forge_spec.errors import SpecNotFoundError, SpecReconcileWarning
from forge_spec.gates import IMPLEMENTABLE_STATUSES, check_implementation_gate
from forge_spec.ids import (
constitution_id_for,
@@ -54,6 +75,7 @@
task_key,
)
from forge_spec.manifest import dump_manifest, load_manifest, manifest_to_dict
+from forge_spec.markdown import SpecParseError, parse_spec_md, render_spec_md
from forge_spec.projection import (
EvidencePort,
InMemoryProjectionRepository,
@@ -71,25 +93,37 @@
SpecEngineService = FileSpecEngine
__all__ = [
+ "ASSERTION",
+ "CHECKLIST",
"DEFAULT_GUARDRAILS",
"DEFAULT_PRINCIPLES",
+ "GHERKIN",
"IMPLEMENTABLE_STATUSES",
"CellStatus",
+ "ChecklistItem",
+ "CriterionStyle",
"CriterionVerdict",
"DashboardService",
"EvidenceIndex",
"EvidencePort",
"FileSpecEngine",
"GapKind",
+ "GivenWhenThen",
"InMemoryProjectionRepository",
+ "ListItemChange",
+ "ManifestDiff",
"NoOpEvidencePort",
"ProjectValidationSummary",
"ProjectionRepository",
+ "ScalarFieldChange",
"SpecEngineService",
"SpecNotFoundError",
+ "SpecParseError",
+ "SpecReconcileWarning",
"SpecSourcePort",
"SpecTraceabilityMatrix",
"SpecValidationRow",
+ "TextDiffLine",
"TraceCell",
"TraceabilityGap",
"TraceabilityProjector",
@@ -101,13 +135,22 @@
"build_validation_report",
"check_implementation_gate",
"classify_cell",
+ "classify_criterion",
+ "compose_checklist",
+ "compose_gherkin",
"compute_spec_rollup",
"constitution_id_for",
"detect_gaps",
+ "diff_manifest",
+ "diff_markdown",
"dump_manifest",
"generate_tasks",
"load_manifest",
"manifest_to_dict",
+ "parse_checklist",
+ "parse_gherkin",
+ "parse_spec_md",
+ "render_spec_md",
"slugify",
"spec_dirname",
"spec_id_for_key",
diff --git a/packages/spec-engine/forge_spec/criteria.py b/packages/spec-engine/forge_spec/criteria.py
new file mode 100644
index 00000000..32230efa
--- /dev/null
+++ b/packages/spec-engine/forge_spec/criteria.py
@@ -0,0 +1,145 @@
+"""Acceptance-criterion *styles* for the spec engine (ss-criteria).
+
+An :class:`~forge_contracts.AcceptanceCriterion` carries free-form ``text`` plus
+its requirement links (``req_refs``). Historically that text was written in one
+shape — Given/When/Then. This module lets a criterion be written in any of three
+first-class *styles*, all encoded losslessly inside the same ``text`` field so
+the canonical :class:`~forge_contracts.SpecManifest` and its ``req_refs`` linking
+are untouched:
+
+- ``gherkin`` — ``Given … When … Then …`` behavioural prose (the default).
+- ``assertion`` — a single plain declarative sentence.
+- ``checklist`` — one or more ``- [ ] item`` / ``- [x] item`` lines (multi-line
+ ``text``; round-trips through ``spec.md`` via continuation lines — see
+ :mod:`forge_spec.markdown`).
+
+:func:`classify_criterion` infers a criterion's style from its text (best-effort,
+never raising) so guided editors, renderers and dashboards can present the right
+affordance. :func:`parse_checklist` / :func:`compose_checklist` and
+:func:`parse_gherkin` / :func:`compose_gherkin` (de)serialise the two structured
+styles. Style is *derived*, not stored: nothing here changes the frozen
+``AcceptanceCriterion`` contract, and requirement (R#) linking is never touched.
+"""
+
+from __future__ import annotations
+
+import re
+from typing import Literal, NamedTuple
+
+#: The three acceptance-criterion authoring styles.
+CriterionStyle = Literal["gherkin", "assertion", "checklist"]
+
+GHERKIN: CriterionStyle = "gherkin"
+ASSERTION: CriterionStyle = "assertion"
+CHECKLIST: CriterionStyle = "checklist"
+
+#: ``- [ ] label`` / ``- [x] label`` (the checked box is case-insensitive; the
+#: space after ``]`` is optional so hand-authored items still classify).
+_CHECK_ITEM = re.compile(r"^- \[(?P[ xX])\] ?(?P