From 80638f319f4b07a18936c53954bd19d65212ebfa Mon Sep 17 00:00:00 2001 From: Forge Swarm Date: Wed, 8 Jul 2026 19:36:01 +0200 Subject: [PATCH 01/20] =?UTF-8?q?docs:=20progress=20=E2=80=94=20repo=20pub?= =?UTF-8?q?lic,=20adaptive=20orchestration=20merged,=20spec=20studio=20nex?= =?UTF-8?q?t?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/MORNING_SUMMARY-2026-07-08.md | 30 ++++++++++++++++++------------ 1 file changed, 18 insertions(+), 12 deletions(-) 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. From 19d165a120d3cc921e53ea02ac626aaa0b387664 Mon Sep 17 00:00:00 2001 From: Forge Swarm Date: Wed, 8 Jul 2026 20:18:30 +0200 Subject: [PATCH 02/20] feat(ss-parser): Spec Studio Co-Authored-By: Claude Fable 5 --- packages/spec-engine/forge_spec/__init__.py | 4 + packages/spec-engine/forge_spec/markdown.py | 435 ++++++++++++++++++ packages/spec-engine/forge_spec/templates.py | 18 +- .../spec-engine/tests/test_spec_markdown.py | 282 ++++++++++++ 4 files changed, 726 insertions(+), 13 deletions(-) create mode 100644 packages/spec-engine/forge_spec/markdown.py create mode 100644 packages/spec-engine/tests/test_spec_markdown.py diff --git a/packages/spec-engine/forge_spec/__init__.py b/packages/spec-engine/forge_spec/__init__.py index 99784242..53aa430b 100644 --- a/packages/spec-engine/forge_spec/__init__.py +++ b/packages/spec-engine/forge_spec/__init__.py @@ -54,6 +54,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, @@ -87,6 +88,7 @@ "ProjectionRepository", "SpecEngineService", "SpecNotFoundError", + "SpecParseError", "SpecSourcePort", "SpecTraceabilityMatrix", "SpecValidationRow", @@ -108,6 +110,8 @@ "generate_tasks", "load_manifest", "manifest_to_dict", + "parse_spec_md", + "render_spec_md", "slugify", "spec_dirname", "spec_id_for_key", diff --git a/packages/spec-engine/forge_spec/markdown.py b/packages/spec-engine/forge_spec/markdown.py new file mode 100644 index 00000000..c7ac916c --- /dev/null +++ b/packages/spec-engine/forge_spec/markdown.py @@ -0,0 +1,435 @@ +"""Dual-format ``spec.md`` (de)serialization for the spec engine. + +``spec.md`` is the human/agent prose surface for a spec; ``manifest.yaml`` is the +precise machine surface. Both are *canonical, non-lossy* serializations of the +one :class:`~forge_contracts.SpecManifest` DTO: editing either updates the model +and re-renders the other. + +This module owns the ``spec.md`` side of that contract: + +- :func:`render_spec_md` — ``SpecManifest`` -> markdown text. +- :func:`parse_spec_md` — markdown text -> ``SpecManifest`` (the exact inverse). +- :class:`SpecParseError` — a line-anchored parse failure. + +Document shape (a YAML frontmatter block for scalar/list *metadata*, then +``##`` sections for the typed lists):: + + --- + 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 + + + + ## Requirements + + - **R1**: + + ## Acceptance Criteria + + - **A1** (R1): + + ## Constraints + + - + + ## Open Questions + + - **Q1**: + - Resolution: + + ## Decisions + + ### ADR-1 — + + - Status: accepted + - Context: <context> + - Decision: <decision> + - Consequences: <consequences> + +``render_spec_md(parse_spec_md(...))`` and ``parse_spec_md(render_spec_md(...))`` +both round-trip, and a spec.md and its manifest.yaml parse to the *same* +``SpecManifest`` (cross-format consistency). +""" + +from __future__ import annotations + +import re +from typing import Any + +import yaml + +from forge_contracts import ( + ADR, + AcceptanceCriterion, + ForgeError, + OpenQuestion, + Requirement, + SpecManifest, +) + +# --------------------------------------------------------------------------- # +# Error # +# --------------------------------------------------------------------------- # + + +class SpecParseError(ForgeError, ValueError): + """A ``spec.md`` document could not be parsed. + + ``line`` is the 1-based source line the failure anchors to (``None`` when a + failure cannot be tied to a specific line). It subclasses the shared + ``ForgeError`` base *and* :class:`ValueError` so callers can catch either. + """ + + def __init__(self, message: str, *, line: int | None = None) -> None: + self.line = line + self.raw_message = message + prefix = f"line {line}: " if line is not None else "" + super().__init__(f"{prefix}{message}") + + +# --------------------------------------------------------------------------- # +# Frontmatter metadata keys (everything on the manifest EXCEPT the typed lists # +# and ``name`` — which is the ``## Goal`` section body). # +# --------------------------------------------------------------------------- # + +#: Scalar / simple-list manifest fields carried by the YAML frontmatter. +_FRONTMATTER_KEYS: tuple[str, ...] = ( + "id", + "status", + "constitution_refs", + "repos", + "execution_mode", + "skill_profile", + "plan_ref", + "tasks_ref", + "validation_ref", +) + +_H2 = "## " +_H3 = "### " +_ADR_SEP = " — " # id — title (em dash, spaced) + +# ``- **ID**: text`` (Requirements / Open Questions). +_BOLD_BULLET = re.compile(r"^- \*\*(?P<id>[^*]+)\*\*:\s?(?P<text>.*)$") +# ``- **ID** (refs): text`` (Acceptance Criteria; the parenthetical is optional). +_ACCEPT_BULLET = re.compile(r"^- \*\*(?P<id>[^*]+)\*\*(?: \((?P<refs>[^)]*)\))?:\s?(?P<text>.*)$") +# `` - Resolution: text`` (indented sub-bullet under an open question). +_RESOLUTION = re.compile(r"^ {2}- Resolution:\s?(?P<text>.*)$") +# ``- Status|Context|Decision|Consequences: text`` (ADR fields). +_ADR_FIELD = re.compile(r"^- (?P<key>Status|Context|Decision|Consequences):\s?(?P<val>.*)$") + +_ADR_FIELD_ATTR = { + "Status": "status", + "Context": "context", + "Decision": "decision", + "Consequences": "consequences", +} + + +# --------------------------------------------------------------------------- # +# Render # +# --------------------------------------------------------------------------- # + + +def _frontmatter(manifest: SpecManifest) -> str: + payload = manifest.model_dump(mode="json") + ordered: dict[str, Any] = {key: payload[key] for key in _FRONTMATTER_KEYS} + body = yaml.safe_dump( + ordered, + sort_keys=False, + default_flow_style=False, + allow_unicode=True, + ) + return f"---\n{body}---" + + +def _acceptance_line(criterion: AcceptanceCriterion) -> str: + inner: list[str] = [] + if criterion.req_refs: + inner.append(", ".join(criterion.req_refs)) + if criterion.spec_ref: + inner.append(f"spec={criterion.spec_ref}") + paren = f" ({'; '.join(inner)})" if inner else "" + return f"- **{criterion.id}**{paren}: {criterion.text}" + + +def _decision_block(adr: ADR) -> list[str]: + lines = [f"{_H3}{adr.id}{_ADR_SEP}{adr.title}", "", f"- Status: {adr.status}"] + if adr.context is not None: + lines.append(f"- Context: {adr.context}") + if adr.decision is not None: + lines.append(f"- Decision: {adr.decision}") + if adr.consequences is not None: + lines.append(f"- Consequences: {adr.consequences}") + return lines + + +def render_spec_md(manifest: SpecManifest) -> str: + """Render ``manifest`` as ``spec.md`` markdown text (inverse of parse).""" + parts: list[str] = [_frontmatter(manifest), "", f"{_H2}Goal", "", manifest.name] + + if manifest.requirements: + parts += ["", f"{_H2}Requirements", ""] + parts += [f"- **{r.id}**: {r.text}" for r in manifest.requirements] + + if manifest.acceptance_criteria: + parts += ["", f"{_H2}Acceptance Criteria", ""] + parts += [_acceptance_line(a) for a in manifest.acceptance_criteria] + + if manifest.constraints: + parts += ["", f"{_H2}Constraints", ""] + parts += [f"- {c}" for c in manifest.constraints] + + if manifest.open_questions: + parts += ["", f"{_H2}Open Questions", ""] + for q in manifest.open_questions: + parts.append(f"- **{q.id}**: {q.text}") + if q.resolution is not None: + parts.append(f" - Resolution: {q.resolution}") + + if manifest.decisions: + parts += ["", f"{_H2}Decisions"] + for adr in manifest.decisions: + parts.append("") + parts += _decision_block(adr) + + return "\n".join(parts) + "\n" + + +# --------------------------------------------------------------------------- # +# Parse # +# --------------------------------------------------------------------------- # + + +class _Section: + """A ``##`` section: its header line number and (1-based line, text) body.""" + + def __init__(self, title: str, header_line: int) -> None: + self.title = title + self.header_line = header_line + self.lines: list[tuple[int, str]] = [] + + +def _split_frontmatter(lines: list[str]) -> tuple[dict[str, Any], int]: + """Return the parsed frontmatter mapping and the 0-based body start index.""" + idx = 0 + n = len(lines) + while idx < n and lines[idx].strip() == "": + idx += 1 + if idx >= n or lines[idx].strip() != "---": + raise SpecParseError("spec.md must begin with a '---' YAML frontmatter block", line=idx + 1) + open_line = idx + 1 # 1-based line number of the opening '---' + idx += 1 + fm_body: list[str] = [] + while idx < n and lines[idx].strip() != "---": + fm_body.append(lines[idx]) + idx += 1 + if idx >= n: + raise SpecParseError("unterminated frontmatter: missing closing '---'", line=open_line) + try: + data = yaml.safe_load("\n".join(fm_body)) + except yaml.YAMLError as exc: # pragma: no cover - message varies by input + raise SpecParseError(f"invalid YAML frontmatter: {exc}", line=open_line + 1) from exc + data = data or {} + if not isinstance(data, dict): + raise SpecParseError("frontmatter must be a YAML mapping", line=open_line + 1) + return data, idx + 1 # skip the closing '---' + + +def _collect_sections(lines: list[str], start: int) -> list[_Section]: + sections: list[_Section] = [] + current: _Section | None = None + for offset in range(start, len(lines)): + raw = lines[offset] + line_no = offset + 1 + if raw.startswith(_H2): + current = _Section(raw[len(_H2) :].strip(), line_no) + sections.append(current) + continue + if current is None: + if raw.strip() == "": + continue + raise SpecParseError("unexpected content before first '##' section", line=line_no) + current.lines.append((line_no, raw)) + return sections + + +def _nonblank(section: _Section) -> list[tuple[int, str]]: + return [(ln, text) for ln, text in section.lines if text.strip() != ""] + + +def _parse_goal(section: _Section) -> str: + body = "\n".join(text for _, text in section.lines).strip() + if not body: + raise SpecParseError("## Goal section is empty", line=section.header_line) + return body + + +def _parse_requirements(section: _Section) -> list[Requirement]: + out: list[Requirement] = [] + for line_no, text in _nonblank(section): + match = _BOLD_BULLET.match(text) + if not match: + raise SpecParseError("requirement must be '- **ID**: text'", line=line_no) + out.append(Requirement(id=match["id"].strip(), text=match["text"].strip())) + return out + + +def _parse_refs(refs: str | None) -> tuple[list[str], str | None]: + if refs is None: + return [], None + req_refs: list[str] = [] + spec_ref: str | None = None + for part in refs.split(";"): + chunk = part.strip() + if not chunk: + continue + if chunk.startswith("spec="): + spec_ref = chunk[len("spec=") :].strip() or None + else: + req_refs = [ref.strip() for ref in chunk.split(",") if ref.strip()] + return req_refs, spec_ref + + +def _parse_acceptance(section: _Section) -> list[AcceptanceCriterion]: + out: list[AcceptanceCriterion] = [] + for line_no, text in _nonblank(section): + match = _ACCEPT_BULLET.match(text) + if not match: + raise SpecParseError( + "acceptance criterion must be '- **ID** (refs): text'", line=line_no + ) + req_refs, spec_ref = _parse_refs(match["refs"]) + out.append( + AcceptanceCriterion( + id=match["id"].strip(), + text=match["text"].strip(), + req_refs=req_refs, + spec_ref=spec_ref, + ) + ) + return out + + +def _parse_constraints(section: _Section) -> list[str]: + out: list[str] = [] + for line_no, text in _nonblank(section): + if not text.startswith("- "): + raise SpecParseError("constraint must be a '- ' bullet", line=line_no) + out.append(text[2:].strip()) + return out + + +def _parse_open_questions(section: _Section) -> list[OpenQuestion]: + out: list[OpenQuestion] = [] + for line_no, text in _nonblank(section): + resolution = _RESOLUTION.match(text) + if resolution is not None: + if not out: + raise SpecParseError("resolution has no preceding open question", line=line_no) + out[-1] = out[-1].model_copy(update={"resolution": resolution["text"].strip()}) + continue + match = _BOLD_BULLET.match(text) + if not match: + raise SpecParseError("open question must be '- **ID**: text'", line=line_no) + out.append(OpenQuestion(id=match["id"].strip(), text=match["text"].strip())) + return out + + +def _parse_decisions(section: _Section) -> list[ADR]: + out: list[ADR] = [] + fields: dict[str, str] = {} + header: tuple[str, str] | None = None + + def flush() -> None: + nonlocal fields, header + if header is None: + return + adr_id, title = header + out.append(ADR(id=adr_id, title=title, **fields)) + fields = {} + header = None + + for line_no, text in _nonblank(section): + if text.startswith(_H3): + flush() + body = text[len(_H3) :] + if _ADR_SEP not in body: + raise SpecParseError("decision heading must be '### ID — Title'", line=line_no) + adr_id, title = body.split(_ADR_SEP, 1) + header = (adr_id.strip(), title.strip()) + continue + field = _ADR_FIELD.match(text) + if field is None: + raise SpecParseError( + "decision field must be '- Status|Context|Decision|Consequences: text'", + line=line_no, + ) + if header is None: + raise SpecParseError("decision field before any '### ID — Title'", line=line_no) + fields[_ADR_FIELD_ATTR[field["key"]]] = field["val"].strip() + flush() + return out + + +def _parse_section(section: _Section, data: dict[str, Any]) -> None: + """Parse one non-Goal ``##`` section into ``data`` (mutating it in place).""" + if section.title == "Requirements": + data["requirements"] = _parse_requirements(section) + elif section.title == "Acceptance Criteria": + data["acceptance_criteria"] = _parse_acceptance(section) + elif section.title == "Constraints": + data["constraints"] = _parse_constraints(section) + elif section.title == "Open Questions": + data["open_questions"] = _parse_open_questions(section) + elif section.title == "Decisions": + data["decisions"] = _parse_decisions(section) + else: + raise SpecParseError(f"unknown section '## {section.title}'", line=section.header_line) + + +def parse_spec_md(text: str) -> SpecManifest: + """Parse ``spec.md`` markdown ``text`` into a :class:`SpecManifest`. + + The exact inverse of :func:`render_spec_md`. Raises :class:`SpecParseError` + (line-anchored) on malformed input. + """ + lines = text.splitlines() + frontmatter, body_start = _split_frontmatter(lines) + + data: dict[str, Any] = { + key: frontmatter[key] for key in _FRONTMATTER_KEYS if key in frontmatter + } + if "id" not in data: + raise SpecParseError("frontmatter is missing required key 'id'", line=1) + + name: str | None = None + for section in _collect_sections(lines, body_start): + if section.title == "Goal": + name = _parse_goal(section) + continue + _parse_section(section, data) + + if name is None: + raise SpecParseError("spec.md is missing a '## Goal' section (the spec name)", line=1) + data["name"] = name + + try: + return SpecManifest.model_validate(data) + except SpecParseError: + raise + except Exception as exc: # pydantic ValidationError, enum coercion, etc. + raise SpecParseError(f"frontmatter did not validate: {exc}", line=1) from exc + + +__all__ = ["SpecParseError", "parse_spec_md", "render_spec_md"] diff --git a/packages/spec-engine/forge_spec/templates.py b/packages/spec-engine/forge_spec/templates.py index f5da66ae..20f25fc8 100644 --- a/packages/spec-engine/forge_spec/templates.py +++ b/packages/spec-engine/forge_spec/templates.py @@ -16,6 +16,11 @@ ValidationReport, ) +# ``spec.md`` is the dual-format prose surface; its render/parse pair lives in +# ``markdown`` so the two stay exact inverses. Re-exported here (and from the +# package root) for the historical import site. +from forge_spec.markdown import render_spec_md + def _bullets(items: list[str]) -> str: return "\n".join(f"- {item}" for item in items) if items else "_None_" @@ -35,19 +40,6 @@ def render_constitution_md(constitution: Constitution) -> str: return "\n".join(lines) + "\n" -def render_spec_md(manifest: SpecManifest) -> str: - lines = [f"# {manifest.id} — {manifest.name}", "", f"Status: **{manifest.status.value}**", ""] - lines += ["## Requirements", ""] - lines += [f"- **{r.id}**: {r.text}" for r in manifest.requirements] or ["_None_"] - lines += ["", "## Acceptance Criteria", ""] - lines += [ - f"- **{a.id}** (refs: {', '.join(a.req_refs) or '—'}): {a.text}" - for a in manifest.acceptance_criteria - ] or ["_None_"] - lines += ["", "## Constraints", "", _bullets(manifest.constraints), ""] - return "\n".join(lines) + "\n" - - def render_clarify_md(manifest: SpecManifest) -> str: lines = ["# Clarifications", ""] if not manifest.open_questions: diff --git a/packages/spec-engine/tests/test_spec_markdown.py b/packages/spec-engine/tests/test_spec_markdown.py new file mode 100644 index 00000000..f41eb482 --- /dev/null +++ b/packages/spec-engine/tests/test_spec_markdown.py @@ -0,0 +1,282 @@ +"""``spec.md`` dual-format (de)serialization tests for ``forge_spec`` (ss-parser). + +``spec.md`` (prose) and ``manifest.yaml`` (machine) are BOTH canonical, non-lossy +serializations of the one :class:`SpecManifest`. These tests pin: + +- ``parse_spec_md`` is the exact inverse of ``render_spec_md`` + (``parse(render(m)) == m`` and ``render(parse(render(m))) == render(m)``), +- messy input raises a line-anchored :class:`SpecParseError`, +- the YAML path (``load_manifest``/``dump_manifest``) round-trips, and +- a spec.md and its manifest.yaml parse to the *same* ``SpecManifest`` + (cross-format consistency — neither format is lossy). +""" + +from __future__ import annotations + +import pytest + +from forge_contracts import ( + ADR, + AcceptanceCriterion, + ExecutionMode, + OpenQuestion, + Requirement, + SpecManifest, + SpecStatus, +) +from forge_spec import ( + SpecParseError, + dump_manifest, + load_manifest, + parse_spec_md, + render_spec_md, +) + +# --------------------------------------------------------------------------- # +# Fixtures # +# --------------------------------------------------------------------------- # + + +def _full_manifest() -> SpecManifest: + return SpecManifest( + id="SPEC-17", + name="Customer endpoint improvements", + status=SpecStatus.APPROVED, + constitution_refs=["engineering/api-principles", "security/auth"], + repos=["github.com/org/api"], + requirements=[ + Requirement(id="R1", text="Add customer search endpoint"), + Requirement(id="R2", text="Endpoint must support bearer auth"), + ], + acceptance_criteria=[ + AcceptanceCriterion(id="A1", req_refs=["R1"], text="cursor + limit params"), + AcceptanceCriterion( + id="A2", + req_refs=["R1", "R2"], + text="Given no bearer, When called, Then 401", + spec_ref="SPEC-9", + ), + ], + constraints=["No breaking changes before v2", "P99 < 200ms"], + open_questions=[ + OpenQuestion(id="Q1", text="Rate limit policy?"), + OpenQuestion(id="Q2", text="Which regions?", resolution="us-east only"), + ], + decisions=[ + ADR( + id="ADR-1", + title="Use cursor pagination", + status="accepted", + context="Offset pagination is unstable under writes.", + decision="Adopt opaque cursor tokens.", + consequences="Clients cannot random-access pages.", + ), + ADR(id="ADR-2", title="Bare decision"), + ], + plan_ref="plan.md", + tasks_ref="tasks.md", + validation_ref="validation.md", + execution_mode=ExecutionMode.SUPERVISED_MULTI_AGENT, + skill_profile="backend-tdd", + ) + + +def _minimal_manifest() -> SpecManifest: + return SpecManifest(id="SPEC-1", name="Tiny spec") + + +# --------------------------------------------------------------------------- # +# Round-trip: parse(render(m)) == m # +# --------------------------------------------------------------------------- # + + +def test_render_parse_round_trips_full_manifest() -> None: + manifest = _full_manifest() + assert parse_spec_md(render_spec_md(manifest)) == manifest + + +def test_render_parse_round_trips_minimal_manifest() -> None: + manifest = _minimal_manifest() + assert parse_spec_md(render_spec_md(manifest)) == manifest + + +def test_render_is_stable_under_parse_render() -> None: + rendered = render_spec_md(_full_manifest()) + assert render_spec_md(parse_spec_md(rendered)) == rendered + + +def test_status_and_execution_mode_survive_round_trip() -> None: + manifest = _full_manifest() + parsed = parse_spec_md(render_spec_md(manifest)) + assert parsed.status is SpecStatus.APPROVED + assert parsed.execution_mode is ExecutionMode.SUPERVISED_MULTI_AGENT + + +def test_acceptance_req_refs_and_spec_ref_survive_round_trip() -> None: + parsed = parse_spec_md(render_spec_md(_full_manifest())) + a2 = parsed.acceptance_criteria[1] + assert a2.req_refs == ["R1", "R2"] + assert a2.spec_ref == "SPEC-9" + + +def test_open_question_resolution_optional_round_trip() -> None: + parsed = parse_spec_md(render_spec_md(_full_manifest())) + assert parsed.open_questions[0].resolution is None + assert parsed.open_questions[1].resolution == "us-east only" + + +def test_bare_and_full_adr_round_trip() -> None: + parsed = parse_spec_md(render_spec_md(_full_manifest())) + bare = parsed.decisions[1] + assert bare.id == "ADR-2" + assert bare.status == "proposed" # the model default + assert bare.context is None and bare.decision is None and bare.consequences is None + + +def test_unicode_survives_round_trip() -> None: + manifest = SpecManifest( + id="SPEC-42", + name="Über café — naïve façade", + requirements=[Requirement(id="R1", text="Support Ünïcödé — 日本語 ✓")], + ) + assert parse_spec_md(render_spec_md(manifest)) == manifest + + +def test_goal_section_carries_the_name() -> None: + text = render_spec_md(_full_manifest()) + assert "## Goal\n\nCustomer endpoint improvements" in text + + +def test_render_begins_with_frontmatter_fence() -> None: + assert render_spec_md(_minimal_manifest()).startswith("---\n") + + +# --------------------------------------------------------------------------- # +# YAML path + cross-format consistency # +# --------------------------------------------------------------------------- # + + +def test_manifest_yaml_round_trips() -> None: + manifest = _full_manifest() + assert load_manifest(dump_manifest(manifest)) == manifest + + +def test_spec_md_and_manifest_yaml_agree() -> None: + """spec.md and manifest.yaml for the SAME spec parse to the SAME manifest.""" + manifest = _full_manifest() + from_md = parse_spec_md(render_spec_md(manifest)) + from_yaml = load_manifest(dump_manifest(manifest)) + assert from_md == from_yaml == manifest + + +def test_cross_format_consistency_minimal() -> None: + manifest = _minimal_manifest() + assert parse_spec_md(render_spec_md(manifest)) == load_manifest(dump_manifest(manifest)) + + +# --------------------------------------------------------------------------- # +# Messy input: line-anchored SpecParseError # +# --------------------------------------------------------------------------- # + + +def test_missing_frontmatter_raises_at_line_one() -> None: + with pytest.raises(SpecParseError) as exc: + parse_spec_md("## Goal\n\nNo frontmatter here\n") + assert exc.value.line == 1 + + +def test_unterminated_frontmatter_raises() -> None: + with pytest.raises(SpecParseError) as exc: + parse_spec_md("---\nid: SPEC-1\n\n## Goal\n\nX\n") + assert "unterminated" in exc.value.raw_message.lower() + + +def test_frontmatter_not_a_mapping_raises() -> None: + with pytest.raises(SpecParseError): + parse_spec_md("---\n- just\n- a\n- list\n---\n\n## Goal\n\nX\n") + + +def test_missing_id_raises() -> None: + text = "---\nstatus: draft\n---\n\n## Goal\n\nNo id given\n" + with pytest.raises(SpecParseError) as exc: + parse_spec_md(text) + assert "id" in exc.value.raw_message + + +def test_missing_goal_section_raises() -> None: + text = "---\nid: SPEC-1\n---\n\n## Requirements\n\n- **R1**: x\n" + with pytest.raises(SpecParseError) as exc: + parse_spec_md(text) + assert "Goal" in exc.value.raw_message + + +def test_empty_goal_section_raises() -> None: + with pytest.raises(SpecParseError): + parse_spec_md("---\nid: SPEC-1\n---\n\n## Goal\n\n") + + +def test_malformed_requirement_bullet_is_line_anchored() -> None: + text = ( + "---\nid: SPEC-1\n---\n\n" # lines 1(---) 2(id) 3(---) 4(blank) + "## Goal\n\nName\n\n" # lines 5-8 + "## Requirements\n\n" # lines 9-10 + "- R1 missing the bold marker\n" # line 11 + ) + with pytest.raises(SpecParseError) as exc: + parse_spec_md(text) + assert exc.value.line == 11 + + +def test_malformed_acceptance_bullet_raises() -> None: + text = ( + "---\nid: SPEC-1\n---\n\n## Goal\n\nName\n\n" + "## Acceptance Criteria\n\n- plain text with no id\n" + ) + with pytest.raises(SpecParseError) as exc: + parse_spec_md(text) + assert "acceptance" in exc.value.raw_message.lower() + + +def test_unknown_section_raises_at_header_line() -> None: + text = "---\nid: SPEC-1\n---\n\n## Goal\n\nName\n\n## Nonsense\n\n- x\n" + with pytest.raises(SpecParseError) as exc: + parse_spec_md(text) + assert exc.value.line == 9 + assert "Nonsense" in exc.value.raw_message + + +def test_content_before_first_section_raises() -> None: + text = "---\nid: SPEC-1\n---\n\nstray prose\n\n## Goal\n\nName\n" + with pytest.raises(SpecParseError) as exc: + parse_spec_md(text) + assert exc.value.line == 5 + + +def test_malformed_decision_heading_raises() -> None: + text = "---\nid: SPEC-1\n---\n\n## Goal\n\nName\n\n## Decisions\n\n### ADR-1 no separator\n" + with pytest.raises(SpecParseError) as exc: + parse_spec_md(text) + assert "###" in exc.value.raw_message or "heading" in exc.value.raw_message.lower() + + +def test_dangling_resolution_raises() -> None: + text = ( + "---\nid: SPEC-1\n---\n\n## Goal\n\nName\n\n## Open Questions\n\n - Resolution: orphan\n" + ) + with pytest.raises(SpecParseError): + parse_spec_md(text) + + +def test_spec_parse_error_str_includes_line() -> None: + err = SpecParseError("boom", line=7) + assert str(err) == "line 7: boom" + assert err.line == 7 + + +def test_spec_parse_error_is_forge_error_and_value_error() -> None: + from forge_contracts import ForgeError + + err = SpecParseError("boom") + assert isinstance(err, ForgeError) + assert isinstance(err, ValueError) + assert err.line is None From dee72e15763c529c430cb2dfc18165dc49aef11d Mon Sep 17 00:00:00 2001 From: Forge Swarm <swarm@forge.local> Date: Wed, 8 Jul 2026 21:03:53 +0200 Subject: [PATCH 03/20] feat(ss-engine): Spec Studio Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --- packages/spec-engine/forge_spec/__init__.py | 3 +- packages/spec-engine/forge_spec/engine.py | 111 ++++++- packages/spec-engine/forge_spec/errors.py | 14 +- .../tests/test_spec_engine_sync.py | 280 ++++++++++++++++++ 4 files changed, 400 insertions(+), 8 deletions(-) create mode 100644 packages/spec-engine/tests/test_spec_engine_sync.py diff --git a/packages/spec-engine/forge_spec/__init__.py b/packages/spec-engine/forge_spec/__init__.py index 53aa430b..580bc60c 100644 --- a/packages/spec-engine/forge_spec/__init__.py +++ b/packages/spec-engine/forge_spec/__init__.py @@ -42,7 +42,7 @@ 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, @@ -89,6 +89,7 @@ "SpecEngineService", "SpecNotFoundError", "SpecParseError", + "SpecReconcileWarning", "SpecSourcePort", "SpecTraceabilityMatrix", "SpecValidationRow", diff --git a/packages/spec-engine/forge_spec/engine.py b/packages/spec-engine/forge_spec/engine.py index ed0046b9..b09da7c3 100644 --- a/packages/spec-engine/forge_spec/engine.py +++ b/packages/spec-engine/forge_spec/engine.py @@ -15,6 +15,7 @@ from __future__ import annotations import uuid +import warnings from collections.abc import Iterator from pathlib import Path from typing import Any @@ -34,7 +35,7 @@ ) from forge_contracts.dtos import ADR from forge_spec import manifest as manifest_io -from forge_spec.errors import SpecNotFoundError +from forge_spec.errors import SpecNotFoundError, SpecReconcileWarning from forge_spec.gates import check_implementation_gate from forge_spec.ids import ( constitution_id_for, @@ -43,6 +44,7 @@ spec_key, spec_number, ) +from forge_spec.markdown import parse_spec_md from forge_spec.tasks import generate_tasks from forge_spec.templates import ( render_clarify_md, @@ -147,7 +149,6 @@ def spec_create( spec_dir = self.root / spec_dirname(key, name) spec_dir.mkdir(parents=True, exist_ok=True) self._persist(manifest, spec_dir) - self._write(spec_dir / manifest_io.SPEC_FILENAME, render_spec_md(manifest)) return manifest def spec_clarify(self, spec_id: uuid.UUID) -> SpecManifest: @@ -273,7 +274,11 @@ def read_manifest(self, spec_id: uuid.UUID) -> SpecManifest: return manifest def write_manifest(self, manifest: SpecManifest) -> SpecManifest: - """Persist (create or update) a spec manifest, returning it.""" + """Persist (create or update) a spec manifest, returning it. + + Writes BOTH canonical serializations (``manifest.yaml`` and ``spec.md``) + so the two stay in sync — see :meth:`_persist`. + """ spec_dir = self._resolve_dir_optional(spec_id_for_key(manifest.id)) if spec_dir is None: spec_dir = self.root / spec_dirname(manifest.id, manifest.name) @@ -281,6 +286,53 @@ def write_manifest(self, manifest: SpecManifest) -> SpecManifest: self._persist(manifest, spec_dir) return manifest + # ----------------------------------------------------------------- # + # Dual-format editing: spec.md <-> manifest.yaml kept in sync # + # ----------------------------------------------------------------- # + + def read_spec_md(self, spec_id: uuid.UUID) -> str: + """Return the spec's ``spec.md`` prose serialization (always in sync). + + Rendered from the canonical manifest rather than read raw, so callers + get a consistent view even if only ``manifest.yaml`` exists on disk + (manifest-only back-compat) or the two had diverged and were reconciled. + """ + _, manifest = self._resolve(spec_id) + return render_spec_md(manifest) + + def save_spec_md(self, text: str) -> SpecManifest: + """Save a spec edited as ``spec.md`` prose (create or update). + + Parses + validates the markdown, then re-renders BOTH formats so + ``manifest.yaml`` is regenerated to match (+ lifecycle docs are left + untouched; they are regenerated by their lifecycle steps). The spec id + is taken from the document's frontmatter. + """ + manifest = parse_spec_md(text) + return self.write_manifest(manifest) + + def save_manifest_yaml(self, text: str) -> SpecManifest: + """Save a spec edited as ``manifest.yaml`` (create or update). + + The YAML counterpart of :meth:`save_spec_md`: parses + validates the + manifest, then re-renders BOTH formats so ``spec.md`` is regenerated to + match. + """ + manifest = manifest_io.load_manifest(text) + return self.write_manifest(manifest) + + def reconcile(self, spec_id: uuid.UUID) -> SpecManifest: + """Resolve any out-of-band ``spec.md``/``manifest.yaml`` divergence. + + Loads the spec — applying the last-write-wins reconcile rule and + emitting :class:`SpecReconcileWarning` if the two serializations had + diverged — then rewrites BOTH so they are byte-for-byte back in sync + with the winning manifest. Returns the reconciled manifest. + """ + spec_dir, manifest = self._resolve(spec_id) + self._persist(manifest, spec_dir) + return manifest + # ----------------------------------------------------------------- # # Gates / verification (extra public surface) # # ----------------------------------------------------------------- # @@ -333,12 +385,57 @@ def _iter_spec_dirs(self) -> Iterator[Path]: if not self.root.exists(): return for entry in sorted(self.root.iterdir()): - if entry.is_dir() and (entry / manifest_io.MANIFEST_FILENAME).exists(): + if not entry.is_dir(): + continue + # A spec dir is identified by EITHER canonical serialization, so + # manifest-only (legacy) and md-only specs both resolve. + if (entry / manifest_io.MANIFEST_FILENAME).exists() or ( + entry / manifest_io.SPEC_FILENAME + ).exists(): yield entry def _load_dir_manifest(self, spec_dir: Path) -> SpecManifest: - text = (spec_dir / manifest_io.MANIFEST_FILENAME).read_text(encoding="utf-8") - return manifest_io.load_manifest(text) + """Load a spec's canonical manifest, reconciling the two serializations. + + ``manifest.yaml`` and ``spec.md`` are both canonical, non-lossy + serializations of the one :class:`SpecManifest`: + + - only one present -> parse it (manifest-only / md-only back-compat); + - both present and in agreement -> return the manifest; + - both present but *diverged* out-of-band -> last-write-wins by mtime + (:class:`SpecReconcileWarning` is emitted so the loss is visible). + """ + yaml_path = spec_dir / manifest_io.MANIFEST_FILENAME + md_path = spec_dir / manifest_io.SPEC_FILENAME + has_yaml = yaml_path.exists() + has_md = md_path.exists() + + if has_yaml and not has_md: + return manifest_io.load_manifest(yaml_path.read_text(encoding="utf-8")) + if has_md and not has_yaml: + return parse_spec_md(md_path.read_text(encoding="utf-8")) + + from_yaml = manifest_io.load_manifest(yaml_path.read_text(encoding="utf-8")) + from_md = parse_spec_md(md_path.read_text(encoding="utf-8")) + if from_yaml == from_md: + return from_yaml + + # Diverged out-of-band: last-write-wins by mtime. On an exact tie, prefer + # spec.md (the human/agent authoring surface). + md_mtime = md_path.stat().st_mtime + yaml_mtime = yaml_path.stat().st_mtime + md_wins = md_mtime >= yaml_mtime + winner_name = manifest_io.SPEC_FILENAME if md_wins else manifest_io.MANIFEST_FILENAME + loser_name = manifest_io.MANIFEST_FILENAME if md_wins else manifest_io.SPEC_FILENAME + warnings.warn( + SpecReconcileWarning( + f"{spec_dir.name}: spec.md and manifest.yaml diverged; " + f"keeping newer {winner_name} and discarding {loser_name}. " + f"Call reconcile() to rewrite both in sync." + ), + stacklevel=2, + ) + return from_md if md_wins else from_yaml def _resolve(self, spec_id: uuid.UUID) -> tuple[Path, SpecManifest]: result = self._resolve_optional(spec_id) @@ -390,7 +487,9 @@ def _next_spec_number(self) -> int: return highest + 1 def _persist(self, manifest: SpecManifest, spec_dir: Path) -> None: + """Write BOTH canonical serializations so spec.md and manifest.yaml stay in sync.""" self._write(spec_dir / manifest_io.MANIFEST_FILENAME, manifest_io.dump_manifest(manifest)) + self._write(spec_dir / manifest_io.SPEC_FILENAME, render_spec_md(manifest)) @staticmethod def _write(path: Path, text: str) -> None: diff --git a/packages/spec-engine/forge_spec/errors.py b/packages/spec-engine/forge_spec/errors.py index e3664d23..53ec0ced 100644 --- a/packages/spec-engine/forge_spec/errors.py +++ b/packages/spec-engine/forge_spec/errors.py @@ -15,4 +15,16 @@ class SpecNotFoundError(ForgeError, KeyError): """Raised when a spec or task uuid does not resolve to an on-disk spec.""" -__all__ = ["SpecNotFoundError"] +class SpecReconcileWarning(UserWarning): + """Emitted when ``spec.md`` and ``manifest.yaml`` diverge out-of-band. + + Both files are canonical serializations of the one ``SpecManifest``; the + engine keeps them in lockstep on every write. If they are edited + independently (e.g. by hand or by two tools) and no longer parse to the same + manifest, the engine resolves the conflict by *last-write-wins* (the file + with the newer mtime) and raises this warning so the divergence is visible + rather than silently dropped. + """ + + +__all__ = ["SpecNotFoundError", "SpecReconcileWarning"] diff --git a/packages/spec-engine/tests/test_spec_engine_sync.py b/packages/spec-engine/tests/test_spec_engine_sync.py new file mode 100644 index 00000000..cb721545 --- /dev/null +++ b/packages/spec-engine/tests/test_spec_engine_sync.py @@ -0,0 +1,280 @@ +"""Dual-format engine tests for ``forge_spec`` (slice ss-engine). + +``FileSpecEngine`` treats ``spec.md`` (prose) and ``manifest.yaml`` (machine) as +BOTH first-class, EDITABLE serializations of the one canonical +:class:`SpecManifest`. These tests pin the engine-level contract on top of the +already-tested parser/serializer: + +- a spec can be CREATED and EDITED from EITHER format; +- every write keeps the two files in sync (they always parse to the same + manifest); +- manifest-only (legacy) and md-only specs still load (back-compat); +- if the two diverge out-of-band the engine reconciles by last-write-wins + (mtime) and warns. +""" + +from __future__ import annotations + +import os +import uuid + +import pytest + +from forge_contracts import ( + AcceptanceCriterion, + Requirement, + SpecManifest, + SpecStatus, +) +from forge_spec import ( + FileSpecEngine, + SpecReconcileWarning, + dump_manifest, + load_manifest, + parse_spec_md, + render_spec_md, + spec_id_for_key, +) +from forge_spec import manifest as manifest_io + + +@pytest.fixture +def engine(tmp_path) -> FileSpecEngine: + return FileSpecEngine(tmp_path) + + +def _manifest(spec_id: str = "SPEC-1", name: str = "Customer endpoint") -> SpecManifest: + return SpecManifest( + id=spec_id, + name=name, + status=SpecStatus.DRAFT, + requirements=[ + Requirement(id="R1", text="Add customer search endpoint"), + Requirement(id="R2", text="Support bearer auth"), + ], + acceptance_criteria=[ + AcceptanceCriterion(id="A1", req_refs=["R1"], text="cursor + limit params"), + ], + constraints=["No breaking changes before v2"], + ) + + +def _both_files_agree(engine: FileSpecEngine, spec_id: uuid.UUID) -> SpecManifest: + """Assert spec.md and manifest.yaml on disk parse to the SAME manifest; return it.""" + spec_dir = engine.spec_path(spec_id) + md_text = (spec_dir / manifest_io.SPEC_FILENAME).read_text(encoding="utf-8") + yaml_text = (spec_dir / manifest_io.MANIFEST_FILENAME).read_text(encoding="utf-8") + from_md = parse_spec_md(md_text) + from_yaml = load_manifest(yaml_text) + assert from_md == from_yaml + return from_md + + +# --------------------------------------------------------------------------- # +# Create / edit via spec.md # +# --------------------------------------------------------------------------- # + + +def test_create_via_spec_md_writes_both_formats(engine) -> None: + manifest = _manifest("SPEC-1", "Created from md") + saved = engine.save_spec_md(render_spec_md(manifest)) + + assert saved == manifest + spec_id = spec_id_for_key(manifest.id) + spec_dir = engine.spec_path(spec_id) + assert (spec_dir / "spec.md").exists() + assert (spec_dir / "manifest.yaml").exists() + assert _both_files_agree(engine, spec_id) == manifest + + +def test_edit_via_spec_md_updates_manifest_yaml(engine) -> None: + manifest = _manifest("SPEC-1") + engine.save_spec_md(render_spec_md(manifest)) + spec_id = spec_id_for_key(manifest.id) + + edited = manifest.model_copy(update={"constraints": ["Edited: P99 < 200ms", "and via md"]}) + engine.save_spec_md(render_spec_md(edited)) + + reloaded = engine.read_manifest(spec_id) + assert reloaded.constraints == ["Edited: P99 < 200ms", "and via md"] + # The machine format was re-rendered to match the prose edit. + assert _both_files_agree(engine, spec_id) == edited + + +# --------------------------------------------------------------------------- # +# Create / edit via manifest.yaml # +# --------------------------------------------------------------------------- # + + +def test_create_via_manifest_yaml_writes_both_formats(engine) -> None: + manifest = _manifest("SPEC-1", "Created from yaml") + saved = engine.save_manifest_yaml(dump_manifest(manifest)) + + assert saved == manifest + spec_id = spec_id_for_key(manifest.id) + spec_dir = engine.spec_path(spec_id) + assert (spec_dir / "spec.md").exists() + assert (spec_dir / "manifest.yaml").exists() + assert _both_files_agree(engine, spec_id) == manifest + + +def test_edit_via_manifest_yaml_updates_spec_md(engine) -> None: + manifest = _manifest("SPEC-1") + engine.save_manifest_yaml(dump_manifest(manifest)) + spec_id = spec_id_for_key(manifest.id) + + edited = manifest.model_copy( + update={"requirements": [Requirement(id="R1", text="Edited requirement via yaml")]} + ) + engine.save_manifest_yaml(dump_manifest(edited)) + + # spec.md was re-rendered from the yaml edit. + md_text = engine.read_spec_md(spec_id) + assert "Edited requirement via yaml" in md_text + assert parse_spec_md(md_text).requirements[0].text == "Edited requirement via yaml" + assert _both_files_agree(engine, spec_id) == edited + + +# --------------------------------------------------------------------------- # +# md <-> yaml stay consistent across the whole lifecycle # +# --------------------------------------------------------------------------- # + + +def test_lifecycle_keeps_both_formats_in_sync(engine) -> None: + manifest = engine.spec_create(uuid.uuid4(), "Sync me", _manifest().requirements) + spec_id = spec_id_for_key(manifest.id) + + _both_files_agree(engine, spec_id) + engine.spec_clarify(spec_id) + _both_files_agree(engine, spec_id) + engine.spec_plan(spec_id) + _both_files_agree(engine, spec_id) + engine.approve_spec(spec_id) + # After every lifecycle write the prose and machine formats still agree. + assert _both_files_agree(engine, spec_id) == engine.read_manifest(spec_id) + + +def test_read_spec_md_matches_read_manifest(engine) -> None: + manifest = _manifest("SPEC-1") + engine.save_manifest_yaml(dump_manifest(manifest)) + spec_id = spec_id_for_key(manifest.id) + assert parse_spec_md(engine.read_spec_md(spec_id)) == engine.read_manifest(spec_id) + + +# --------------------------------------------------------------------------- # +# Back-compat: manifest-only and md-only specs # +# --------------------------------------------------------------------------- # + + +def test_back_compat_manifest_only_spec_loads(engine) -> None: + manifest = _manifest("SPEC-1", "Legacy manifest-only") + engine.save_manifest_yaml(dump_manifest(manifest)) + spec_id = spec_id_for_key(manifest.id) + # Simulate a legacy spec that predates dual-format: only manifest.yaml. + (engine.spec_path(spec_id) / manifest_io.SPEC_FILENAME).unlink() + + reloaded = FileSpecEngine(engine.root).read_manifest(spec_id) + assert reloaded == manifest + # Rendering its prose surface still works (derived from the manifest). + assert parse_spec_md(FileSpecEngine(engine.root).read_spec_md(spec_id)) == manifest + + +def test_back_compat_md_only_spec_loads(engine) -> None: + manifest = _manifest("SPEC-1", "md-only spec") + engine.save_spec_md(render_spec_md(manifest)) + spec_id = spec_id_for_key(manifest.id) + # Only spec.md on disk (authored purely as prose, no machine sidecar yet). + (engine.spec_path(spec_id) / manifest_io.MANIFEST_FILENAME).unlink() + + reloaded = FileSpecEngine(engine.root).read_manifest(spec_id) + assert reloaded == manifest + + +def test_saving_md_only_spec_regenerates_manifest_yaml(engine) -> None: + manifest = _manifest("SPEC-1", "md-only spec") + engine.save_spec_md(render_spec_md(manifest)) + spec_id = spec_id_for_key(manifest.id) + (engine.spec_path(spec_id) / manifest_io.MANIFEST_FILENAME).unlink() + + fresh = FileSpecEngine(engine.root) + fresh.reconcile(spec_id) + assert (engine.spec_path(spec_id) / manifest_io.MANIFEST_FILENAME).exists() + assert _both_files_agree(fresh, spec_id) == manifest + + +# --------------------------------------------------------------------------- # +# Out-of-band divergence: last-write-wins + warning # +# --------------------------------------------------------------------------- # + + +def _diverge(engine: FileSpecEngine, spec_id: uuid.UUID, *, md_wins: bool) -> SpecManifest: + """Rewrite spec.md with a different manifest and set mtimes so md/yaml wins.""" + spec_dir = engine.spec_path(spec_id) + md_path = spec_dir / manifest_io.SPEC_FILENAME + yaml_path = spec_dir / manifest_io.MANIFEST_FILENAME + base = engine.read_manifest(spec_id) + hand_edited = base.model_copy(update={"name": "Edited by hand out of band"}) + md_path.write_text(render_spec_md(hand_edited), encoding="utf-8") + # Make the winner's mtime strictly newer. + if md_wins: + os.utime(yaml_path, (1_000_000, 1_000_000)) + os.utime(md_path, (2_000_000, 2_000_000)) + else: + os.utime(md_path, (1_000_000, 1_000_000)) + os.utime(yaml_path, (2_000_000, 2_000_000)) + return hand_edited + + +def test_divergence_last_write_wins_md_newer(engine) -> None: + manifest = _manifest("SPEC-1", "Original") + engine.save_manifest_yaml(dump_manifest(manifest)) + spec_id = spec_id_for_key(manifest.id) + hand_edited = _diverge(engine, spec_id, md_wins=True) + + with pytest.warns(SpecReconcileWarning): + resolved = FileSpecEngine(engine.root).read_manifest(spec_id) + assert resolved.name == hand_edited.name == "Edited by hand out of band" + + +def test_divergence_last_write_wins_yaml_newer(engine) -> None: + manifest = _manifest("SPEC-1", "Original") + engine.save_manifest_yaml(dump_manifest(manifest)) + spec_id = spec_id_for_key(manifest.id) + _diverge(engine, spec_id, md_wins=False) + + with pytest.warns(SpecReconcileWarning): + resolved = FileSpecEngine(engine.root).read_manifest(spec_id) + # manifest.yaml is newer -> it wins, the hand md edit is discarded. + assert resolved.name == "Original" + + +def test_reconcile_rewrites_both_in_sync(engine) -> None: + manifest = _manifest("SPEC-1", "Original") + engine.save_manifest_yaml(dump_manifest(manifest)) + spec_id = spec_id_for_key(manifest.id) + _diverge(engine, spec_id, md_wins=True) + + fresh = FileSpecEngine(engine.root) + with pytest.warns(SpecReconcileWarning): + winner = fresh.reconcile(spec_id) + + assert winner.name == "Edited by hand out of band" + # After reconcile the two files agree again -> no further warning. + import warnings as _warnings + + with _warnings.catch_warnings(): + _warnings.simplefilter("error") + assert _both_files_agree(fresh, spec_id) == winner + + +def test_in_sync_files_do_not_warn(engine) -> None: + manifest = _manifest("SPEC-1") + engine.save_manifest_yaml(dump_manifest(manifest)) + spec_id = spec_id_for_key(manifest.id) + + import warnings as _warnings + + with _warnings.catch_warnings(): + _warnings.simplefilter("error") + # A normal read of an in-sync spec must never trigger a reconcile warning. + FileSpecEngine(engine.root).read_manifest(spec_id) From 26c938fb2f2ba01f4329308ce66ed0d71018db7e Mon Sep 17 00:00:00 2001 From: Forge Swarm <swarm@forge.local> Date: Wed, 8 Jul 2026 22:21:49 +0200 Subject: [PATCH 04/20] feat(ss-endpoints): Spec Studio Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --- apps/api/forge_api/routers/spec.py | 73 ++++++++++++ apps/api/tests/test_spec_router.py | 121 ++++++++++++++++++++ apps/web/src/lib/api/client-spec.test.tsx | 130 ++++++++++++++++++++++ apps/web/src/lib/api/client.ts | 91 +++++++++++++++ packages/spec-engine/forge_spec/engine.py | 11 ++ 5 files changed, 426 insertions(+) create mode 100644 apps/web/src/lib/api/client-spec.test.tsx diff --git a/apps/api/forge_api/routers/spec.py b/apps/api/forge_api/routers/spec.py index 58705a73..5f2da2b4 100644 --- a/apps/api/forge_api/routers/spec.py +++ b/apps/api/forge_api/routers/spec.py @@ -21,6 +21,7 @@ from typing import Annotated from fastapi import APIRouter, Depends, HTTPException, status +from fastapi.responses import PlainTextResponse from pydantic import BaseModel, Field from forge_api.auth.rbac import Permission @@ -131,6 +132,12 @@ class SpecCreateRequest(BaseModel): requirements: list[Requirement] = Field(default_factory=list) +class TextContent(BaseModel): + """Body for the ``spec.md`` / ``manifest.yaml`` write endpoints.""" + + content: str + + # --------------------------------------------------------------------------- # # Routes # # --------------------------------------------------------------------------- # @@ -172,6 +179,71 @@ def write_manifest(engine: EngineDep, spec_id: uuid.UUID, manifest: SpecManifest return engine.write_manifest(manifest) +@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(): + 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) -> 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}``. + """ + with _spec_errors(): + engine.read_manifest(spec_id) + return engine.save_spec_md(body.content) + + +@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 +) -> 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). + """ + with _spec_errors(): + return engine.save_manifest_yaml(body.content) + + +@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]) def spec_clarify(engine: EngineDep, spec_id: uuid.UUID) -> SpecManifest: """Run the clarification pass.""" @@ -291,6 +363,7 @@ def project_spec_overview( "SpecDashboard", "SpecEngineRegistry", "SpecOverview", + "TextContent", "get_spec_engine", "get_spec_registry", "project_router", diff --git a/apps/api/tests/test_spec_router.py b/apps/api/tests/test_spec_router.py index 9d2b8b26..f5fff25d 100644 --- a/apps/api/tests/test_spec_router.py +++ b/apps/api/tests/test_spec_router.py @@ -70,6 +70,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/web/src/lib/api/client-spec.test.tsx b/apps/web/src/lib/api/client-spec.test.tsx new file mode 100644 index 00000000..58369690 --- /dev/null +++ b/apps/web/src/lib/api/client-spec.test.tsx @@ -0,0 +1,130 @@ +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("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"); + }); +}); diff --git a/apps/web/src/lib/api/client.ts b/apps/web/src/lib/api/client.ts index d37ac173..5c7a8f46 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, @@ -91,6 +93,7 @@ import type { SprintReport, TaskDTO, TaskStatus, + ValidationReport, VelocityDashboard, HrdDiscoverRequest, HrdDiscoverResponse, @@ -399,6 +402,71 @@ export class ForgeApiClient { ); } + /** Create a draft spec for an epic (SDD lifecycle entry point). */ + createSpec(body: { + epic_id: string; + name: string; + requirements?: Requirement[]; + }): Promise<SpecManifest> { + return this.request<SpecManifest>("/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<string> { + return this.request<string>( + `/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<SpecManifest> { + return this.request<SpecManifest>( + `/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<string> { + return this.request<string>( + `/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<SpecManifest> { + return this.request<SpecManifest>( + `/spec/specs/${encodeURIComponent(specId)}/manifest`, + { method: "PUT", body: { content } }, + ); + } + + /** Run the clarification pass: surface + resolve open questions. */ + clarifySpec(specId: string): Promise<SpecManifest> { + return this.request<SpecManifest>( + `/spec/specs/${encodeURIComponent(specId)}/clarify`, + { method: "POST" }, + ); + } + + /** Generate the technical plan + ADRs. */ + planSpec(specId: string): Promise<SpecManifest> { + return this.request<SpecManifest>( + `/spec/specs/${encodeURIComponent(specId)}/plan`, + { method: "POST" }, + ); + } + /** Approve a spec — the human gate that advances it out of clarification. */ approveSpec(specId: string): Promise<SpecManifest> { return this.request<SpecManifest>( @@ -407,6 +475,29 @@ export class ForgeApiClient { ); } + /** Generate implementation tasks from an *approved* spec (409 if not). */ + generateTasks(specId: string): Promise<TaskDTO[]> { + return this.request<TaskDTO[]>( + `/spec/specs/${encodeURIComponent(specId)}/tasks`, + { method: "POST" }, + ); + } + + /** Validate a task against its spec (requirement-to-test traceability). */ + validateTask(taskId: string): Promise<ValidationReport> { + return this.request<ValidationReport>( + `/spec/tasks/${encodeURIComponent(taskId)}/validate`, + { method: "POST" }, + ); + } + + /** Read a project's constitution (404 if it was never initialised). */ + getConstitution(projectId: string): Promise<Constitution> { + return this.request<Constitution>( + `/spec/constitution/${encodeURIComponent(projectId)}`, + ); + } + // --- Onboarding / guided walkthrough ------------------------------------ // /** diff --git a/packages/spec-engine/forge_spec/engine.py b/packages/spec-engine/forge_spec/engine.py index b09da7c3..a60a377d 100644 --- a/packages/spec-engine/forge_spec/engine.py +++ b/packages/spec-engine/forge_spec/engine.py @@ -300,6 +300,17 @@ def read_spec_md(self, spec_id: uuid.UUID) -> str: _, manifest = self._resolve(spec_id) return render_spec_md(manifest) + def read_manifest_yaml(self, spec_id: uuid.UUID) -> str: + """Return the spec's ``manifest.yaml`` serialization (always in sync). + + The YAML counterpart of :meth:`read_spec_md`: rendered from the + canonical manifest so callers get a consistent view even when only + ``spec.md`` exists on disk (md-only back-compat) or the two had + diverged and were reconciled. + """ + _, manifest = self._resolve(spec_id) + return manifest_io.dump_manifest(manifest) + def save_spec_md(self, text: str) -> SpecManifest: """Save a spec edited as ``spec.md`` prose (create or update). From 899a5e97312ed1b4b07639c9fe0244ca560ddb39 Mon Sep 17 00:00:00 2001 From: Forge Swarm <swarm@forge.local> Date: Wed, 8 Jul 2026 22:58:15 +0200 Subject: [PATCH 05/20] feat(ss-yaml): Spec Studio Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --- apps/web/package.json | 3 +- .../components/spec-studio/guided-mode.tsx | 179 +++++++++++ .../components/spec-studio/markdown-mode.tsx | 53 ++++ .../spec-studio/spec-studio.test.tsx | 164 ++++++++++ .../components/spec-studio/spec-studio.tsx | 211 ++++++++++++ .../components/spec-studio/yaml-mode.test.tsx | 71 +++++ .../src/components/spec-studio/yaml-mode.tsx | 139 ++++++++ .../components/spec/spec-dashboard.test.tsx | 15 + .../src/components/spec/spec-dashboard.tsx | 6 +- apps/web/src/lib/api/client-spec.test.tsx | 19 ++ apps/web/src/lib/api/client.ts | 12 + apps/web/src/lib/api/spec-studio.ts | 112 +++++++ .../src/lib/spec-studio/yaml-schema.test.ts | 93 ++++++ apps/web/src/lib/spec-studio/yaml-schema.ts | 299 ++++++++++++++++++ pnpm-lock.yaml | 33 +- 15 files changed, 1396 insertions(+), 13 deletions(-) create mode 100644 apps/web/src/components/spec-studio/guided-mode.tsx create mode 100644 apps/web/src/components/spec-studio/markdown-mode.tsx create mode 100644 apps/web/src/components/spec-studio/spec-studio.test.tsx create mode 100644 apps/web/src/components/spec-studio/spec-studio.tsx create mode 100644 apps/web/src/components/spec-studio/yaml-mode.test.tsx create mode 100644 apps/web/src/components/spec-studio/yaml-mode.tsx create mode 100644 apps/web/src/lib/api/spec-studio.ts create mode 100644 apps/web/src/lib/spec-studio/yaml-schema.test.ts create mode 100644 apps/web/src/lib/spec-studio/yaml-schema.ts 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/components/spec-studio/guided-mode.tsx b/apps/web/src/components/spec-studio/guided-mode.tsx new file mode 100644 index 00000000..c93ce230 --- /dev/null +++ b/apps/web/src/components/spec-studio/guided-mode.tsx @@ -0,0 +1,179 @@ +"use client"; + +import { Plus, Trash2 } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { SPEC_STATUSES, type SpecManifest, type SpecStatus } from "@/lib/api/types"; + +export interface GuidedModeProps { + /** The current draft manifest (controlled). */ + value: SpecManifest; + onChange: (next: SpecManifest) => void; + onSave: () => void; + saving?: boolean; + dirty?: boolean; + saveError?: string | null; +} + +/** + * The Guided mode — a structured form over the same `SpecManifest` the + * Markdown and YAML modes edit. The friendliest surface: name, status, + * requirements and constraints as plain fields/lists rather than prose or + * YAML syntax. + */ +export function GuidedMode({ value, onChange, onSave, saving = false, dirty = false, saveError }: GuidedModeProps) { + const requirements = value.requirements ?? []; + const constraints = value.constraints ?? []; + + return ( + <div className="flex flex-col gap-5" data-testid="guided-mode"> + <div className="flex items-center justify-between gap-3"> + <span className="text-xs text-muted-foreground"> + {dirty ? "Unsaved changes" : "Guided"} + </span> + <Button size="sm" onClick={onSave} disabled={saving || !dirty} data-testid="guided-save"> + {saving ? "Saving…" : "Save"} + </Button> + </div> + + <label className="flex flex-col gap-1.5 text-sm"> + <span className="font-medium text-foreground">Name</span> + <input + data-testid="guided-name" + value={value.name} + onChange={(event) => onChange({ ...value, name: event.target.value })} + className="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" + /> + </label> + + <label className="flex flex-col gap-1.5 text-sm"> + <span className="font-medium text-foreground">Status</span> + <select + data-testid="guided-status" + value={value.status ?? "draft"} + onChange={(event) => onChange({ ...value, status: event.target.value as SpecStatus })} + className="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" + > + {SPEC_STATUSES.map((status) => ( + <option key={status} value={status}> + {status} + </option> + ))} + </select> + </label> + + <section className="flex flex-col gap-2"> + <h3 className="font-display text-sm font-semibold tracking-tight text-foreground"> + Requirements + </h3> + <ul className="flex flex-col gap-2" data-testid="guided-requirements"> + {requirements.map((req, index) => ( + <li key={req.id || index} className="flex items-center gap-2"> + <input + aria-label={`Requirement ${index + 1} id`} + value={req.id} + onChange={(event) => { + const next = [...requirements]; + next[index] = { ...next[index], id: event.target.value }; + onChange({ ...value, requirements: next }); + }} + className="w-20 shrink-0 rounded-md border border-border bg-card px-2 py-1.5 font-mono text-xs text-foreground outline-none" + /> + <input + aria-label={`Requirement ${index + 1} text`} + value={req.text} + onChange={(event) => { + const next = [...requirements]; + next[index] = { ...next[index], text: event.target.value }; + onChange({ ...value, requirements: next }); + }} + className="flex-1 rounded-md border border-border bg-card px-3 py-1.5 text-sm text-foreground outline-none" + /> + <button + type="button" + aria-label={`Remove requirement ${index + 1}`} + onClick={() => + onChange({ + ...value, + requirements: requirements.filter((_, i) => i !== index), + }) + } + className="rounded-md p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground" + > + <Trash2 className="h-4 w-4" aria-hidden /> + </button> + </li> + ))} + </ul> + <Button + type="button" + variant="outline" + size="sm" + className="w-fit" + data-testid="guided-add-requirement" + onClick={() => + onChange({ + ...value, + requirements: [ + ...requirements, + { id: `R${requirements.length + 1}`, text: "" }, + ], + }) + } + > + <Plus className="h-4 w-4" aria-hidden /> + Add requirement + </Button> + </section> + + <section className="flex flex-col gap-2"> + <h3 className="font-display text-sm font-semibold tracking-tight text-foreground"> + Constraints + </h3> + <ul className="flex flex-col gap-2" data-testid="guided-constraints"> + {constraints.map((constraint, index) => ( + <li key={index} className="flex items-center gap-2"> + <input + aria-label={`Constraint ${index + 1}`} + value={constraint} + onChange={(event) => { + const next = [...constraints]; + next[index] = event.target.value; + onChange({ ...value, constraints: next }); + }} + className="flex-1 rounded-md border border-border bg-card px-3 py-1.5 text-sm text-foreground outline-none" + /> + <button + type="button" + aria-label={`Remove constraint ${index + 1}`} + onClick={() => + onChange({ ...value, constraints: constraints.filter((_, i) => i !== index) }) + } + className="rounded-md p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground" + > + <Trash2 className="h-4 w-4" aria-hidden /> + </button> + </li> + ))} + </ul> + <Button + type="button" + variant="outline" + size="sm" + className="w-fit" + data-testid="guided-add-constraint" + onClick={() => onChange({ ...value, constraints: [...constraints, ""] })} + > + <Plus className="h-4 w-4" aria-hidden /> + Add constraint + </Button> + </section> + + {saveError ? ( + <p role="alert" className="text-xs text-danger" data-testid="guided-save-error"> + {saveError} + </p> + ) : null} + </div> + ); +} 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..3ba7229d --- /dev/null +++ b/apps/web/src/components/spec-studio/markdown-mode.tsx @@ -0,0 +1,53 @@ +"use client"; + +import { Button } from "@/components/ui/button"; + +export interface MarkdownModeProps { + /** The current `spec.md` text (controlled). */ + value: string; + onChange: (next: string) => void; + onSave: () => void; + saving?: boolean; + dirty?: boolean; + saveError?: string | null; +} + +/** + * The `spec.md` prose editor — Spec Studio's default human/agent surface. + * A plain textarea (no schema gate: prose is forgiving); saving re-renders + * `manifest.yaml` to match on the backend. + */ +export function MarkdownMode({ + value, + onChange, + onSave, + saving = false, + dirty = false, + saveError, +}: MarkdownModeProps) { + return ( + <div className="flex flex-col gap-3" data-testid="markdown-mode"> + <div className="flex items-center justify-between gap-3"> + <span className="text-xs text-muted-foreground"> + {dirty ? "Unsaved changes" : "spec.md"} + </span> + <Button size="sm" onClick={onSave} disabled={saving || !dirty} data-testid="markdown-save"> + {saving ? "Saving…" : "Save spec.md"} + </Button> + </div> + <textarea + data-testid="markdown-textarea" + aria-label="spec.md" + spellCheck={false} + value={value} + onChange={(event) => onChange(event.target.value)} + className="min-h-[24rem] resize-none rounded-lg border border-border bg-card px-3 py-3 font-mono text-xs leading-5 text-foreground outline-none" + /> + {saveError ? ( + <p role="alert" className="text-xs text-danger" data-testid="markdown-save-error"> + {saveError} + </p> + ) : null} + </div> + ); +} diff --git a/apps/web/src/components/spec-studio/spec-studio.test.tsx b/apps/web/src/components/spec-studio/spec-studio.test.tsx new file mode 100644 index 00000000..96201041 --- /dev/null +++ b/apps/web/src/components/spec-studio/spec-studio.test.tsx @@ -0,0 +1,164 @@ +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 { SpecManifest } from "@/lib/api/types"; + +import { SpecStudio } from "./spec-studio"; + +function renderStudio(client: ForgeApiClient, specId = "SPEC-1") { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + function Wrapper({ children }: { children: ReactNode }) { + return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>; + } + return render(<SpecStudio specId={specId} client={client} />, { wrapper: Wrapper }); +} + +const manifest: SpecManifest = { + id: "SPEC-1", + name: "Passwordless auth", + status: "draft", + requirements: [{ id: "R1", text: "Sign in without a password" }], + constraints: [], +}; + +const specMd = "---\nid: SPEC-1\n---\n\n## Goal\n\nPasswordless auth\n"; +const manifestYaml = "id: SPEC-1\nname: Passwordless auth\nstatus: draft\n"; + +function makeClient(overrides: Partial<ForgeApiClient> = {}): ForgeApiClient { + return { + getSpecManifest: vi.fn(() => Promise.resolve(manifest)), + getSpecMarkdown: vi.fn(() => Promise.resolve(specMd)), + getSpecManifestYaml: vi.fn(() => Promise.resolve(manifestYaml)), + putSpecManifest: vi.fn((_id: string, m: SpecManifest) => Promise.resolve(m)), + putSpecMarkdown: vi.fn(() => Promise.resolve(manifest)), + putSpecManifestYaml: vi.fn(() => Promise.resolve(manifest)), + ...overrides, + } as unknown as ForgeApiClient; +} + +describe("SpecStudio", () => { + it("loads the manifest and defaults to Guided mode", async () => { + renderStudio(makeClient()); + expect(await screen.findByTestId("guided-mode")).toBeInTheDocument(); + expect(screen.getByTestId("guided-name")).toHaveValue("Passwordless auth"); + }); + + it("switches to Markdown mode and lazily loads spec.md", async () => { + const client = makeClient(); + renderStudio(client); + await screen.findByTestId("guided-mode"); + + fireEvent.click(screen.getByTestId("studio-mode-markdown")); + + expect(await screen.findByTestId("markdown-mode")).toBeInTheDocument(); + expect(screen.getByTestId("markdown-textarea")).toHaveValue(specMd); + expect(client.getSpecMarkdown).toHaveBeenCalledWith("SPEC-1"); + }); + + it("switches to YAML mode and lazily loads manifest.yaml with live validation", async () => { + const client = makeClient(); + renderStudio(client); + await screen.findByTestId("guided-mode"); + + fireEvent.click(screen.getByTestId("studio-mode-yaml")); + + expect(await screen.findByTestId("yaml-mode")).toBeInTheDocument(); + expect(screen.getByTestId("yaml-status-valid")).toBeInTheDocument(); + expect(client.getSpecManifestYaml).toHaveBeenCalledWith("SPEC-1"); + }); + + it("switches to Read mode showing the rendered manifest panel", async () => { + renderStudio(makeClient()); + await screen.findByTestId("guided-mode"); + + fireEvent.click(screen.getByTestId("studio-mode-read")); + + expect(await screen.findByTestId("manifest-panel")).toBeInTheDocument(); + }); + + it("preserves unsaved YAML edits when switching away and back", async () => { + renderStudio(makeClient()); + await screen.findByTestId("guided-mode"); + + fireEvent.click(screen.getByTestId("studio-mode-yaml")); + const textarea = await screen.findByTestId("yaml-textarea"); + fireEvent.change(textarea, { + target: { value: "id: SPEC-1\nname: Renamed via YAML\nstatus: draft\n" }, + }); + + fireEvent.click(screen.getByTestId("studio-mode-guided")); + fireEvent.click(screen.getByTestId("studio-mode-yaml")); + + expect(await screen.findByTestId("yaml-textarea")).toHaveValue( + "id: SPEC-1\nname: Renamed via YAML\nstatus: draft\n", + ); + }); + + it("saving in YAML mode invalidates the Markdown buffer so it reloads fresh, synced text", async () => { + const client = makeClient({ + putSpecManifestYaml: vi.fn(() => + Promise.resolve({ ...manifest, name: "Renamed via YAML" }), + ), + getSpecMarkdown: vi + .fn() + .mockResolvedValueOnce(specMd) + .mockResolvedValueOnce("---\nid: SPEC-1\n---\n\n## Goal\n\nRenamed via YAML\n"), + }); + renderStudio(client); + await screen.findByTestId("guided-mode"); + + // Visit markdown once so it's cached, then switch to yaml and save. + fireEvent.click(screen.getByTestId("studio-mode-markdown")); + await screen.findByTestId("markdown-textarea"); + fireEvent.click(screen.getByTestId("studio-mode-yaml")); + const textarea = await screen.findByTestId("yaml-textarea"); + fireEvent.change(textarea, { + target: { value: "id: SPEC-1\nname: Renamed via YAML\nstatus: draft\n" }, + }); + + fireEvent.click(screen.getByTestId("yaml-save")); + await waitFor(() => expect(client.putSpecManifestYaml).toHaveBeenCalled()); + + fireEvent.click(screen.getByTestId("studio-mode-markdown")); + await waitFor(() => expect(client.getSpecMarkdown).toHaveBeenCalledTimes(2)); + expect(await screen.findByTestId("markdown-textarea")).toHaveValue( + "---\nid: SPEC-1\n---\n\n## Goal\n\nRenamed via YAML\n", + ); + }); + + it("saves a Guided-mode edit via putSpecManifest", async () => { + const client = makeClient(); + renderStudio(client); + await screen.findByTestId("guided-mode"); + + fireEvent.change(screen.getByTestId("guided-name"), { + target: { value: "Passwordless auth v2" }, + }); + expect(screen.getByTestId("guided-save")).toBeEnabled(); + fireEvent.click(screen.getByTestId("guided-save")); + + await waitFor(() => + expect(client.putSpecManifest).toHaveBeenCalledWith( + "SPEC-1", + expect.objectContaining({ name: "Passwordless auth v2" }), + ), + ); + }); + + it("disables the YAML save button and surfaces errors for an invalid manifest", async () => { + renderStudio(makeClient()); + await screen.findByTestId("guided-mode"); + fireEvent.click(screen.getByTestId("studio-mode-yaml")); + const textarea = await screen.findByTestId("yaml-textarea"); + + fireEvent.change(textarea, { target: { value: "id: SPEC-1\nname: X\nstatus: bogus\n" } }); + + expect(screen.getByTestId("yaml-status-invalid")).toBeInTheDocument(); + expect(screen.getByTestId("yaml-save")).toBeDisabled(); + }); +}); diff --git a/apps/web/src/components/spec-studio/spec-studio.tsx b/apps/web/src/components/spec-studio/spec-studio.tsx new file mode 100644 index 00000000..6ec36d2e --- /dev/null +++ b/apps/web/src/components/spec-studio/spec-studio.tsx @@ -0,0 +1,211 @@ +"use client"; + +import { Eye, FileCode2, FileText, ListTree } from "lucide-react"; +import { useState } from "react"; + +import { ManifestPanel } from "@/components/spec/manifest-panel"; +import { apiClient, ApiError, type ForgeApiClient } from "@/lib/api/client"; +import { + useSaveGuidedManifest, + useSaveSpecMarkdown, + useSaveSpecManifestYaml, + useSpecStudioManifest, + useSpecStudioMarkdown, + useSpecStudioYaml, +} from "@/lib/api/spec-studio"; +import type { SpecManifest } from "@/lib/api/types"; +import { cn } from "@/lib/utils"; + +import { GuidedMode } from "./guided-mode"; +import { MarkdownMode } from "./markdown-mode"; +import { YamlMode } from "./yaml-mode"; + +export type SpecStudioMode = "guided" | "markdown" | "yaml" | "read"; + +const MODES: { id: SpecStudioMode; label: string; icon: typeof ListTree }[] = [ + { id: "guided", label: "Guided", icon: ListTree }, + { id: "markdown", label: "Markdown", icon: FileText }, + { id: "yaml", label: "YAML", icon: FileCode2 }, + { id: "read", label: "Read", icon: Eye }, +]; + +export interface SpecStudioProps { + specId: string; + client?: ForgeApiClient; +} + +function errorMessage(error: unknown): string { + if (error instanceof ApiError) return error.message; + if (error instanceof Error) return error.message; + return "Something went wrong"; +} + +/** + * Spec Studio — the spec-authoring surface over one canonical `SpecManifest`, + * editable from four modes: **Guided** (a structured form), **Markdown** + * (`spec.md` prose — the default human/agent surface), **YAML** + * (`manifest.yaml` — the precise machine/CI/agent surface, schema-aware with + * live validation), and **Read** (the rendered, read-only manifest). + * + * All three editable modes round-trip through the same `SpecManifest` on the + * backend (`forge_spec.FileSpecEngine`): saving in any one re-renders the + * other two to match. Each mode keeps its own uncommitted-edit "override" in + * local state (not the query cache) so switching tabs never discards unsaved + * work; a successful save clears that mode's override and invalidates the + * *other* two modes' queries, so the next visit reloads the freshly synced + * text rather than stale content. + */ +export function SpecStudio({ specId, client = apiClient }: SpecStudioProps) { + const [mode, setMode] = useState<SpecStudioMode>("guided"); + + // Reset per-spec overrides during render when `specId` changes (React's + // "adjust state while rendering" pattern for resetting state on a prop + // change) rather than in an effect. + const [activeSpecId, setActiveSpecId] = useState(specId); + const [guidedOverride, setGuidedOverride] = useState<SpecManifest | null>(null); + const [markdownOverride, setMarkdownOverride] = useState<string | null>(null); + const [yamlOverride, setYamlOverride] = useState<string | null>(null); + if (specId !== activeSpecId) { + setActiveSpecId(specId); + setGuidedOverride(null); + setMarkdownOverride(null); + setYamlOverride(null); + } + + const manifestQuery = useSpecStudioManifest(specId, client); + const markdownQuery = useSpecStudioMarkdown(specId, mode === "markdown", client); + const yamlQuery = useSpecStudioYaml(specId, mode === "yaml", client); + + const saveGuided = useSaveGuidedManifest(specId, client); + const saveMarkdown = useSaveSpecMarkdown(specId, client); + const saveYaml = useSaveSpecManifestYaml(specId, client); + + const manifest = manifestQuery.data ?? null; + const guidedValue = guidedOverride ?? manifest; + const guidedDirty = Boolean( + manifest && guidedOverride && JSON.stringify(manifest) !== JSON.stringify(guidedOverride), + ); + + const markdownValue = markdownOverride ?? markdownQuery.data ?? null; + const markdownDirty = Boolean( + markdownQuery.data !== undefined && markdownOverride !== null && markdownOverride !== markdownQuery.data, + ); + + const yamlValue = yamlOverride ?? yamlQuery.data ?? null; + const yamlDirty = Boolean( + yamlQuery.data !== undefined && yamlOverride !== null && yamlOverride !== yamlQuery.data, + ); + + const loadError = manifestQuery.isError + ? errorMessage(manifestQuery.error) + : mode === "markdown" && markdownQuery.isError + ? errorMessage(markdownQuery.error) + : mode === "yaml" && yamlQuery.isError + ? errorMessage(yamlQuery.error) + : null; + + return ( + <div className="flex flex-col gap-4" data-testid="spec-studio"> + <div + role="tablist" + aria-label="Spec Studio mode" + className="inline-flex w-fit items-center gap-1 rounded-lg border border-border bg-muted/50 p-1" + > + {MODES.map((m) => { + const Icon = m.icon; + const selected = m.id === mode; + return ( + <button + key={m.id} + role="tab" + type="button" + aria-selected={selected} + onClick={() => setMode(m.id)} + data-testid={`studio-mode-${m.id}`} + className={cn( + "inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors", + "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", + selected ? "bg-card text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground", + )} + > + <Icon className="h-4 w-4" aria-hidden /> + {m.label} + </button> + ); + })} + </div> + + {loadError ? ( + <p role="status" data-testid="studio-error" className="text-xs text-muted-foreground"> + {loadError} + </p> + ) : null} + + {manifestQuery.isLoading || !manifest ? ( + <p className="text-sm text-muted-foreground" data-testid="studio-loading"> + Loading spec… + </p> + ) : ( + <> + {mode === "guided" && guidedValue ? ( + <GuidedMode + value={guidedValue} + onChange={setGuidedOverride} + onSave={() => { + if (guidedOverride) { + saveGuided.mutate(guidedOverride, { onSuccess: () => setGuidedOverride(null) }); + } + }} + saving={saveGuided.isPending} + dirty={guidedDirty} + saveError={saveGuided.isError ? errorMessage(saveGuided.error) : null} + /> + ) : null} + {mode === "markdown" ? ( + markdownQuery.isLoading || markdownValue === null ? ( + <p className="text-sm text-muted-foreground" data-testid="markdown-loading"> + Loading spec.md… + </p> + ) : ( + <MarkdownMode + value={markdownValue} + onChange={setMarkdownOverride} + onSave={() => { + if (markdownOverride !== null) { + saveMarkdown.mutate(markdownOverride, { + onSuccess: () => setMarkdownOverride(null), + }); + } + }} + saving={saveMarkdown.isPending} + dirty={markdownDirty} + saveError={saveMarkdown.isError ? errorMessage(saveMarkdown.error) : null} + /> + ) + ) : null} + {mode === "yaml" ? ( + yamlQuery.isLoading || yamlValue === null ? ( + <p className="text-sm text-muted-foreground" data-testid="yaml-loading"> + Loading manifest.yaml… + </p> + ) : ( + <YamlMode + value={yamlValue} + onChange={setYamlOverride} + onSave={() => { + if (yamlOverride !== null) { + saveYaml.mutate(yamlOverride, { onSuccess: () => setYamlOverride(null) }); + } + }} + saving={saveYaml.isPending} + dirty={yamlDirty} + saveError={saveYaml.isError ? errorMessage(saveYaml.error) : null} + /> + ) + ) : null} + {mode === "read" ? <ManifestPanel spec={manifest} /> : null} + </> + )} + </div> + ); +} diff --git a/apps/web/src/components/spec-studio/yaml-mode.test.tsx b/apps/web/src/components/spec-studio/yaml-mode.test.tsx new file mode 100644 index 00000000..1c23720a --- /dev/null +++ b/apps/web/src/components/spec-studio/yaml-mode.test.tsx @@ -0,0 +1,71 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { useState } from "react"; +import { describe, expect, it, vi } from "vitest"; + +import { YamlMode } from "./yaml-mode"; + +const VALID = `id: SPEC-1\nname: Passwordless auth\nstatus: draft\n`; + +function Harness({ initial }: { initial: string }) { + const [value, setValue] = useState(initial); + return ( + <YamlMode + value={value} + onChange={setValue} + onSave={vi.fn()} + dirty={value !== initial} + /> + ); +} + +describe("YamlMode", () => { + it("shows a valid status for a well-formed manifest and disables save when not dirty", () => { + render(<YamlMode value={VALID} onChange={vi.fn()} onSave={vi.fn()} dirty={false} />); + expect(screen.getByTestId("yaml-status-valid")).toBeInTheDocument(); + expect(screen.getByTestId("yaml-save")).toBeDisabled(); + }); + + it("shows line-anchored errors for an invalid manifest and disables save", () => { + render( + <YamlMode value={`name: X\nstatus: bogus\n`} onChange={vi.fn()} onSave={vi.fn()} dirty />, + ); + expect(screen.getByTestId("yaml-status-invalid")).toBeInTheDocument(); + expect(screen.getByTestId("yaml-save")).toBeDisabled(); + const issues = screen.getByTestId("yaml-issues"); + expect(issues).toBeInTheDocument(); + expect(screen.getByText(/'status' must be one of/i)).toBeInTheDocument(); + }); + + it("enables save once the manifest is valid and dirty, and calls onSave", () => { + const onSave = vi.fn(); + render(<YamlMode value={VALID} onChange={vi.fn()} onSave={onSave} dirty />); + const button = screen.getByTestId("yaml-save"); + expect(button).toBeEnabled(); + fireEvent.click(button); + expect(onSave).toHaveBeenCalled(); + }); + + it("renders a line-number gutter matching the text line count", () => { + const { container } = render( + <YamlMode value={"id: A\nname: B\nstatus: draft\n"} onChange={vi.fn()} onSave={vi.fn()} />, + ); + const gutterLines = container.querySelectorAll('[aria-hidden="true"] > div'); + // 4 lines (trailing newline yields an extra empty line, which is fine). + expect(gutterLines.length).toBeGreaterThanOrEqual(3); + }); + + it("edits update validity live", () => { + render(<Harness initial={"id: A\nname: B\n"} />); + expect(screen.getByTestId("yaml-status-valid")).toBeInTheDocument(); + const textarea = screen.getByTestId("yaml-textarea"); + fireEvent.change(textarea, { target: { value: "id: A\nname: B\nstatus: nope\n" } }); + expect(screen.getByTestId("yaml-status-invalid")).toBeInTheDocument(); + }); + + it("surfaces a save error when provided", () => { + render( + <YamlMode value={VALID} onChange={vi.fn()} onSave={vi.fn()} dirty saveError="409 conflict" />, + ); + expect(screen.getByTestId("yaml-save-error")).toHaveTextContent("409 conflict"); + }); +}); diff --git a/apps/web/src/components/spec-studio/yaml-mode.tsx b/apps/web/src/components/spec-studio/yaml-mode.tsx new file mode 100644 index 00000000..03402b0a --- /dev/null +++ b/apps/web/src/components/spec-studio/yaml-mode.tsx @@ -0,0 +1,139 @@ +"use client"; + +import { AlertTriangle, CheckCircle2 } from "lucide-react"; +import { useMemo, useRef, useState, type ChangeEvent, type UIEvent } from "react"; + +import { cn } from "@/lib/utils"; +import { hasErrors, validateManifestYaml } from "@/lib/spec-studio/yaml-schema"; +import { Button } from "@/components/ui/button"; + +export interface YamlModeProps { + /** The current `manifest.yaml` text (controlled). */ + value: string; + onChange: (next: string) => void; + onSave: () => void; + saving?: boolean; + /** True once `value` differs from the last saved/loaded text. */ + dirty?: boolean; + saveError?: string | null; +} + +/** + * The YAML manifest editor — Spec Studio's 4th mode. A schema-aware + * `manifest.yaml` editor: JetBrains Mono, a line-number gutter, and live + * client-side validation with line-anchored errors (see + * `lib/spec-studio/yaml-schema`). Edits are the same `SpecManifest` the + * Guided and Markdown modes edit — this only changes the surface. + */ +export function YamlMode({ value, onChange, onSave, saving = false, dirty = false, saveError }: YamlModeProps) { + const issues = useMemo(() => validateManifestYaml(value), [value]); + const invalid = hasErrors(issues); + const lineCount = useMemo(() => Math.max(1, value.split("\n").length), [value]); + const gutterRef = useRef<HTMLDivElement>(null); + const [scrollTop, setScrollTop] = useState(0); + + const onScroll = (event: UIEvent<HTMLTextAreaElement>) => { + setScrollTop(event.currentTarget.scrollTop); + }; + + const onTextChange = (event: ChangeEvent<HTMLTextAreaElement>) => { + onChange(event.target.value); + }; + + return ( + <div className="flex flex-col gap-3" data-testid="yaml-mode"> + <div className="flex items-center justify-between gap-3"> + <div className="flex items-center gap-2 text-xs text-muted-foreground"> + {invalid ? ( + <span className="inline-flex items-center gap-1 text-danger" data-testid="yaml-status-invalid"> + <AlertTriangle className="h-3.5 w-3.5" aria-hidden /> + {issues.filter((i) => i.severity === "error").length} error + {issues.filter((i) => i.severity === "error").length === 1 ? "" : "s"} + </span> + ) : ( + <span className="inline-flex items-center gap-1 text-success" data-testid="yaml-status-valid"> + <CheckCircle2 className="h-3.5 w-3.5" aria-hidden /> + Valid manifest + </span> + )} + {dirty ? <span className="text-muted-foreground/70">Unsaved changes</span> : null} + </div> + <Button + size="sm" + onClick={onSave} + disabled={invalid || saving || !dirty} + data-testid="yaml-save" + > + {saving ? "Saving…" : "Save manifest.yaml"} + </Button> + </div> + + <div className="flex overflow-hidden rounded-lg border border-border bg-card"> + <div + ref={gutterRef} + aria-hidden + className="select-none overflow-hidden border-r border-border bg-muted/40 px-3 py-3 text-right font-mono text-xs leading-5 text-muted-foreground/70" + style={{ transform: `translateY(-${scrollTop}px)` }} + > + {Array.from({ length: lineCount }, (_, i) => ( + <div key={i}>{i + 1}</div> + ))} + </div> + <textarea + data-testid="yaml-textarea" + aria-label="manifest.yaml" + spellCheck={false} + value={value} + onChange={onTextChange} + onScroll={onScroll} + className="min-h-[24rem] flex-1 resize-none bg-transparent px-3 py-3 font-mono text-xs leading-5 text-foreground outline-none" + /> + </div> + + {saveError ? ( + <p role="alert" className="text-xs text-danger" data-testid="yaml-save-error"> + {saveError} + </p> + ) : null} + + {issues.length > 0 ? ( + <ul className="flex flex-col gap-1" data-testid="yaml-issues" aria-label="Manifest validation issues"> + {issues.map((issue, index) => ( + <li key={`${issue.line}-${index}`}> + <button + type="button" + className={cn( + "flex w-full items-start gap-2 rounded-md border px-3 py-1.5 text-left text-xs", + issue.severity === "error" + ? "border-danger/30 bg-danger/5 text-danger" + : "border-warning/30 bg-warning/5 text-warning", + )} + onClick={() => focusLine(gutterRef, issue.line)} + > + <span className="font-mono text-[11px] shrink-0"> + Ln {issue.line} + {issue.column ? `:${issue.column}` : ""} + </span> + <span>{issue.message}</span> + </button> + </li> + ))} + </ul> + ) : null} + </div> + ); +} + +function focusLine(gutterRef: React.RefObject<HTMLDivElement | null>, line: number) { + const container = gutterRef.current?.parentElement; + const textarea = container?.querySelector("textarea"); + if (!textarea) return; + const value = textarea.value; + const lines = 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); +} diff --git a/apps/web/src/components/spec/spec-dashboard.test.tsx b/apps/web/src/components/spec/spec-dashboard.test.tsx index 030845dc..9efbb801 100644 --- a/apps/web/src/components/spec/spec-dashboard.test.tsx +++ b/apps/web/src/components/spec/spec-dashboard.test.tsx @@ -65,6 +65,9 @@ function makeClient(overrides: Partial<ForgeApiClient> = {}): ForgeApiClient { approveSpec: vi.fn((id: string) => Promise.resolve({ id, name: "Passwordless auth", status: "approved" as const }), ), + getSpecManifest: vi.fn((id: string) => + Promise.resolve(dashboard.specs.find((s) => s.id === id) ?? dashboard.specs[0]), + ), ...overrides, } as unknown as ForgeApiClient; } @@ -132,6 +135,18 @@ describe("SpecDashboard", () => { expect(screen.getByText(/smallest correct change/i)).toBeInTheDocument(); }); + it("switches to the Studio tab and opens Spec Studio for the selected spec", async () => { + const client = makeClient(); + renderDashboard(client); + await screen.findByRole("heading", { level: 2, name: /passwordless auth/i }); + + fireEvent.click(screen.getByRole("tab", { name: /studio/i })); + + expect(await screen.findByTestId("spec-studio")).toBeInTheDocument(); + expect(await screen.findByTestId("guided-mode")).toBeInTheDocument(); + expect(client.getSpecManifest).toHaveBeenCalledWith("s1"); + }); + it("shows the empty state when the project has no specs", async () => { const client = makeClient({ getProjectSpecOverview: vi.fn(() => diff --git a/apps/web/src/components/spec/spec-dashboard.tsx b/apps/web/src/components/spec/spec-dashboard.tsx index 6056bc5b..871c093c 100644 --- a/apps/web/src/components/spec/spec-dashboard.tsx +++ b/apps/web/src/components/spec/spec-dashboard.tsx @@ -5,6 +5,7 @@ import { FileText, Landmark, ListChecks, + Pencil, Route, ShieldCheck, Stamp, @@ -21,6 +22,7 @@ import { } from "react"; import { useRegisterCommands } from "@/components/command-palette"; +import { SpecStudio } from "@/components/spec-studio/spec-studio"; import { Button } from "@/components/ui/button"; import { apiClient, type ForgeApiClient } from "@/lib/api/client"; import { useApproveSpec, useSpecOverview } from "@/lib/api/spec"; @@ -43,12 +45,13 @@ import { TraceabilityMatrix } from "./traceability-matrix"; /** Placeholder project until project routing lands (F02). */ export const DEFAULT_PROJECT_ID = "default"; -type TabId = "traceability" | "manifest" | "constitution"; +type TabId = "traceability" | "manifest" | "constitution" | "studio"; const TABS: { id: TabId; label: string; icon: typeof Route }[] = [ { id: "traceability", label: "Traceability", icon: Route }, { id: "manifest", label: "Manifest", icon: FileText }, { id: "constitution", label: "Constitution", icon: Landmark }, + { id: "studio", label: "Studio", icon: Pencil }, ]; function isEditableTarget(target: EventTarget | null): boolean { @@ -241,6 +244,7 @@ export function SpecDashboard({ {tab === "constitution" ? ( <ConstitutionPanel constitution={constitution} /> ) : null} + {tab === "studio" ? <SpecStudio specId={selected.id} client={client} /> : null} </div> </div> </div> diff --git a/apps/web/src/lib/api/client-spec.test.tsx b/apps/web/src/lib/api/client-spec.test.tsx index 58369690..35934c67 100644 --- a/apps/web/src/lib/api/client-spec.test.tsx +++ b/apps/web/src/lib/api/client-spec.test.tsx @@ -84,6 +84,25 @@ describe("ForgeApiClient spec-engine surface", () => { 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); diff --git a/apps/web/src/lib/api/client.ts b/apps/web/src/lib/api/client.ts index 5c7a8f46..872d66af 100644 --- a/apps/web/src/lib/api/client.ts +++ b/apps/web/src/lib/api/client.ts @@ -402,6 +402,18 @@ 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<SpecManifest> { + return this.request<SpecManifest>( + `/spec/specs/${encodeURIComponent(specId)}`, + { method: "PUT", body: manifest }, + ); + } + /** Create a draft spec for an epic (SDD lifecycle entry point). */ createSpec(body: { epic_id: string; 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..9f1381fa --- /dev/null +++ b/apps/web/src/lib/api/spec-studio.ts @@ -0,0 +1,112 @@ +"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 type { 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<SpecManifest> { + return useQuery({ + queryKey: specStudioKeys.manifest(specId), + queryFn: () => client.getSpecManifest(specId), + enabled: Boolean(specId), + }); +} + +export function useSpecStudioMarkdown( + specId: string, + enabled: boolean, + client: ForgeApiClient = apiClient, +): UseQueryResult<string> { + 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<string> { + 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) }); + } + }; +} + +export function useSaveGuidedManifest( + specId: string, + client: ForgeApiClient = apiClient, +): UseMutationResult<SpecManifest, Error, SpecManifest> { + 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<SpecManifest, Error, string> { + 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<SpecManifest, Error, string> { + const sync = useSyncAfterSave(specId); + return useMutation({ + mutationFn: (content: string) => client.putSpecManifestYaml(specId, content), + onSuccess: (updated) => sync(updated, "yaml"), + }); +} 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<string>, + collectIds?: Set<string>, +): 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<string>(); + 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/pnpm-lock.yaml b/pnpm-lock.yaml index dc271994..d4522c47 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -59,6 +59,9 @@ importers: tailwind-merge: specifier: ^3.6.0 version: 3.6.0 + yaml: + specifier: ^2.9.0 + version: 2.9.0 devDependencies: '@tailwindcss/postcss': specifier: ^4.3.1 @@ -83,7 +86,7 @@ importers: version: 19.2.3(@types/react@19.2.17) '@vitejs/plugin-react': specifier: ^6.0.3 - version: 6.0.3(vite@8.1.0(@types/node@26.0.1)(jiti@2.7.0)) + version: 6.0.3(vite@8.1.0(@types/node@26.0.1)(jiti@2.7.0)(yaml@2.9.0)) eslint: specifier: ^9 version: 9.39.4(jiti@2.7.0) @@ -107,10 +110,10 @@ importers: version: 6.0.3 vite: specifier: ^8 - version: 8.1.0(@types/node@26.0.1)(jiti@2.7.0) + version: 8.1.0(@types/node@26.0.1)(jiti@2.7.0)(yaml@2.9.0) vitest: specifier: ^4.1.9 - version: 4.1.9(@types/node@26.0.1)(jsdom@29.1.1)(vite@8.1.0(@types/node@26.0.1)(jiti@2.7.0)) + version: 4.1.9(@types/node@26.0.1)(jsdom@29.1.1)(vite@8.1.0(@types/node@26.0.1)(jiti@2.7.0)(yaml@2.9.0)) packages: @@ -2837,6 +2840,11 @@ packages: yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + yaml@2.9.0: + resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} + engines: {node: '>= 14.6'} + hasBin: true + yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} @@ -3794,10 +3802,10 @@ snapshots: '@unrs/resolver-binding-win32-x64-msvc@1.12.2': optional: true - '@vitejs/plugin-react@6.0.3(vite@8.1.0(@types/node@26.0.1)(jiti@2.7.0))': + '@vitejs/plugin-react@6.0.3(vite@8.1.0(@types/node@26.0.1)(jiti@2.7.0)(yaml@2.9.0))': dependencies: '@rolldown/pluginutils': 1.0.1 - vite: 8.1.0(@types/node@26.0.1)(jiti@2.7.0) + vite: 8.1.0(@types/node@26.0.1)(jiti@2.7.0)(yaml@2.9.0) '@vitest/expect@4.1.9': dependencies: @@ -3808,13 +3816,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.9(vite@8.1.0(@types/node@26.0.1)(jiti@2.7.0))': + '@vitest/mocker@4.1.9(vite@8.1.0(@types/node@26.0.1)(jiti@2.7.0)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.1.9 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.1.0(@types/node@26.0.1)(jiti@2.7.0) + vite: 8.1.0(@types/node@26.0.1)(jiti@2.7.0)(yaml@2.9.0) '@vitest/pretty-format@4.1.9': dependencies: @@ -5578,7 +5586,7 @@ snapshots: optionalDependencies: '@types/react': 19.2.17 - vite@8.1.0(@types/node@26.0.1)(jiti@2.7.0): + vite@8.1.0(@types/node@26.0.1)(jiti@2.7.0)(yaml@2.9.0): dependencies: lightningcss: 1.32.0 picomatch: 4.0.4 @@ -5589,11 +5597,12 @@ snapshots: '@types/node': 26.0.1 fsevents: 2.3.3 jiti: 2.7.0 + yaml: 2.9.0 - vitest@4.1.9(@types/node@26.0.1)(jsdom@29.1.1)(vite@8.1.0(@types/node@26.0.1)(jiti@2.7.0)): + vitest@4.1.9(@types/node@26.0.1)(jsdom@29.1.1)(vite@8.1.0(@types/node@26.0.1)(jiti@2.7.0)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.9 - '@vitest/mocker': 4.1.9(vite@8.1.0(@types/node@26.0.1)(jiti@2.7.0)) + '@vitest/mocker': 4.1.9(vite@8.1.0(@types/node@26.0.1)(jiti@2.7.0)(yaml@2.9.0)) '@vitest/pretty-format': 4.1.9 '@vitest/runner': 4.1.9 '@vitest/snapshot': 4.1.9 @@ -5610,7 +5619,7 @@ snapshots: tinyexec: 1.2.4 tinyglobby: 0.2.17 tinyrainbow: 3.1.0 - vite: 8.1.0(@types/node@26.0.1)(jiti@2.7.0) + vite: 8.1.0(@types/node@26.0.1)(jiti@2.7.0)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 26.0.1 @@ -5692,6 +5701,8 @@ snapshots: yallist@3.1.1: {} + yaml@2.9.0: {} + yocto-queue@0.1.0: {} zod-validation-error@4.0.2(zod@4.4.3): From bec981346ddf2675ba7d12ba859753411531aee3 Mon Sep 17 00:00:00 2001 From: Forge Swarm <swarm@forge.local> Date: Wed, 8 Jul 2026 23:58:03 +0200 Subject: [PATCH 06/20] feat(ss-draft): Spec Studio Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --- apps/api/forge_api/auth/service.py | 10 + apps/api/forge_api/routers/spec.py | 100 ++++++ apps/api/forge_api/schemas/ao_settings.py | 2 +- .../forge_api/services/ao_settings_service.py | 19 +- .../forge_api/services/spec_draft_service.py | 231 ++++++++++++++ apps/api/tests/test_spec_draft.py | 293 ++++++++++++++++++ .../forge_agent/execution_plan.py | 17 +- .../forge_agent/providers/router.py | 3 +- .../tests/test_execution_plan.py | 3 +- .../tests/test_providers_router.py | 2 +- .../db/tests/test_ao_config_role_config.py | 2 +- .../tests/test_ao_policy_wiring.py | 2 +- .../tests/test_ao_config_resolver.py | 3 +- .../tests/test_complexity.py | 4 +- ruff.toml | 1 + 15 files changed, 661 insertions(+), 31 deletions(-) create mode 100644 apps/api/forge_api/services/spec_draft_service.py create mode 100644 apps/api/tests/test_spec_draft.py 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/spec.py b/apps/api/forge_api/routers/spec.py index 5f2da2b4..9c50f0b6 100644 --- a/apps/api/forge_api/routers/spec.py +++ b/apps/api/forge_api/routers/spec.py @@ -16,6 +16,7 @@ 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 @@ -28,10 +29,12 @@ 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.spec_draft_service import SpecDraft, draft_spec from forge_api.settings import get_settings from forge_contracts import ( BoardFilter, Constitution, + ModelClient, Requirement, SpecManifest, TaskDTO, @@ -39,6 +42,7 @@ ) from forge_contracts.exceptions import SpecGateError from forge_db.models import Project +from forge_orchestration_policy import Tier from forge_spec import FileSpecEngine, SpecNotFoundError router = APIRouter( @@ -138,6 +142,15 @@ class TextContent(BaseModel): 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 # # --------------------------------------------------------------------------- # @@ -283,6 +296,93 @@ def validate(engine: EngineDep, task_id: uuid.UUID) -> ValidationReport: return engine.validate(task_id) +# --------------------------------------------------------------------------- # +# 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 + + # --------------------------------------------------------------------------- # # F23 spec-validation dashboard: GET /projects/{project_id}/specs # # --------------------------------------------------------------------------- # 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" + "<one concise sentence naming what is being built>\n\n" + "## Requirements\n\n" + "- **R1**: <requirement>\n" + "- **R2**: <requirement>\n\n" + "## Acceptance Criteria\n\n" + "- **A1** (R1): Given <context>, when <action>, then <observable outcome>\n\n" + "## Constraints\n\n" + "- <constraint>\n\n" + "## Open Questions\n\n" + "- **Q1**: <question that must be resolved before implementation>\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/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/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/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/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/ruff.toml b/ruff.toml index 78ecaf3d..1128e47f 100644 --- a/ruff.toml +++ b/ruff.toml @@ -43,6 +43,7 @@ known-first-party = [ "forge_agent", "forge_coordinator", "forge_spec", + "forge_orchestration_policy", "forge_board", "forge_knowledge", "forge_integrations", From 13ae1ce9118a85cf2766f06e5eb0760c737982e4 Mon Sep 17 00:00:00 2001 From: Forge Swarm <swarm@forge.local> Date: Thu, 9 Jul 2026 00:48:41 +0200 Subject: [PATCH 07/20] feat(ss-guided): Spec Studio Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --- apps/web/src/app/(board)/specs/[id]/page.tsx | 14 + apps/web/src/app/(board)/specs/new/page.tsx | 9 + .../spec-studio/guided-helpers.test.ts | 139 ++++++ .../components/spec-studio/guided-helpers.ts | 178 +++++++ .../spec-studio/guided-mode.test.tsx | 128 +++++ .../components/spec-studio/guided-mode.tsx | 452 ++++++++++++++++-- .../spec-studio/new-spec-page.test.tsx | 118 +++++ .../components/spec-studio/new-spec-page.tsx | 126 +++++ .../spec-studio/spec-studio-page.test.tsx | 40 ++ .../spec-studio/spec-studio-page.tsx | 44 ++ .../src/components/spec/spec-dashboard.tsx | 20 +- apps/web/src/lib/api/spec.test.tsx | 63 ++- apps/web/src/lib/api/spec.ts | 104 +++- 13 files changed, 1393 insertions(+), 42 deletions(-) create mode 100644 apps/web/src/app/(board)/specs/[id]/page.tsx create mode 100644 apps/web/src/app/(board)/specs/new/page.tsx create mode 100644 apps/web/src/components/spec-studio/guided-helpers.test.ts create mode 100644 apps/web/src/components/spec-studio/guided-helpers.ts create mode 100644 apps/web/src/components/spec-studio/guided-mode.test.tsx create mode 100644 apps/web/src/components/spec-studio/new-spec-page.test.tsx create mode 100644 apps/web/src/components/spec-studio/new-spec-page.tsx create mode 100644 apps/web/src/components/spec-studio/spec-studio-page.test.tsx create mode 100644 apps/web/src/components/spec-studio/spec-studio-page.tsx 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 <SpecStudioPage specId={id} />; +} 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..c499c410 --- /dev/null +++ b/apps/web/src/app/(board)/specs/new/page.tsx @@ -0,0 +1,9 @@ +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). + */ +export default function NewSpecRoute() { + return <NewSpecPage />; +} 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..81abf137 --- /dev/null +++ b/apps/web/src/components/spec-studio/guided-helpers.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, it } from "vitest"; + +import type { SpecManifest } from "@/lib/api/types"; + +import { + addAcceptanceCriterion, + addAdr, + addRequirement, + composeGivenWhenThen, + computeChecklist, + computeCoverage, + computeNudges, + nextSequentialId, + 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("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..7a19abef --- /dev/null +++ b/apps/web/src/components/spec-studio/guided-helpers.ts @@ -0,0 +1,178 @@ +/** + * 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}<number>` 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(" "); +} + +/** 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..b9770142 --- /dev/null +++ b/apps/web/src/components/spec-studio/guided-mode.test.tsx @@ -0,0 +1,128 @@ +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 <GuidedMode value={value} onChange={setValue} onSave={vi.fn()} dirty />; +} + +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(<Harness initial={baseManifest} />); + 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(<Harness initial={{ id: "s1", name: "x" }} />); + 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(<Harness initial={baseManifest} />); + 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("links an acceptance criterion to a requirement via the dropdown, not free text", () => { + render(<Harness initial={baseManifest} />); + 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( + <Harness + initial={{ + ...baseManifest, + acceptance_criteria: [{ id: "AC1", text: "Then it works", req_refs: ["R1"] }], + }} + />, + ); + 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(<Harness initial={baseManifest} />); + 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(<Harness initial={baseManifest} />); + 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( + <Harness + initial={{ + ...baseManifest, + acceptance_criteria: [{ id: "AC1", text: "Then it works", req_refs: ["R1"] }], + }} + />, + ); + expect(screen.queryByTestId("guided-nudge-uncovered-R1")).not.toBeInTheDocument(); + }); + + it("shows a Ready-to-create checklist and coverage meter", () => { + render(<Harness initial={baseManifest} />); + 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( + <Harness + initial={{ + ...baseManifest, + acceptance_criteria: [{ id: "AC1", text: "Then it works", req_refs: ["R1"] }], + }} + />, + ); + 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 index c93ce230..00750e02 100644 --- a/apps/web/src/components/spec-studio/guided-mode.tsx +++ b/apps/web/src/components/spec-studio/guided-mode.tsx @@ -1,9 +1,29 @@ "use client"; -import { Plus, Trash2 } from "lucide-react"; +import { ChevronDown, ChevronRight, Plus, Trash2 } from "lucide-react"; +import { useState } from "react"; import { Button } from "@/components/ui/button"; -import { SPEC_STATUSES, type SpecManifest, type SpecStatus } from "@/lib/api/types"; +import { + SPEC_STATUSES, + type ADR, + type AcceptanceCriterion, + type ExecutionMode, + type Requirement, + type SpecManifest, + type SpecStatus, +} from "@/lib/api/types"; + +import { + addAcceptanceCriterion, + addAdr, + addRequirement, + composeGivenWhenThen, + computeChecklist, + computeCoverage, + computeNudges, + parseGivenWhenThen, +} from "./guided-helpers"; export interface GuidedModeProps { /** The current draft manifest (controlled). */ @@ -15,15 +35,44 @@ export interface GuidedModeProps { 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 structured form over the same `SpecManifest` the - * Markdown and YAML modes edit. The friendliest surface: name, status, - * requirements and constraints as plain fields/lists rather than prose or - * YAML syntax. + * 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. */ export function GuidedMode({ value, onChange, onSave, saving = false, dirty = false, saveError }: GuidedModeProps) { + const [advancedOpen, setAdvancedOpen] = useState(false); 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 ( <div className="flex flex-col gap-5" data-testid="guided-mode"> @@ -37,11 +86,12 @@ export function GuidedMode({ value, onChange, onSave, saving = false, dirty = fa </div> <label className="flex flex-col gap-1.5 text-sm"> - <span className="font-medium text-foreground">Name</span> + <span className="font-medium text-foreground">Goal</span> <input data-testid="guided-name" value={value.name} onChange={(event) => onChange({ ...value, name: event.target.value })} + placeholder="What does this spec achieve?" className="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" /> </label> @@ -62,42 +112,34 @@ export function GuidedMode({ value, onChange, onSave, saving = false, dirty = fa </select> </label> + {/* --- Requirements ------------------------------------------------- */} <section className="flex flex-col gap-2"> <h3 className="font-display text-sm font-semibold tracking-tight text-foreground"> Requirements </h3> <ul className="flex flex-col gap-2" data-testid="guided-requirements"> {requirements.map((req, index) => ( - <li key={req.id || index} className="flex items-center gap-2"> - <input - aria-label={`Requirement ${index + 1} id`} - value={req.id} - onChange={(event) => { - const next = [...requirements]; - next[index] = { ...next[index], id: event.target.value }; - onChange({ ...value, requirements: next }); - }} - className="w-20 shrink-0 rounded-md border border-border bg-card px-2 py-1.5 font-mono text-xs text-foreground outline-none" - /> + <li key={req.id} className="flex items-center gap-2"> + <span + data-testid={`requirement-id-${index}`} + className="w-12 shrink-0 rounded-md border border-border bg-muted px-2 py-1.5 text-center font-mono text-xs text-muted-foreground" + > + {req.id} + </span> <input - aria-label={`Requirement ${index + 1} text`} + aria-label={`${req.id} text`} value={req.text} onChange={(event) => { const next = [...requirements]; next[index] = { ...next[index], text: event.target.value }; - onChange({ ...value, requirements: next }); + setRequirements(next); }} className="flex-1 rounded-md border border-border bg-card px-3 py-1.5 text-sm text-foreground outline-none" /> <button type="button" - aria-label={`Remove requirement ${index + 1}`} - onClick={() => - onChange({ - ...value, - requirements: requirements.filter((_, i) => i !== index), - }) - } + aria-label={`Remove ${req.id}`} + onClick={() => setRequirements(requirements.filter((_, i) => i !== index))} className="rounded-md p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground" > <Trash2 className="h-4 w-4" aria-hidden /> @@ -111,21 +153,147 @@ export function GuidedMode({ value, onChange, onSave, saving = false, dirty = fa size="sm" className="w-fit" data-testid="guided-add-requirement" - onClick={() => - onChange({ - ...value, - requirements: [ - ...requirements, - { id: `R${requirements.length + 1}`, text: "" }, - ], - }) - } + onClick={() => setRequirements(addRequirement(requirements))} > <Plus className="h-4 w-4" aria-hidden /> Add requirement </Button> </section> + {/* --- Acceptance Criteria ------------------------------------------ */} + <section className="flex flex-col gap-2"> + <h3 className="font-display text-sm font-semibold tracking-tight text-foreground"> + Acceptance Criteria + </h3> + <ul className="flex flex-col gap-3" data-testid="guided-acceptance-criteria"> + {criteria.map((ac, index) => { + const gwt = parseGivenWhenThen(ac.text); + const refs = ac.req_refs ?? []; + const linkable = requirements.filter((r) => !refs.includes(r.id)); + + function updateGwt(patch: Partial<typeof gwt>) { + const next = [...criteria]; + next[index] = { ...next[index], text: composeGivenWhenThen({ ...gwt, ...patch }) }; + setCriteria(next); + } + + return ( + <li + key={ac.id} + className="flex flex-col gap-2 rounded-md border border-border bg-card/60 p-3" + data-testid={`ac-item-${index}`} + > + <div className="flex items-center gap-2"> + <span + data-testid={`acceptance-criterion-id-${index}`} + className="w-12 shrink-0 rounded-md border border-border bg-muted px-2 py-1.5 text-center font-mono text-xs text-muted-foreground" + > + {ac.id} + </span> + <span className="text-xs text-muted-foreground">Acceptance criterion</span> + <button + type="button" + aria-label={`Remove ${ac.id}`} + onClick={() => setCriteria(criteria.filter((_, i) => i !== index))} + className="ml-auto rounded-md p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground" + > + <Trash2 className="h-4 w-4" aria-hidden /> + </button> + </div> + + <div className="grid grid-cols-1 gap-2 sm:grid-cols-3"> + <label className="flex flex-col gap-1 text-xs"> + <span className="text-muted-foreground">Given</span> + <input + aria-label={`${ac.id} given`} + value={gwt.given} + onChange={(event) => updateGwt({ given: event.target.value })} + className="rounded-md border border-border bg-card px-2 py-1.5 text-sm text-foreground outline-none" + /> + </label> + <label className="flex flex-col gap-1 text-xs"> + <span className="text-muted-foreground">When</span> + <input + aria-label={`${ac.id} when`} + value={gwt.when} + onChange={(event) => updateGwt({ when: event.target.value })} + className="rounded-md border border-border bg-card px-2 py-1.5 text-sm text-foreground outline-none" + /> + </label> + <label className="flex flex-col gap-1 text-xs"> + <span className="text-muted-foreground">Then</span> + <input + aria-label={`${ac.id} then`} + value={gwt.then} + onChange={(event) => updateGwt({ then: event.target.value })} + className="rounded-md border border-border bg-card px-2 py-1.5 text-sm text-foreground outline-none" + /> + </label> + </div> + + <div className="flex flex-wrap items-center gap-1.5"> + {refs.map((refId) => ( + <span + key={refId} + data-testid={`ac-linked-req-${index}-${refId}`} + className="inline-flex items-center gap-1 rounded-full border border-primary/30 bg-primary/10 px-2 py-0.5 font-mono text-[10px] text-foreground" + > + {refId} + <button + type="button" + aria-label={`Unlink ${refId} from ${ac.id}`} + onClick={() => { + const next = [...criteria]; + next[index] = { ...next[index], req_refs: refs.filter((r) => r !== refId) }; + setCriteria(next); + }} + className="text-muted-foreground hover:text-foreground" + > + <Trash2 className="h-3 w-3" aria-hidden /> + </button> + </span> + ))} + {linkable.length > 0 ? ( + <select + aria-label={`Link a requirement to ${ac.id}`} + data-testid={`ac-link-requirement-${index}`} + value="" + onChange={(event) => { + const reqId = event.target.value; + if (!reqId) return; + const next = [...criteria]; + next[index] = { ...next[index], req_refs: [...refs, reqId] }; + setCriteria(next); + }} + className="rounded-md border border-dashed border-border bg-transparent px-2 py-0.5 text-[11px] text-muted-foreground outline-none" + > + <option value="">Link requirement…</option> + {linkable.map((r) => ( + <option key={r.id} value={r.id}> + {r.id} — {r.text || "untitled"} + </option> + ))} + </select> + ) : null} + </div> + </li> + ); + })} + </ul> + <Button + type="button" + variant="outline" + size="sm" + className="w-fit" + data-testid="guided-add-acceptance-criterion" + onClick={() => setCriteria(addAcceptanceCriterion(criteria))} + > + <Plus className="h-4 w-4" aria-hidden /> + Add acceptance criterion + </Button> + </section> + + {/* --- Constraints ---------------------------------------------------- */} <section className="flex flex-col gap-2"> <h3 className="font-display text-sm font-semibold tracking-tight text-foreground"> Constraints @@ -169,6 +337,163 @@ export function GuidedMode({ value, onChange, onSave, saving = false, dirty = fa </Button> </section> + {/* --- Advanced (collapsed by default) -------------------------------- */} + <section className="flex flex-col gap-2 rounded-md border border-border"> + <button + type="button" + data-testid="guided-advanced-toggle" + aria-expanded={advancedOpen} + onClick={() => setAdvancedOpen((open) => !open)} + className="flex items-center gap-1.5 px-3 py-2 text-left text-sm font-medium text-foreground" + > + {advancedOpen ? ( + <ChevronDown className="h-4 w-4" aria-hidden /> + ) : ( + <ChevronRight className="h-4 w-4" aria-hidden /> + )} + Advanced + </button> + {advancedOpen ? ( + <div className="flex flex-col gap-4 border-t border-border px-3 py-3" data-testid="guided-advanced-panel"> + <label className="flex flex-col gap-1.5 text-sm"> + <span className="font-medium text-foreground">Execution mode</span> + <select + data-testid="guided-execution-mode" + value={value.execution_mode ?? ""} + onChange={(event) => + onChange({ + ...value, + execution_mode: (event.target.value || undefined) as ExecutionMode | undefined, + }) + } + className="rounded-md border border-border bg-card px-3 py-2 text-sm text-foreground outline-none" + > + <option value="">Default</option> + {EXECUTION_MODES.map((mode) => ( + <option key={mode.value} value={mode.value}> + {mode.label} + </option> + ))} + </select> + </label> + + <StringListField + label="Constitution refs" + testId="guided-constitution-refs" + addTestId="guided-add-constitution-ref" + items={constitutionRefs} + onChange={(next) => onChange({ ...value, constitution_refs: next })} + /> + + <StringListField + label="Repos" + testId="guided-repos" + addTestId="guided-add-repo" + items={repos} + onChange={(next) => onChange({ ...value, repos: next })} + /> + + <div className="flex flex-col gap-2"> + <span className="text-sm font-medium text-foreground">Architecture decisions</span> + <ul className="flex flex-col gap-2" data-testid="guided-decisions"> + {decisions.map((adr, index) => ( + <li key={adr.id} className="flex items-center gap-2"> + <span className="w-14 shrink-0 rounded-md border border-border bg-muted px-2 py-1.5 text-center font-mono text-xs text-muted-foreground"> + {adr.id} + </span> + <input + aria-label={`${adr.id} title`} + value={adr.title} + onChange={(event) => { + const next = [...decisions]; + next[index] = { ...next[index], title: event.target.value }; + setDecisions(next); + }} + className="flex-1 rounded-md border border-border bg-card px-3 py-1.5 text-sm text-foreground outline-none" + placeholder="Decision title" + /> + <button + type="button" + aria-label={`Remove ${adr.id}`} + onClick={() => setDecisions(decisions.filter((_, i) => i !== index))} + className="rounded-md p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground" + > + <Trash2 className="h-4 w-4" aria-hidden /> + </button> + </li> + ))} + </ul> + <Button + type="button" + variant="outline" + size="sm" + className="w-fit" + data-testid="guided-add-decision" + onClick={() => setDecisions(addAdr(decisions))} + > + <Plus className="h-4 w-4" aria-hidden /> + Add decision + </Button> + </div> + </div> + ) : null} + </section> + + {/* --- Nudges ---------------------------------------------------------- */} + {nudges.length > 0 ? ( + <ul className="flex flex-col gap-1.5" data-testid="guided-nudges" aria-label="Validation nudges"> + {nudges.map((nudge) => ( + <li + key={nudge.id} + data-testid={`guided-nudge-${nudge.id}`} + className="rounded-md border border-dashed border-warning/40 bg-warning/10 px-3 py-1.5 text-xs text-warning-foreground" + > + {nudge.message} + </li> + ))} + </ul> + ) : null} + + {/* --- Ready-to-create checklist + coverage meter ---------------------- */} + <section + className="flex flex-col gap-3 rounded-lg border border-border bg-muted/30 p-3" + data-testid="guided-checklist" + > + <div className="flex items-center justify-between gap-3"> + <h3 className="font-display text-sm font-semibold tracking-tight text-foreground"> + Ready to create + </h3> + <span className="font-mono text-xs text-muted-foreground" data-testid="guided-coverage-meter"> + {coverage.satisfied}/{coverage.total} requirements covered ({coverage.pct}%) + </span> + </div> + <div className="h-1.5 overflow-hidden rounded-full bg-muted"> + <div + className={`h-full rounded-full ${coverage.pct >= 100 ? "bg-success" : "bg-primary"}`} + style={{ width: `${coverage.pct}%` }} + /> + </div> + <ul className="flex flex-col gap-1"> + {checklist.map((item) => ( + <li + key={item.id} + data-testid={`checklist-item-${item.id}`} + className="flex items-center gap-2 text-xs" + > + <span + aria-hidden + className={`h-3.5 w-3.5 shrink-0 rounded-full border ${ + item.done ? "border-success bg-success" : "border-border bg-transparent" + }`} + /> + <span className={item.done ? "text-foreground" : "text-muted-foreground"}> + {item.label} + </span> + </li> + ))} + </ul> + </section> + {saveError ? ( <p role="alert" className="text-xs text-danger" data-testid="guided-save-error"> {saveError} @@ -177,3 +502,58 @@ export function GuidedMode({ value, onChange, onSave, saving = false, dirty = fa </div> ); } + +function StringListField({ + label, + testId, + addTestId, + items, + onChange, +}: { + label: string; + testId: string; + addTestId: string; + items: string[]; + onChange: (next: string[]) => void; +}) { + return ( + <div className="flex flex-col gap-2"> + <span className="text-sm font-medium text-foreground">{label}</span> + <ul className="flex flex-col gap-2" data-testid={testId}> + {items.map((item, index) => ( + <li key={index} className="flex items-center gap-2"> + <input + aria-label={`${label} ${index + 1}`} + value={item} + onChange={(event) => { + const next = [...items]; + next[index] = event.target.value; + onChange(next); + }} + className="flex-1 rounded-md border border-border bg-card px-3 py-1.5 text-sm text-foreground outline-none" + /> + <button + type="button" + aria-label={`Remove ${label} ${index + 1}`} + onClick={() => onChange(items.filter((_, i) => i !== index))} + className="rounded-md p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground" + > + <Trash2 className="h-4 w-4" aria-hidden /> + </button> + </li> + ))} + </ul> + <Button + type="button" + variant="outline" + size="sm" + className="w-fit" + data-testid={addTestId} + onClick={() => onChange([...items, ""])} + > + <Plus className="h-4 w-4" aria-hidden /> + Add + </Button> + </div> + ); +} 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..5bb82ccf --- /dev/null +++ b/apps/web/src/components/spec-studio/new-spec-page.test.tsx @@ -0,0 +1,118 @@ +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 { EpicDTO, SpecManifest } from "@/lib/api/types"; + +import { NewSpecPage } from "./new-spec-page"; + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: vi.fn(), replace: vi.fn(), refresh: vi.fn() }), +})); + +const epics: EpicDTO[] = [ + { id: "e1", title: "Auth overhaul" }, + { id: "e2", title: "Billing v2" }, +]; + +function makeClient(overrides: Partial<ForgeApiClient> = {}): ForgeApiClient { + return { + listEpics: vi.fn(() => Promise.resolve(epics)), + 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), + ), + ...overrides, + } as unknown as ForgeApiClient; +} + +function renderPage(client: ForgeApiClient, onCreated = vi.fn()) { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + function Wrapper({ children }: { children: ReactNode }) { + return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>; + } + return { + onCreated, + ...render(<NewSpecPage client={client} onCreated={onCreated} />, { 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")); + }); +}); 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..1c3c9310 --- /dev/null +++ b/apps/web/src/components/spec-studio/new-spec-page.tsx @@ -0,0 +1,126 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useState } from "react"; + +import { Button } from "@/components/ui/button"; +import { ApiError, apiClient, type ForgeApiClient } from "@/lib/api/client"; +import { useEpics } from "@/lib/api/hooks"; +import { useCreateSpec } from "@/lib/api/spec"; +import type { SpecManifest } from "@/lib/api/types"; +import { cn } from "@/lib/utils"; + +import { GuidedMode } from "./guided-mode"; + +export interface NewSpecPageProps { + client?: ForgeApiClient; + /** Navigate to the created spec (defaults to router push to /specs/{id}). */ + onCreated?: (specId: string) => void; +} + +function errorMessage(error: unknown): string { + if (error instanceof ApiError) return error.message; + if (error instanceof Error) return error.message; + return "Something went wrong"; +} + +/** + * `/specs/new` — the guided spec-creation entry point. Pick the epic the spec + * belongs to, then draft the manifest in the same Guided-mode form used to + * edit an existing spec, so authoring feels identical whether you're + * starting fresh or refining later. On create, hands off to `/specs/{id}` + * where the full four-mode Spec Studio (Guided/Markdown/YAML/Read) takes over. + */ +export function NewSpecPage({ + client = apiClient, + onCreated, +}: NewSpecPageProps) { + const router = useRouter(); + const epicsQuery = useEpics(client); + const createSpec = useCreateSpec(client); + + const [epicId, setEpicId] = useState(""); + const [draft, setDraft] = useState<SpecManifest>({ id: "", name: "" }); + + const epics = epicsQuery.data ?? []; + const canCreate = + Boolean(epicId) && draft.name.trim().length > 0 && !createSpec.isPending; + + function handleCreate() { + if (!canCreate) return; + createSpec.mutate( + { + epic_id: epicId, + name: draft.name, + requirements: draft.requirements, + acceptance_criteria: draft.acceptance_criteria, + open_questions: draft.open_questions, + constraints: draft.constraints, + decisions: draft.decisions, + execution_mode: draft.execution_mode, + constitution_refs: draft.constitution_refs, + repos: draft.repos, + }, + { + onSuccess: (created) => { + if (onCreated) onCreated(created.id); + else router.push(`/specs/${encodeURIComponent(created.id)}`); + }, + }, + ); + } + + return ( + <div className="flex flex-col gap-5" data-testid="new-spec-page"> + <header className="flex flex-col gap-1"> + <h1 className="font-display text-xl font-semibold tracking-tight text-foreground"> + New spec + </h1> + <p className="text-sm text-muted-foreground"> + Draft the goal, requirements and acceptance criteria — refine and + switch to Markdown or YAML any time after it’s created. + </p> + </header> + + <label className="flex flex-col gap-1.5 text-sm"> + <span className="font-medium text-foreground">Epic</span> + <select + data-testid="new-spec-epic" + value={epicId} + onChange={(event) => setEpicId(event.target.value)} + disabled={epicsQuery.isLoading} + className={cn( + "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", + )} + > + <option value="">Select an epic…</option> + {epics.map((epic) => ( + <option key={epic.id} value={epic.id ?? ""}> + {epic.title} + </option> + ))} + </select> + </label> + + <GuidedMode + value={draft} + onChange={setDraft} + onSave={handleCreate} + saving={createSpec.isPending} + dirty={canCreate} + saveError={createSpec.isError ? errorMessage(createSpec.error) : null} + /> + + <div className="flex justify-end"> + <Button + onClick={handleCreate} + disabled={!canCreate} + data-testid="create-spec" + > + {createSpec.isPending ? "Creating…" : "Create spec"} + </Button> + </div> + </div> + ); +} diff --git a/apps/web/src/components/spec-studio/spec-studio-page.test.tsx b/apps/web/src/components/spec-studio/spec-studio-page.test.tsx new file mode 100644 index 00000000..04e4b215 --- /dev/null +++ b/apps/web/src/components/spec-studio/spec-studio-page.test.tsx @@ -0,0 +1,40 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, screen } 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 { SpecManifest } from "@/lib/api/types"; + +import { SpecStudioPage } from "./spec-studio-page"; + +const manifest: SpecManifest = { id: "s1", name: "Passwordless auth", status: "draft" }; + +function makeClient(overrides: Partial<ForgeApiClient> = {}): ForgeApiClient { + return { + getSpecManifest: vi.fn(() => Promise.resolve(manifest)), + ...overrides, + } as unknown as ForgeApiClient; +} + +function renderPage(client: ForgeApiClient) { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + function Wrapper({ children }: { children: ReactNode }) { + return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>; + } + return render(<SpecStudioPage specId="s1" client={client} />, { wrapper: Wrapper }); +} + +describe("SpecStudioPage", () => { + it("renders the spec name and defaults to the Guided-mode Spec Studio", async () => { + renderPage(makeClient()); + expect(await screen.findByText("Passwordless auth")).toBeInTheDocument(); + expect(await screen.findByTestId("guided-mode")).toBeInTheDocument(); + expect(screen.getByRole("link", { name: /back to spec validation/i })).toHaveAttribute( + "href", + "/specs", + ); + }); +}); diff --git a/apps/web/src/components/spec-studio/spec-studio-page.tsx b/apps/web/src/components/spec-studio/spec-studio-page.tsx new file mode 100644 index 00000000..9b8b9de0 --- /dev/null +++ b/apps/web/src/components/spec-studio/spec-studio-page.tsx @@ -0,0 +1,44 @@ +"use client"; + +import Link from "next/link"; +import { ArrowLeft } from "lucide-react"; + +import { apiClient, type ForgeApiClient } from "@/lib/api/client"; +import { useSpecStudioManifest } from "@/lib/api/spec-studio"; + +import { SpecStudio } from "./spec-studio"; + +export interface SpecStudioPageProps { + specId: string; + client?: ForgeApiClient; +} + +/** + * `/specs/{id}` — a dedicated, full-page Spec Studio for one spec. Defaults + * to Guided mode (the friendliest surface) with Markdown, YAML and Read one + * tab away; the same round-tripping `SpecStudio` embedded in the F23 + * dashboard's "Studio" tab, given its own URL so a spec can be linked to, + * bookmarked and deep-linked directly. + */ +export function SpecStudioPage({ specId, client = apiClient }: SpecStudioPageProps) { + const manifestQuery = useSpecStudioManifest(specId, client); + const name = manifestQuery.data?.name; + + return ( + <div className="flex flex-col gap-4" data-testid="spec-studio-page"> + <div className="flex flex-col gap-1"> + <Link + href="/specs" + className="inline-flex w-fit items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground" + > + <ArrowLeft className="h-3.5 w-3.5" aria-hidden /> + Back to spec validation + </Link> + <h1 className="font-display text-xl font-semibold tracking-tight text-foreground"> + {name ?? "Spec"} + </h1> + </div> + <SpecStudio specId={specId} client={client} /> + </div> + ); +} diff --git a/apps/web/src/components/spec/spec-dashboard.tsx b/apps/web/src/components/spec/spec-dashboard.tsx index 871c093c..8db13904 100644 --- a/apps/web/src/components/spec/spec-dashboard.tsx +++ b/apps/web/src/components/spec/spec-dashboard.tsx @@ -6,11 +6,13 @@ import { Landmark, ListChecks, Pencil, + Plus, Route, ShieldCheck, Stamp, XCircle, } from "lucide-react"; +import Link from "next/link"; import { useCallback, useEffect, @@ -166,7 +168,14 @@ export function SpecDashboard({ {specs.length} {specs.length === 1 ? "spec" : "specs"} </span> </div> - {selected ? ( + <div className="flex items-center gap-3"> + <Button asChild size="sm" variant="outline" data-testid="new-spec-link"> + <Link href="/specs/new"> + <Plus className="h-4 w-4" aria-hidden /> + New spec + </Link> + </Button> + {selected ? ( <div className="flex items-center gap-3"> <span data-testid="selected-status" @@ -189,7 +198,8 @@ export function SpecDashboard({ </Button> ) : null} </div> - ) : null} + ) : null} + </div> </header> <div className="grid min-h-0 flex-1 grid-cols-1 gap-4 lg:grid-cols-[minmax(15rem,20rem)_1fr]"> @@ -507,6 +517,12 @@ function EmptyList() { Create a spec from an epic to start the SDD lifecycle — draft, clarify, approve, then validate. </p> + <Button asChild size="sm" variant="outline" data-testid="empty-new-spec-link"> + <Link href="/specs/new"> + <Plus className="h-4 w-4" aria-hidden /> + New spec + </Link> + </Button> </div> ); } diff --git a/apps/web/src/lib/api/spec.test.tsx b/apps/web/src/lib/api/spec.test.tsx index fb758ffa..207e2c05 100644 --- a/apps/web/src/lib/api/spec.test.tsx +++ b/apps/web/src/lib/api/spec.test.tsx @@ -4,7 +4,7 @@ import type { ReactNode } from "react"; import { describe, expect, it, vi } from "vitest"; import type { ForgeApiClient } from "./client"; -import { specKeys, useApproveSpec, useSpecOverview } from "./spec"; +import { specKeys, useApproveSpec, useCreateSpec, useSpecOverview } from "./spec"; import type { SpecDashboard, SpecManifest } from "./types"; function makeWrapper(client: QueryClient) { @@ -98,3 +98,64 @@ describe("useApproveSpec (optimistic)", () => { expect(data?.specs.find((s) => s.id === "s1")?.status).toBe("clarifying"); }); }); + +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..c86ae24f 100644 --- a/apps/web/src/lib/api/spec.ts +++ b/apps/web/src/lib/api/spec.ts @@ -21,7 +21,15 @@ import { } from "@tanstack/react-query"; import { apiClient, type ForgeApiClient } from "./client"; -import type { SpecDashboard, SpecManifest } from "./types"; +import type { + ADR, + AcceptanceCriterion, + ExecutionMode, + OpenQuestion, + Requirement, + SpecDashboard, + SpecManifest, +} from "./types"; export const specKeys = { all: () => ["specs"] as const, @@ -41,6 +49,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<SpecManifest, Error, CreateSpecVariables> { + 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 +146,19 @@ interface ApproveSpecContext { */ export function useApproveSpec( client: ForgeApiClient = apiClient, -): UseMutationResult<SpecManifest, Error, ApproveSpecVariables, ApproveSpecContext> { +): UseMutationResult< + SpecManifest, + Error, + ApproveSpecVariables, + ApproveSpecContext +> { const queryClient = useQueryClient(); - return useMutation<SpecManifest, Error, ApproveSpecVariables, ApproveSpecContext>({ + return useMutation< + SpecManifest, + Error, + ApproveSpecVariables, + ApproveSpecContext + >({ mutationFn: ({ specId }) => client.approveSpec(specId), onMutate: async ({ specId }) => { await queryClient.cancelQueries({ queryKey: specKeys.overviews() }); From 08af69e2f812c8af0345d6b799ca2865e4b571b9 Mon Sep 17 00:00:00 2001 From: Forge Swarm <swarm@forge.local> Date: Thu, 9 Jul 2026 01:16:06 +0200 Subject: [PATCH 08/20] feat(ss-markdown): Spec Studio Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --- .../spec-studio/markdown-mode.test.tsx | 136 +++++++ .../components/spec-studio/markdown-mode.tsx | 351 +++++++++++++++++- .../spec-studio/spec-studio.test.tsx | 18 + .../lib/spec-studio/markdown-parse.test.ts | 145 ++++++++ .../web/src/lib/spec-studio/markdown-parse.ts | 351 ++++++++++++++++++ 5 files changed, 988 insertions(+), 13 deletions(-) create mode 100644 apps/web/src/components/spec-studio/markdown-mode.test.tsx create mode 100644 apps/web/src/lib/spec-studio/markdown-parse.test.ts create mode 100644 apps/web/src/lib/spec-studio/markdown-parse.ts 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 <MarkdownMode value={value} onChange={setValue} onSave={vi.fn()} dirty={value !== initial} />; +} + +describe("MarkdownMode", () => { + it("shows a valid parse status and defaults to the Structure panel", () => { + render(<MarkdownMode value={VALID} onChange={vi.fn()} onSave={vi.fn()} dirty={false} />); + 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(<MarkdownMode value={VALID} onChange={vi.fn()} onSave={vi.fn()} />); + 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(<MarkdownMode value={VALID} onChange={vi.fn()} onSave={vi.fn()} />); + 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(<MarkdownMode value={VALID} onChange={vi.fn()} onSave={vi.fn()} />); + + 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( + <MarkdownMode + value={"---\nid: SPEC-1\n---\n\n## Requirements\n\n- broken\n"} + onChange={vi.fn()} + onSave={vi.fn()} + dirty + />, + ); + 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(<MarkdownMode value={text} onChange={vi.fn()} onSave={vi.fn()} />); + 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(<Harness initial={""} />); + 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(<Harness initial={"abc"} />); + 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(<MarkdownMode value={VALID} onChange={vi.fn()} onSave={onSave} dirty />); + const button = screen.getByTestId("markdown-save"); + expect(button).toBeEnabled(); + fireEvent.click(button); + expect(onSave).toHaveBeenCalled(); + }); + + it("surfaces a save error when provided", () => { + render(<MarkdownMode value={VALID} onChange={vi.fn()} onSave={vi.fn()} dirty saveError="409 conflict" />); + 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 index 3ba7229d..62d86a6a 100644 --- a/apps/web/src/components/spec-studio/markdown-mode.tsx +++ b/apps/web/src/components/spec-studio/markdown-mode.tsx @@ -1,6 +1,14 @@ "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). */ @@ -12,10 +20,227 @@ export interface MarkdownModeProps { 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<HTMLTextAreaElement | null>, 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<typeof parseSpecMarkdown>["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 ( + <ul className="flex flex-col gap-1" data-testid="markdown-issues" aria-label="spec.md parse issues"> + {issues.map((issue, index) => ( + <li key={`${issue.line}-${index}`}> + <button + type="button" + className={cn( + "flex w-full items-start gap-2 rounded-md border px-3 py-1.5 text-left text-xs", + issue.severity === "error" + ? "border-danger/30 bg-danger/5 text-danger" + : "border-warning/30 bg-warning/5 text-warning", + )} + onClick={() => onJump(issue.line)} + > + <span className="font-mono text-[11px] shrink-0">Ln {issue.line}</span> + <span>{issue.message}</span> + </button> + </li> + ))} + </ul> + ); +} + +function StructurePanel({ manifest }: { manifest: ReturnType<typeof parseSpecMarkdown>["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 ( + <div className="flex flex-col gap-4" data-testid="markdown-panel-structure"> + <dl className="grid grid-cols-2 gap-3"> + {counts.map((c) => ( + <div key={c.label} className="rounded-md border border-border bg-card/60 px-3 py-2"> + <dt className="text-[11px] uppercase tracking-wide text-muted-foreground">{c.label}</dt> + <dd className="font-mono text-lg text-foreground">{c.count}</dd> + </div> + ))} + </dl> + <div> + <p className="mb-1 text-xs font-medium text-foreground"> + Requirement coverage: {coverage.satisfied}/{coverage.total} ({coverage.pct}%) + </p> + <ul className="flex flex-col gap-1" data-testid="markdown-checklist"> + {checklist.map((item) => ( + <li key={item.id} className="flex items-center gap-2 text-xs"> + <CheckCircle2 + className={cn("h-3.5 w-3.5 shrink-0", item.done ? "text-success" : "text-muted-foreground/40")} + aria-hidden + /> + <span className={item.done ? "text-foreground" : "text-muted-foreground"}>{item.label}</span> + </li> + ))} + </ul> + </div> + {nudges.length > 0 ? ( + <ul className="flex flex-col gap-1" data-testid="markdown-nudges"> + {nudges.map((nudge) => ( + <li + key={nudge.id} + className="flex items-start gap-1.5 rounded-md border border-warning/30 bg-warning/5 px-2 py-1 text-xs text-warning" + > + <AlertTriangle className="mt-0.5 h-3 w-3 shrink-0" aria-hidden /> + {nudge.message} + </li> + ))} + </ul> + ) : null} + </div> + ); +} + +function PreviewPanel({ manifest }: { manifest: ReturnType<typeof parseSpecMarkdown>["manifest"] }) { + const requirements = manifest.requirements ?? []; + const criteria = manifest.acceptance_criteria ?? []; + const constraints = manifest.constraints ?? []; + const openQuestions = manifest.open_questions ?? []; + const decisions = manifest.decisions ?? []; + return ( + <div className="flex flex-col gap-5" data-testid="markdown-panel-preview"> + <section> + <h3 className="font-display text-sm font-semibold text-foreground">Goal</h3> + <p className="text-sm text-foreground/90">{manifest.name || "—"}</p> + </section> + {requirements.length > 0 ? ( + <section> + <h3 className="font-display text-sm font-semibold text-foreground">Requirements</h3> + <ul className="flex flex-col gap-1"> + {requirements.map((r) => ( + <li key={r.id} className="text-sm text-foreground/90"> + <span className="font-mono text-xs text-primary">{r.id}</span> {r.text} + </li> + ))} + </ul> + </section> + ) : null} + {criteria.length > 0 ? ( + <section> + <h3 className="font-display text-sm font-semibold text-foreground">Acceptance Criteria</h3> + <ul className="flex flex-col gap-1"> + {criteria.map((c) => ( + <li key={c.id} className="text-sm text-foreground/90"> + <span className="font-mono text-xs text-primary">{c.id}</span>{" "} + {(c.req_refs ?? []).length > 0 ? ( + <span className="font-mono text-[11px] text-muted-foreground"> + ({(c.req_refs ?? []).join(", ")}) + </span> + ) : null}{" "} + {c.text} + </li> + ))} + </ul> + </section> + ) : null} + {constraints.length > 0 ? ( + <section> + <h3 className="font-display text-sm font-semibold text-foreground">Constraints</h3> + <ul className="flex flex-col gap-1"> + {constraints.map((c, i) => ( + <li key={i} className="text-sm text-foreground/90"> + {c} + </li> + ))} + </ul> + </section> + ) : null} + {openQuestions.length > 0 ? ( + <section> + <h3 className="font-display text-sm font-semibold text-foreground">Open Questions</h3> + <ul className="flex flex-col gap-1"> + {openQuestions.map((q) => ( + <li key={q.id} className="text-sm text-foreground/90"> + <span className="font-mono text-xs text-primary">{q.id}</span> {q.text} + {q.resolution ? ( + <span className="block pl-4 text-xs text-success">Resolution: {q.resolution}</span> + ) : null} + </li> + ))} + </ul> + </section> + ) : null} + {decisions.length > 0 ? ( + <section> + <h3 className="font-display text-sm font-semibold text-foreground">Decisions</h3> + <ul className="flex flex-col gap-2"> + {decisions.map((d) => ( + <li key={d.id} className="text-sm text-foreground/90"> + <span className="font-mono text-xs text-primary">{d.id}</span> — {d.title} + </li> + ))} + </ul> + </section> + ) : null} + </div> + ); +} + /** * The `spec.md` prose editor — Spec Studio's default human/agent surface. - * A plain textarea (no schema gate: prose is forgiving); saving re-renders - * `manifest.yaml` to match on the backend. + * 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, @@ -25,29 +250,129 @@ export function MarkdownMode({ dirty = false, saveError, }: MarkdownModeProps) { + const [panel, setPanel] = useState<PanelTab>("structure"); + const textareaRef = useRef<HTMLTextAreaElement>(null); + const gutterRef = useRef<HTMLDivElement>(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<HTMLTextAreaElement>) => { + setScrollTop(event.currentTarget.scrollTop); + }; + + /** Keyboard-first: `Tab` indents (two spaces) instead of leaving the editor. */ + const onKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => { + 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 ( <div className="flex flex-col gap-3" data-testid="markdown-mode"> <div className="flex items-center justify-between gap-3"> - <span className="text-xs text-muted-foreground"> - {dirty ? "Unsaved changes" : "spec.md"} - </span> + <div className="flex items-center gap-2 text-xs text-muted-foreground"> + {invalid ? ( + <span className="inline-flex items-center gap-1 text-danger" data-testid="markdown-status-invalid"> + <AlertTriangle className="h-3.5 w-3.5" aria-hidden /> + {issues.filter((i) => i.severity === "error").length} issue + {issues.filter((i) => i.severity === "error").length === 1 ? "" : "s"} + </span> + ) : ( + <span className="inline-flex items-center gap-1 text-success" data-testid="markdown-status-valid"> + <CheckCircle2 className="h-3.5 w-3.5" aria-hidden /> + Parses cleanly + </span> + )} + {dirty ? <span className="text-muted-foreground/70">Unsaved changes</span> : null} + </div> <Button size="sm" onClick={onSave} disabled={saving || !dirty} data-testid="markdown-save"> {saving ? "Saving…" : "Save spec.md"} </Button> </div> - <textarea - data-testid="markdown-textarea" - aria-label="spec.md" - spellCheck={false} - value={value} - onChange={(event) => onChange(event.target.value)} - className="min-h-[24rem] resize-none rounded-lg border border-border bg-card px-3 py-3 font-mono text-xs leading-5 text-foreground outline-none" - /> + + <div className="grid grid-cols-1 gap-3 lg:grid-cols-2"> + <div className="flex overflow-hidden rounded-lg border border-border bg-card"> + <div + ref={gutterRef} + aria-hidden + className="select-none overflow-hidden border-r border-border bg-muted/40 px-3 py-3 text-right font-mono text-xs leading-5 text-muted-foreground/70" + style={{ transform: `translateY(-${scrollTop}px)` }} + > + {Array.from({ length: lineCount }, (_, i) => ( + <div key={i}>{i + 1}</div> + ))} + </div> + <textarea + ref={textareaRef} + data-testid="markdown-textarea" + aria-label="spec.md" + spellCheck={false} + value={value} + onChange={(event) => onChange(event.target.value)} + onScroll={onScroll} + onKeyDown={onKeyDown} + className="min-h-[28rem] flex-1 resize-none bg-transparent px-3 py-3 font-mono text-xs leading-5 text-foreground outline-none" + /> + </div> + + <div className="flex flex-col gap-3 rounded-lg border border-border bg-card/60 p-3"> + <div + role="tablist" + aria-label="spec.md parsed view" + className="inline-flex w-fit items-center gap-1 rounded-lg border border-border bg-muted/50 p-1" + > + {PANEL_TABS.map((tab) => { + const Icon = tab.icon; + const selected = tab.id === panel; + return ( + <button + key={tab.id} + role="tab" + type="button" + aria-selected={selected} + onClick={() => setPanel(tab.id)} + data-testid={`markdown-panel-tab-${tab.id}`} + className={cn( + "inline-flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-medium transition-colors", + "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", + selected ? "bg-card text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground", + )} + > + <Icon className="h-3.5 w-3.5" aria-hidden /> + {tab.label} + </button> + ); + })} + </div> + <div className="min-h-0 flex-1 overflow-y-auto"> + {panel === "structure" ? <StructurePanel manifest={manifest} /> : null} + {panel === "preview" ? <PreviewPanel manifest={manifest} /> : null} + {panel === "traceability" ? ( + <div data-testid="markdown-panel-traceability"> + <TraceabilityMatrix traces={traces} /> + </div> + ) : null} + </div> + </div> + </div> + {saveError ? ( <p role="alert" className="text-xs text-danger" data-testid="markdown-save-error"> {saveError} </p> ) : null} + + <IssueList issues={issues} onJump={(line) => jumpToLine(textareaRef, line)} /> </div> ); } diff --git a/apps/web/src/components/spec-studio/spec-studio.test.tsx b/apps/web/src/components/spec-studio/spec-studio.test.tsx index 96201041..f1614b63 100644 --- a/apps/web/src/components/spec-studio/spec-studio.test.tsx +++ b/apps/web/src/components/spec-studio/spec-studio.test.tsx @@ -99,6 +99,24 @@ describe("SpecStudio", () => { ); }); + it("preserves unsaved Markdown edits when switching to Guided and back", async () => { + renderStudio(makeClient()); + await screen.findByTestId("guided-mode"); + + fireEvent.click(screen.getByTestId("studio-mode-markdown")); + const textarea = await screen.findByTestId("markdown-textarea"); + fireEvent.change(textarea, { + target: { value: "---\nid: SPEC-1\n---\n\n## Goal\n\nRenamed via Markdown\n" }, + }); + + fireEvent.click(screen.getByTestId("studio-mode-guided")); + fireEvent.click(screen.getByTestId("studio-mode-markdown")); + + expect(await screen.findByTestId("markdown-textarea")).toHaveValue( + "---\nid: SPEC-1\n---\n\n## Goal\n\nRenamed via Markdown\n", + ); + }); + it("saving in YAML mode invalidates the Markdown buffer so it reloads fresh, synced text", async () => { const client = makeClient({ putSpecManifestYaml: vi.fn(() => 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..152301ab --- /dev/null +++ b/apps/web/src/lib/spec-studio/markdown-parse.test.ts @@ -0,0 +1,145 @@ +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("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..f6b42e78 --- /dev/null +++ b/apps/web/src/lib/spec-studio/markdown-parse.ts @@ -0,0 +1,351 @@ +/** + * 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<string, "status" | "context" | "decision" | "consequences"> = { + 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<string, unknown>; 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<string, unknown>, 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)) { + 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<string, string> = {}; + 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"); +} From d8bfb51edc4b52455f3a9fe79da3f5b75bac0c41 Mon Sep 17 00:00:00 2001 From: Forge Swarm <swarm@forge.local> Date: Thu, 9 Jul 2026 01:45:59 +0200 Subject: [PATCH 09/20] feat(ss-read): Spec Studio Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --- .../components/spec-studio/read-mode.test.tsx | 129 ++++++++ .../src/components/spec-studio/read-mode.tsx | 278 ++++++++++++++++++ .../components/spec-studio/spec-studio.tsx | 13 +- 3 files changed, 418 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/components/spec-studio/read-mode.test.tsx create mode 100644 apps/web/src/components/spec-studio/read-mode.tsx diff --git a/apps/web/src/components/spec-studio/read-mode.test.tsx b/apps/web/src/components/spec-studio/read-mode.test.tsx new file mode 100644 index 00000000..4bff4d70 --- /dev/null +++ b/apps/web/src/components/spec-studio/read-mode.test.tsx @@ -0,0 +1,129 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import type { SpecManifest } from "@/lib/api/types"; + +import { ReadMode } from "./read-mode"; + +const baseSpec: SpecManifest = { + id: "SPEC-1", + name: "Passwordless auth", + status: "draft", + requirements: [{ id: "R1", text: "Sign in without a password" }], + acceptance_criteria: [ + { id: "AC1", text: "Given a valid magic link, when opened, then the user is signed in.", req_refs: ["R1"] }, + ], + constraints: ["Must work offline"], + open_questions: [{ id: "Q1", text: "What about shared devices?" }], + decisions: [{ id: "ADR-1", title: "Use magic links", decision: "Adopt email magic links." }], +}; + +function setup(overrides: Partial<React.ComponentProps<typeof ReadMode>> = {}) { + const props = { + spec: baseSpec, + onApprove: vi.fn(), + ...overrides, + } as React.ComponentProps<typeof ReadMode>; + render(<ReadMode {...props} />); + return props; +} + +describe("ReadMode", () => { + it("renders clean rendered prose for every populated spec.md section", () => { + setup(); + const prose = screen.getByTestId("read-prose"); + expect(prose).toHaveTextContent("Passwordless auth"); + expect(prose).toHaveTextContent("Sign in without a password"); + expect(prose).toHaveTextContent("Given a valid magic link"); + expect(prose).toHaveTextContent("AC1 (R1)"); + expect(prose).toHaveTextContent("Must work offline"); + expect(prose).toHaveTextContent("What about shared devices?"); + expect(prose).toHaveTextContent("Use magic links"); + }); + + it("keeps the full manifest facts panel one disclosure away", () => { + setup(); + expect(screen.getByTestId("manifest-panel")).toBeInTheDocument(); + }); + + it("shows the spec's current lifecycle status", () => { + setup({ spec: { ...baseSpec, status: "clarifying" } }); + expect(screen.getByTestId("read-status")).toHaveTextContent("Clarifying"); + }); + + it("clicking Approve calls onApprove directly (no note needed)", () => { + const onApprove = vi.fn(); + setup({ onApprove }); + fireEvent.click(screen.getByTestId("decision-approve")); + expect(onApprove).toHaveBeenCalledTimes(1); + }); + + it("pressing 'a' approves via keyboard shortcut", () => { + const onApprove = vi.fn(); + setup({ onApprove }); + fireEvent.keyDown(screen.getByTestId("read-mode"), { key: "a" }); + expect(onApprove).toHaveBeenCalledTimes(1); + }); + + it("pressing 'x' opens the reject note composer, and confirming records + calls onReject", () => { + const onReject = vi.fn(); + setup({ onReject }); + fireEvent.keyDown(screen.getByTestId("read-mode"), { key: "x" }); + const composer = screen.getByTestId("reason-composer"); + fireEvent.change(composer.querySelector("textarea") as HTMLTextAreaElement, { + target: { value: "Missing offline handling" }, + }); + fireEvent.click(screen.getByTestId("confirm-decision")); + expect(onReject).toHaveBeenCalledWith("Missing offline handling"); + expect(screen.getByTestId("review-recorded")).toHaveTextContent("Rejected"); + expect(screen.getByTestId("review-recorded")).toHaveTextContent("Missing offline handling"); + }); + + it("pressing 'r' opens the request-changes note composer, and confirming records + calls onRequestChanges", () => { + const onRequestChanges = vi.fn(); + setup({ onRequestChanges }); + fireEvent.keyDown(screen.getByTestId("read-mode"), { key: "r" }); + const composer = screen.getByTestId("reason-composer"); + fireEvent.change(composer.querySelector("textarea") as HTMLTextAreaElement, { + target: { value: "Please add a rate limit" }, + }); + fireEvent.click(screen.getByTestId("confirm-decision")); + expect(onRequestChanges).toHaveBeenCalledWith("Please add a rate limit"); + expect(screen.getByTestId("review-recorded")).toHaveTextContent("Changes requested"); + }); + + it("Escape cancels the note composer without recording a decision", () => { + const onReject = vi.fn(); + setup({ onReject }); + fireEvent.keyDown(screen.getByTestId("read-mode"), { key: "x" }); + fireEvent.keyDown(screen.getByTestId("reason-composer").querySelector("textarea") as HTMLTextAreaElement, { + key: "Escape", + }); + expect(screen.queryByTestId("reason-composer")).not.toBeInTheDocument(); + expect(onReject).not.toHaveBeenCalled(); + expect(screen.queryByTestId("review-recorded")).not.toBeInTheDocument(); + }); + + it("disables the decision bar once the spec is past the human gate", () => { + setup({ spec: { ...baseSpec, status: "approved" } }); + expect(screen.getByTestId("review-gate-closed")).toBeInTheDocument(); + expect(screen.getByTestId("decision-approve")).toBeDisabled(); + }); + + it("does not react to keyboard shortcuts once past the human gate", () => { + const onApprove = vi.fn(); + setup({ spec: { ...baseSpec, status: "approved" }, onApprove }); + fireEvent.keyDown(screen.getByTestId("read-mode"), { key: "a" }); + expect(onApprove).not.toHaveBeenCalled(); + }); + + it("surfaces an approve error from the caller", () => { + setup({ approveError: "Couldn't reach the spec engine" }); + expect(screen.getByRole("alert")).toHaveTextContent("Couldn't reach the spec engine"); + }); + + it("shows a saving state on Approve while the mutation is pending", () => { + setup({ approving: true }); + expect(screen.getByTestId("decision-approve")).toBeDisabled(); + }); +}); diff --git a/apps/web/src/components/spec-studio/read-mode.tsx b/apps/web/src/components/spec-studio/read-mode.tsx new file mode 100644 index 00000000..c9ec5b3c --- /dev/null +++ b/apps/web/src/components/spec-studio/read-mode.tsx @@ -0,0 +1,278 @@ +"use client"; + +import { useCallback, useState, type KeyboardEvent } from "react"; + +import { actionForKey } from "@/components/approvals/approval-meta"; +import { DecisionBar } from "@/components/approvals/decision-bar"; +import { ManifestPanel } from "@/components/spec/manifest-panel"; +import { STATUS_LABELS, isApprovable, statusBadgeClass } from "@/components/spec/spec-meta"; +import type { ApprovalAction, SpecManifest } from "@/lib/api/types"; +import { cn } from "@/lib/utils"; + +/** The three review decisions Read mode exposes (no escalate — that's F36's). */ +const REVIEW_ACTIONS: ApprovalAction[] = ["approve", "request_changes", "reject"]; + +function isEditableTarget(target: EventTarget | null): boolean { + if (!(target instanceof HTMLElement)) return false; + const tag = target.tagName; + return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || target.isContentEditable; +} + +export interface ReadModeProps { + spec: SpecManifest; + /** Approves the spec at the human gate (`POST /spec/specs/{id}/approve`, real). */ + onApprove: () => void; + approving?: boolean; + approveError?: string | null; + /** + * Reject / request-changes have no backend endpoint yet — `forge_spec`'s + * `FileSpecEngine` only exposes `approve_spec` (no `reject_spec` / + * `request_changes` state transition, and `SpecStatus` has no such values). + * Read mode still records the decision + note locally (so the keyboard-first + * review flow is fully usable) and surfaces it through these optional + * callbacks for a caller to persist once that endpoint exists — parked, + * tracked separately; not faked as a server round-trip. + */ + onReject?: (note: string) => void; + onRequestChanges?: (note: string) => void; +} + +/** + * Read mode — Spec Studio's clean, rendered-prose surface for reviewers (the + * same sections `spec.md` renders: Goal, Requirements, Acceptance Criteria, + * Constraints, Open Questions, Decisions) paired with the approval gate: + * Approve / Reject / Request changes, keyboard-first (`a`/`x`/`r`, the same + * map the F36 approval inbox uses) via the shared {@link DecisionBar}. The + * full manifest facts (repos, plan/tasks/validation refs) stay one disclosure + * away rather than competing with the prose for attention. + */ +export function ReadMode({ + spec, + onApprove, + approving = false, + approveError = null, + onReject, + onRequestChanges, +}: ReadModeProps) { + const [activeNote, setActiveNote] = useState<"reject" | "request_changes" | null>(null); + const [note, setNote] = useState(""); + const [recorded, setRecorded] = useState<{ action: "reject" | "request_changes"; note: string } | null>( + null, + ); + + const reviewable = isApprovable(spec.status); + + const submit = useCallback( + (action: ApprovalAction, reason?: string) => { + if (action === "approve") { + onApprove(); + return; + } + const decision = action as "reject" | "request_changes"; + const trimmed = (reason ?? "").trim(); + setRecorded({ action: decision, note: trimmed }); + setActiveNote(null); + setNote(""); + if (decision === "reject") onReject?.(trimmed); + else onRequestChanges?.(trimmed); + }, + [onApprove, onReject, onRequestChanges], + ); + + const trigger = useCallback( + (action: ApprovalAction) => { + if (!reviewable || approving) return; + if (action === "reject" || action === "request_changes") { + setNote(""); + setActiveNote(action); + } else { + submit(action); + } + }, + [reviewable, approving, submit], + ); + + const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => { + if (isEditableTarget(event.target) || activeNote) return; + const action = actionForKey(event.key); + if (action && REVIEW_ACTIONS.includes(action)) { + event.preventDefault(); + trigger(action); + } + }; + + const requirements = spec.requirements ?? []; + const criteria = spec.acceptance_criteria ?? []; + const constraints = spec.constraints ?? []; + const openQuestions = spec.open_questions ?? []; + const decisions = spec.decisions ?? []; + + return ( + <div + data-testid="read-mode" + tabIndex={0} + onKeyDown={onKeyDown} + className="flex flex-col gap-6 outline-none" + > + <header className="flex flex-wrap items-center justify-between gap-3"> + <div> + <h2 className="font-display text-lg font-semibold tracking-tight text-foreground"> + {spec.name} + </h2> + <p className="text-xs text-muted-foreground"> + spec.md, rendered read-only for review + </p> + </div> + <span + data-testid="read-status" + className={cn( + "rounded-full border px-2.5 py-0.5 text-xs font-medium capitalize", + statusBadgeClass(spec.status), + )} + > + {STATUS_LABELS[spec.status ?? "draft"]} + </span> + </header> + + <article + data-testid="read-prose" + className="flex flex-col gap-5 rounded-lg border border-border bg-card/60 p-5" + > + <section> + <h3 className="font-display text-sm font-semibold text-foreground">Goal</h3> + <p className="mt-1 text-sm leading-relaxed text-foreground/90">{spec.name}</p> + </section> + + {requirements.length > 0 ? ( + <section> + <h3 className="font-display text-sm font-semibold text-foreground">Requirements</h3> + <ul className="mt-1 flex flex-col gap-1.5"> + {requirements.map((r) => ( + <li key={r.id} className="text-sm leading-relaxed text-foreground/90"> + <span className="font-mono text-xs text-primary">{r.id}</span> {r.text} + </li> + ))} + </ul> + </section> + ) : null} + + {criteria.length > 0 ? ( + <section> + <h3 className="font-display text-sm font-semibold text-foreground"> + Acceptance Criteria + </h3> + <ul className="mt-1 flex flex-col gap-1.5"> + {criteria.map((c) => { + const refs = c.req_refs ?? []; + return ( + <li key={c.id} className="text-sm leading-relaxed text-foreground/90"> + <span className="font-mono text-xs text-primary"> + {c.id} + {refs.length > 0 ? ` (${refs.join(", ")})` : ""}: + </span>{" "} + {c.text} + </li> + ); + })} + </ul> + </section> + ) : null} + + {constraints.length > 0 ? ( + <section> + <h3 className="font-display text-sm font-semibold text-foreground">Constraints</h3> + <ul className="mt-1 flex flex-col gap-1"> + {constraints.map((c, i) => ( + <li key={i} className="text-sm leading-relaxed text-foreground/90"> + {c} + </li> + ))} + </ul> + </section> + ) : null} + + {openQuestions.length > 0 ? ( + <section> + <h3 className="font-display text-sm font-semibold text-foreground"> + Open Questions + </h3> + <ul className="mt-1 flex flex-col gap-1"> + {openQuestions.map((q) => ( + <li key={q.id} className="text-sm leading-relaxed text-foreground/90"> + <span className="font-mono text-xs text-primary">{q.id}</span> {q.text} + {q.resolution ? ( + <span className="mt-0.5 block pl-4 text-xs text-success"> + Resolution: {q.resolution} + </span> + ) : null} + </li> + ))} + </ul> + </section> + ) : null} + + {decisions.length > 0 ? ( + <section> + <h3 className="font-display text-sm font-semibold text-foreground">Decisions</h3> + <ul className="mt-1 flex flex-col gap-2"> + {decisions.map((d) => ( + <li key={d.id} className="text-sm leading-relaxed text-foreground/90"> + <span className="font-mono text-xs text-primary">{d.id}</span> — {d.title} + {d.decision ? ( + <span className="block text-xs text-muted-foreground">{d.decision}</span> + ) : null} + </li> + ))} + </ul> + </section> + ) : null} + </article> + + <details className="rounded-lg border border-border bg-card/40 p-4" data-testid="read-manifest-facts"> + <summary className="cursor-pointer font-display text-sm font-semibold text-foreground"> + Manifest facts + </summary> + <div className="mt-4"> + <ManifestPanel spec={spec} /> + </div> + </details> + + <div className="rounded-lg border border-border bg-card" data-testid="review-gate"> + <div className="flex items-center justify-between gap-3 border-b border-border px-4 py-3"> + <h3 className="font-display text-sm font-semibold text-foreground">Approval gate</h3> + {!reviewable ? ( + <span className="text-xs text-muted-foreground" data-testid="review-gate-closed"> + Already past the human gate. + </span> + ) : null} + </div> + {recorded ? ( + <p + role="status" + data-testid="review-recorded" + className="px-4 pt-3 text-xs text-muted-foreground" + > + {recorded.action === "reject" ? "Rejected" : "Changes requested"} + {recorded.note ? ` — "${recorded.note}"` : ""} (recorded locally; not yet + persisted server-side). + </p> + ) : null} + <DecisionBar + actions={REVIEW_ACTIONS} + activeNote={activeNote} + note={note} + onNoteChange={setNote} + pending={approving} + disabled={!reviewable} + errorMessage={approveError} + onTrigger={trigger} + onConfirm={() => activeNote && submit(activeNote, note)} + onCancel={() => { + setActiveNote(null); + setNote(""); + }} + /> + </div> + </div> + ); +} diff --git a/apps/web/src/components/spec-studio/spec-studio.tsx b/apps/web/src/components/spec-studio/spec-studio.tsx index 6ec36d2e..3c8b7e6f 100644 --- a/apps/web/src/components/spec-studio/spec-studio.tsx +++ b/apps/web/src/components/spec-studio/spec-studio.tsx @@ -3,8 +3,8 @@ import { Eye, FileCode2, FileText, ListTree } from "lucide-react"; import { useState } from "react"; -import { ManifestPanel } from "@/components/spec/manifest-panel"; import { apiClient, ApiError, type ForgeApiClient } from "@/lib/api/client"; +import { useApproveSpec } from "@/lib/api/spec"; import { useSaveGuidedManifest, useSaveSpecMarkdown, @@ -18,6 +18,7 @@ import { cn } from "@/lib/utils"; import { GuidedMode } from "./guided-mode"; import { MarkdownMode } from "./markdown-mode"; +import { ReadMode } from "./read-mode"; import { YamlMode } from "./yaml-mode"; export type SpecStudioMode = "guided" | "markdown" | "yaml" | "read"; @@ -79,6 +80,7 @@ export function SpecStudio({ specId, client = apiClient }: SpecStudioProps) { const saveGuided = useSaveGuidedManifest(specId, client); const saveMarkdown = useSaveSpecMarkdown(specId, client); const saveYaml = useSaveSpecManifestYaml(specId, client); + const approveSpec = useApproveSpec(client); const manifest = manifestQuery.data ?? null; const guidedValue = guidedOverride ?? manifest; @@ -203,7 +205,14 @@ export function SpecStudio({ specId, client = apiClient }: SpecStudioProps) { /> ) ) : null} - {mode === "read" ? <ManifestPanel spec={manifest} /> : null} + {mode === "read" ? ( + <ReadMode + spec={manifest} + onApprove={() => approveSpec.mutate({ specId })} + approving={approveSpec.isPending} + approveError={approveSpec.isError ? errorMessage(approveSpec.error) : null} + /> + ) : null} </> )} </div> From a9f381f61656c63a3b2088664d67756de0412b08 Mon Sep 17 00:00:00 2001 From: Forge Swarm <swarm@forge.local> Date: Thu, 9 Jul 2026 03:09:16 +0200 Subject: [PATCH 10/20] feat(ss-lifecycle): Spec Studio Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --- .../spec-studio/spec-studio-page.tsx | 4 + .../components/spec/lifecycle-rail.test.tsx | 33 ---- .../src/components/spec/lifecycle-rail.tsx | 99 ---------- .../spec/lifecycle-stepper.test.tsx | 111 +++++++++++ .../src/components/spec/lifecycle-stepper.tsx | 170 +++++++++++++++++ .../components/spec/spec-dashboard.test.tsx | 2 +- .../src/components/spec/spec-dashboard.tsx | 4 +- .../web/src/components/spec/spec-meta.test.ts | 106 +++++++++-- apps/web/src/components/spec/spec-meta.ts | 104 ++++++---- apps/web/src/lib/api/spec.test.tsx | 177 +++++++++++++++++- apps/web/src/lib/api/spec.ts | 134 +++++++++++++ 11 files changed, 755 insertions(+), 189 deletions(-) delete mode 100644 apps/web/src/components/spec/lifecycle-rail.test.tsx delete mode 100644 apps/web/src/components/spec/lifecycle-rail.tsx create mode 100644 apps/web/src/components/spec/lifecycle-stepper.test.tsx create mode 100644 apps/web/src/components/spec/lifecycle-stepper.tsx diff --git a/apps/web/src/components/spec-studio/spec-studio-page.tsx b/apps/web/src/components/spec-studio/spec-studio-page.tsx index 9b8b9de0..4334f645 100644 --- a/apps/web/src/components/spec-studio/spec-studio-page.tsx +++ b/apps/web/src/components/spec-studio/spec-studio-page.tsx @@ -3,6 +3,7 @@ import Link from "next/link"; import { ArrowLeft } from "lucide-react"; +import { LifecycleStepper } from "@/components/spec/lifecycle-stepper"; import { apiClient, type ForgeApiClient } from "@/lib/api/client"; import { useSpecStudioManifest } from "@/lib/api/spec-studio"; @@ -38,6 +39,9 @@ export function SpecStudioPage({ specId, client = apiClient }: SpecStudioPagePro {name ?? "Spec"} </h1> </div> + {manifestQuery.data ? ( + <LifecycleStepper spec={manifestQuery.data} client={client} /> + ) : null} <SpecStudio specId={specId} client={client} /> </div> ); diff --git a/apps/web/src/components/spec/lifecycle-rail.test.tsx b/apps/web/src/components/spec/lifecycle-rail.test.tsx deleted file mode 100644 index 17c327a9..00000000 --- a/apps/web/src/components/spec/lifecycle-rail.test.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { render, screen } from "@testing-library/react"; -import { describe, expect, it } from "vitest"; - -import { LifecycleRail } from "./lifecycle-rail"; - -describe("LifecycleRail", () => { - it("renders all six SDD stages", () => { - render(<LifecycleRail status="approved" />); - for (const label of [ - "Draft", - "Clarifying", - "Approved", - "Implementing", - "Validated", - "Closed", - ]) { - expect(screen.getByText(label)).toBeInTheDocument(); - } - }); - - it("marks the current stage, past stages done, and future stages upcoming", () => { - render(<LifecycleRail status="approved" />); - expect(screen.getByTestId("stage-approved")).toHaveAttribute("data-state", "current"); - expect(screen.getByTestId("stage-approved")).toHaveAttribute("aria-current", "step"); - expect(screen.getByTestId("stage-draft")).toHaveAttribute("data-state", "done"); - expect(screen.getByTestId("stage-validated")).toHaveAttribute("data-state", "upcoming"); - }); - - it("defaults an unknown status to the draft stage", () => { - render(<LifecycleRail status={undefined} />); - expect(screen.getByTestId("stage-draft")).toHaveAttribute("data-state", "current"); - }); -}); diff --git a/apps/web/src/components/spec/lifecycle-rail.tsx b/apps/web/src/components/spec/lifecycle-rail.tsx deleted file mode 100644 index 2d7ab7e0..00000000 --- a/apps/web/src/components/spec/lifecycle-rail.tsx +++ /dev/null @@ -1,99 +0,0 @@ -import { Check, Flame } from "lucide-react"; - -import { cn } from "@/lib/utils"; -import type { SpecStatus } from "@/lib/api/types"; - -import { LIFECYCLE_STAGES, stageIndex, stageState } from "./spec-meta"; - -export interface LifecycleRailProps { - status: SpecStatus | undefined; -} - -/** - * The SDD lifecycle as a forge heat rail. Ember heat has travelled up to the - * spec's current stage — filled ember segments and a glowing, spark-ringed - * current node — with cold, dashed steel ahead. The rail encodes real progress - * (the lifecycle is a genuine sequence), so it doubles as the spec-status view. - */ -export function LifecycleRail({ status }: LifecycleRailProps) { - const current = stageIndex(status); - return ( - <div className="overflow-x-auto"> - <ol - data-testid="lifecycle-rail" - aria-label="SDD lifecycle" - className="flex min-w-[34rem] items-start" - > - {LIFECYCLE_STAGES.map((stage, index) => { - const state = stageState(index, status); - const heated = index <= current; - return ( - <li - key={stage.status} - data-testid={`stage-${stage.status}`} - data-state={state} - aria-current={state === "current" ? "step" : undefined} - className="relative flex flex-1 flex-col items-center gap-2 px-1 text-center" - > - {index > 0 ? ( - <span - aria-hidden - className={cn( - "absolute left-[-50%] top-[13px] -z-0 h-0.5 w-full", - heated - ? "bg-primary" - : "border-t-2 border-dashed border-border bg-transparent", - )} - /> - ) : null} - - <span className="relative z-10 flex h-7 w-7 items-center justify-center"> - {state === "current" ? ( - <span - aria-hidden - className="absolute inset-0 rounded-full bg-spark/30 motion-safe:animate-pulse" - /> - ) : null} - <span - className={cn( - "relative flex h-7 w-7 items-center justify-center rounded-full border text-[11px]", - state === "current" && - "border-spark bg-primary text-primary-foreground shadow-sm ring-4 ring-spark/25", - state === "done" && - "border-primary/60 bg-primary/15 text-primary", - state === "upcoming" && - "border-dashed border-border bg-muted text-muted-foreground", - )} - > - {state === "done" ? ( - <Check className="h-3.5 w-3.5" aria-hidden /> - ) : state === "current" ? ( - <Flame className="h-3.5 w-3.5" aria-hidden /> - ) : ( - <span className="h-1.5 w-1.5 rounded-full bg-current" aria-hidden /> - )} - </span> - </span> - - <div className="flex flex-col gap-0.5"> - <span - className={cn( - "font-display text-xs font-semibold tracking-tight", - state === "upcoming" - ? "text-muted-foreground" - : "text-foreground", - )} - > - {stage.label} - </span> - <span className="hidden text-[11px] leading-tight text-muted-foreground sm:block"> - {stage.blurb} - </span> - </div> - </li> - ); - })} - </ol> - </div> - ); -} diff --git a/apps/web/src/components/spec/lifecycle-stepper.test.tsx b/apps/web/src/components/spec/lifecycle-stepper.test.tsx new file mode 100644 index 00000000..737f210a --- /dev/null +++ b/apps/web/src/components/spec/lifecycle-stepper.test.tsx @@ -0,0 +1,111 @@ +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 { SpecOverview, TaskDTO } from "@/lib/api/types"; + +import { LifecycleStepper } from "./lifecycle-stepper"; + +function renderStepper(spec: SpecOverview, client: ForgeApiClient) { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + function Wrapper({ children }: { children: ReactNode }) { + return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>; + } + return render(<LifecycleStepper spec={spec} client={client} />, { wrapper: Wrapper }); +} + +function makeClient(overrides: Partial<ForgeApiClient> = {}): ForgeApiClient { + return { ...overrides } as unknown as ForgeApiClient; +} + +describe("LifecycleStepper", () => { + it("renders all five plain-language steps", () => { + renderStepper({ id: "s1", name: "Auth", status: "draft" }, makeClient()); + for (const label of ["Describe", "Refine", "Approve", "Build", "Verify"]) { + expect(screen.getByText(label)).toBeInTheDocument(); + } + }); + + it("marks the current step and offers its action for a fresh draft", () => { + renderStepper({ id: "s1", name: "Auth", status: "draft" }, makeClient()); + expect(screen.getByTestId("plain-stage-describe")).toHaveAttribute("data-state", "current"); + expect(screen.getByTestId("stepper-run-describe")).toHaveTextContent("Clarify"); + }); + + it("runs Clarify and calls the client with the spec id", async () => { + const clarifySpec = vi.fn(() => Promise.resolve({ id: "s1", name: "Auth", status: "clarifying" as const })); + const client = makeClient({ clarifySpec }); + renderStepper({ id: "s1", name: "Auth", status: "draft" }, client); + + fireEvent.click(screen.getByTestId("stepper-run-describe")); + + await waitFor(() => expect(clarifySpec).toHaveBeenCalledWith("s1")); + }); + + it("offers Plan once clarifying but before a plan exists", () => { + renderStepper({ id: "s1", name: "Auth", status: "clarifying" }, makeClient()); + expect(screen.getByTestId("plain-stage-refine")).toHaveAttribute("data-state", "current"); + expect(screen.getByTestId("stepper-run-refine")).toHaveTextContent("Plan"); + }); + + it("offers Generate tasks once approved with a plan", () => { + renderStepper( + { id: "s1", name: "Auth", status: "approved", plan_ref: "plan.md" }, + makeClient(), + ); + expect(screen.getByTestId("plain-stage-build")).toHaveAttribute("data-state", "current"); + expect(screen.getByTestId("stepper-run-build")).toHaveTextContent("Generate tasks"); + }); + + it("offers Validate once tasks are generated, chaining generateTasks + validateTask", async () => { + const tasks: TaskDTO[] = [{ id: "t1", title: "Implement R1" }]; + const generateTasks = vi.fn(() => Promise.resolve(tasks)); + const validateTask = vi.fn(() => Promise.resolve({ task_id: "t1", passed: true })); + const client = makeClient({ generateTasks, validateTask }); + renderStepper( + { + id: "s1", + name: "Auth", + status: "approved", + plan_ref: "plan.md", + tasks_ref: "tasks.md", + }, + client, + ); + + expect(screen.getByTestId("plain-stage-verify")).toHaveAttribute("data-state", "current"); + fireEvent.click(screen.getByTestId("stepper-run-verify")); + + await waitFor(() => expect(validateTask).toHaveBeenCalledWith("t1")); + expect(generateTasks).toHaveBeenCalledWith("s1"); + }); + + it("shows a completion message once every step is done", () => { + renderStepper( + { + id: "s1", + name: "Auth", + status: "validated", + plan_ref: "plan.md", + tasks_ref: "tasks.md", + }, + makeClient(), + ); + expect(screen.getByTestId("stepper-complete")).toBeInTheDocument(); + expect(screen.queryByTestId(/stepper-run-/)).not.toBeInTheDocument(); + }); + + it("surfaces a mutation error inline", async () => { + const clarifySpec = vi.fn(() => Promise.reject(new Error("engine offline"))); + const client = makeClient({ clarifySpec }); + renderStepper({ id: "s1", name: "Auth", status: "draft" }, client); + + fireEvent.click(screen.getByTestId("stepper-run-describe")); + + expect(await screen.findByTestId("stepper-error")).toHaveTextContent(/engine offline/i); + }); +}); diff --git a/apps/web/src/components/spec/lifecycle-stepper.tsx b/apps/web/src/components/spec/lifecycle-stepper.tsx new file mode 100644 index 00000000..60e4bbfb --- /dev/null +++ b/apps/web/src/components/spec/lifecycle-stepper.tsx @@ -0,0 +1,170 @@ +"use client"; + +import { Check, Flame, Loader2 } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { ApiError, apiClient, type ForgeApiClient } from "@/lib/api/client"; +import { + useApproveSpec, + useClarifySpec, + useGenerateTasks, + usePlanSpec, + useValidateSpec, +} from "@/lib/api/spec"; +import type { SpecOverview } from "@/lib/api/types"; +import { cn } from "@/lib/utils"; + +import { + PLAIN_LIFECYCLE_STEPS, + plainCurrentStep, + plainStepCompletion, + plainStepState, +} from "./spec-meta"; + +export interface LifecycleStepperProps { + spec: SpecOverview; + client?: ForgeApiClient; +} + +function errorMessage(error: unknown): string { + if (error instanceof ApiError) return error.message; + if (error instanceof Error) return error.message; + return "Something went wrong"; +} + +/** + * The SDD lifecycle wired inline as five everyday verbs — Describe, Refine, + * Approve, Build, Verify — each one backed by an existing `/spec` engine call + * (Clarify / Plan / Approve / Generate tasks / Validate). Whichever step is + * next gets a single button that runs its action right from the dashboard or + * Spec Studio, no separate page needed. Approve and the safe, single-field + * Clarify flip are optimistic (the rail advances before the request settles); + * Plan / Generate tasks / Validate touch richer, gated manifest state, so + * those wait for the engine's response before the rail moves. + */ +export function LifecycleStepper({ spec, client = apiClient }: LifecycleStepperProps) { + const clarify = useClarifySpec(client); + const plan = usePlanSpec(client); + const approve = useApproveSpec(client); + const generateTasks = useGenerateTasks(client); + const validate = useValidateSpec(client); + + const completion = plainStepCompletion(spec); + const current = plainCurrentStep(completion); + const allDone = completion.every(Boolean); + + const actions = [ + { mutation: clarify, run: () => clarify.mutate({ specId: spec.id }) }, + { mutation: plan, run: () => plan.mutate({ specId: spec.id }) }, + { mutation: approve, run: () => approve.mutate({ specId: spec.id }) }, + { mutation: generateTasks, run: () => generateTasks.mutate({ specId: spec.id }) }, + { mutation: validate, run: () => validate.mutate({ specId: spec.id }) }, + ] as const; + + const activeStep = PLAIN_LIFECYCLE_STEPS[current]; + const activeAction = actions[current]; + + return ( + <div className="flex flex-col gap-3" data-testid="lifecycle-stepper"> + <ol + aria-label="Spec lifecycle" + className="flex min-w-[30rem] items-start overflow-x-auto" + > + {PLAIN_LIFECYCLE_STEPS.map((step, index) => { + const state = plainStepState(index, completion, current); + return ( + <li + key={step.id} + data-testid={`plain-stage-${step.id}`} + data-state={state} + aria-current={state === "current" ? "step" : undefined} + className="relative flex flex-1 flex-col items-center gap-2 px-1 text-center" + > + {index > 0 ? ( + <span + aria-hidden + className={cn( + "absolute left-[-50%] top-[13px] -z-0 h-0.5 w-full", + completion[index - 1] + ? "bg-primary" + : "border-t-2 border-dashed border-border bg-transparent", + )} + /> + ) : null} + + <span className="relative z-10 flex h-7 w-7 items-center justify-center"> + {state === "current" ? ( + <span + aria-hidden + className="absolute inset-0 rounded-full bg-spark/30 motion-safe:animate-pulse" + /> + ) : null} + <span + className={cn( + "relative flex h-7 w-7 items-center justify-center rounded-full border text-[11px]", + state === "current" && + "border-spark bg-primary text-primary-foreground shadow-sm ring-4 ring-spark/25", + state === "done" && "border-primary/60 bg-primary/15 text-primary", + state === "upcoming" && + "border-dashed border-border bg-muted text-muted-foreground", + )} + > + {state === "done" ? ( + <Check className="h-3.5 w-3.5" aria-hidden /> + ) : state === "current" ? ( + <Flame className="h-3.5 w-3.5" aria-hidden /> + ) : ( + <span className="h-1.5 w-1.5 rounded-full bg-current" aria-hidden /> + )} + </span> + </span> + + <span + className={cn( + "font-display text-xs font-semibold tracking-tight", + state === "upcoming" ? "text-muted-foreground" : "text-foreground", + )} + > + {step.label} + </span> + <span className="hidden text-[11px] leading-tight text-muted-foreground sm:block"> + {step.blurb} + </span> + </li> + ); + })} + </ol> + + {allDone ? ( + <p + role="status" + data-testid="stepper-complete" + className="text-xs text-muted-foreground" + > + Lifecycle complete — validated and traceable. + </p> + ) : ( + <div className="flex flex-wrap items-center gap-2"> + <Button + size="sm" + onClick={activeAction.run} + disabled={activeAction.mutation.isPending} + data-testid={`stepper-run-${activeStep.id}`} + > + {activeAction.mutation.isPending ? ( + <Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden /> + ) : ( + <Flame className="h-3.5 w-3.5" aria-hidden /> + )} + {activeAction.mutation.isPending ? "Working…" : activeStep.actionLabel} + </Button> + {activeAction.mutation.isError ? ( + <span role="status" data-testid="stepper-error" className="text-xs text-danger"> + {errorMessage(activeAction.mutation.error)} + </span> + ) : null} + </div> + )} + </div> + ); +} diff --git a/apps/web/src/components/spec/spec-dashboard.test.tsx b/apps/web/src/components/spec/spec-dashboard.test.tsx index 9efbb801..5b295a6e 100644 --- a/apps/web/src/components/spec/spec-dashboard.test.tsx +++ b/apps/web/src/components/spec/spec-dashboard.test.tsx @@ -92,7 +92,7 @@ describe("SpecDashboard", () => { expect( await screen.findByRole("heading", { level: 2, name: /passwordless auth/i }), ).toBeInTheDocument(); - expect(screen.getByTestId("lifecycle-rail")).toBeInTheDocument(); + expect(screen.getByTestId("lifecycle-stepper")).toBeInTheDocument(); expect(screen.getByTestId("gate-tiles")).toBeInTheDocument(); expect(screen.getByTestId("traceability-matrix")).toBeInTheDocument(); expect(screen.getByText(/2\s*specs/i)).toBeInTheDocument(); diff --git a/apps/web/src/components/spec/spec-dashboard.tsx b/apps/web/src/components/spec/spec-dashboard.tsx index 8db13904..e4b08ce5 100644 --- a/apps/web/src/components/spec/spec-dashboard.tsx +++ b/apps/web/src/components/spec/spec-dashboard.tsx @@ -32,7 +32,7 @@ import type { SpecOverview } from "@/lib/api/types"; import { cn } from "@/lib/utils"; import { ConstitutionPanel } from "./constitution-panel"; -import { LifecycleRail } from "./lifecycle-rail"; +import { LifecycleStepper } from "./lifecycle-stepper"; import { ManifestPanel } from "./manifest-panel"; import { formatCoverage, @@ -235,7 +235,7 @@ export function SpecDashboard({ <h2 className="font-display text-lg font-semibold leading-tight text-foreground"> {selected.name} </h2> - <LifecycleRail status={selected.status} /> + <LifecycleStepper spec={selected} client={client} /> </div> <div className="border-b border-border px-6 py-4"> 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<SpecStatus, string> = { 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/spec.test.tsx b/apps/web/src/lib/api/spec.test.tsx index 207e2c05..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, useCreateSpec, 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 }) { @@ -99,6 +108,170 @@ describe("useApproveSpec (optimistic)", () => { }); }); +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<SpecManifest>((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<SpecDashboard>(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<SpecDashboard>(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" }; diff --git a/apps/web/src/lib/api/spec.ts b/apps/web/src/lib/api/spec.ts index c86ae24f..01bff70a 100644 --- a/apps/web/src/lib/api/spec.ts +++ b/apps/web/src/lib/api/spec.ts @@ -21,6 +21,7 @@ import { } from "@tanstack/react-query"; import { apiClient, type ForgeApiClient } from "./client"; +import { specStudioKeys } from "./spec-studio"; import type { ADR, AcceptanceCriterion, @@ -29,6 +30,8 @@ import type { Requirement, SpecDashboard, SpecManifest, + TaskDTO, + ValidationReport, } from "./types"; export const specKeys = { @@ -192,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<typeof useQueryClient>, + specId: string, + status: SpecManifest["status"], +): OptimisticStatusContext["previous"] { + const previous = queryClient.getQueriesData<SpecDashboard>({ + queryKey: specKeys.overviews(), + }); + queryClient.setQueriesData<SpecDashboard>( + { queryKey: specKeys.overviews() }, + (old) => + old + ? { + ...old, + specs: old.specs.map((spec) => (spec.id === specId ? { ...spec, status } : spec)), + } + : old, + ); + return previous; +} + +function rollbackStatus( + queryClient: ReturnType<typeof useQueryClient>, + 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<typeof useQueryClient>, 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<SpecManifest, Error, SpecIdVariables, OptimisticStatusContext> { + const queryClient = useQueryClient(); + return useMutation<SpecManifest, Error, SpecIdVariables, OptimisticStatusContext>({ + 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<SpecManifest, Error, SpecIdVariables> { + 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<TaskDTO[], Error, SpecIdVariables> { + 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<ValidationReport, Error, SpecIdVariables> { + 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), + }); +} From 777e51f054480462c03e4396e15f2547594d7df1 Mon Sep 17 00:00:00 2001 From: Forge Swarm <swarm@forge.local> Date: Thu, 9 Jul 2026 04:14:58 +0200 Subject: [PATCH 11/20] feat(ss-ai-panel): Spec Studio Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --- .../spec-studio/ai-draft-panel.test.tsx | 153 ++++++++++++++ .../components/spec-studio/ai-draft-panel.tsx | 189 ++++++++++++++++++ .../spec-studio/new-spec-page.test.tsx | 51 ++++- .../components/spec-studio/new-spec-page.tsx | 50 ++++- apps/web/src/lib/api/client.ts | 16 ++ apps/web/src/lib/api/spec-studio.test.tsx | 67 +++++++ apps/web/src/lib/api/spec-studio.ts | 21 +- apps/web/src/lib/api/types.ts | 29 +++ 8 files changed, 573 insertions(+), 3 deletions(-) create mode 100644 apps/web/src/components/spec-studio/ai-draft-panel.test.tsx create mode 100644 apps/web/src/components/spec-studio/ai-draft-panel.tsx create mode 100644 apps/web/src/lib/api/spec-studio.test.tsx 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> = {}): 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 <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>; + } + return { + onDraft, + ...render( + <AiDraftPanel client={client} onDraft={onDraft} revealIntervalMs={1} revealChunkSize={4} />, + { 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<ReturnType<typeof setInterval> | 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 ( + <div + className="flex flex-col gap-3 rounded-lg border border-border bg-card/60 p-4" + data-testid="ai-draft-panel" + > + <div className="flex items-center gap-2"> + <Sparkles className="h-4 w-4 text-primary" aria-hidden /> + <h3 className="font-display text-sm font-semibold text-foreground">Draft with AI</h3> + </div> + <p className="text-xs text-muted-foreground"> + Describe the goal in one line — a draft spec.md streams in below. It’s a starting + point: review and refine before saving. + </p> + <div className="flex gap-2"> + <input + data-testid="ai-draft-goal" + aria-label="One-line goal" + value={goal} + onChange={(event) => 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" + /> + <Button + type="button" + onClick={handleDraft} + disabled={!goal.trim() || draftSpec.isPending} + data-testid="ai-draft-submit" + > + {draftSpec.isPending ? "Drafting…" : "Draft with AI"} + </Button> + </div> + + {draftSpec.isError ? ( + <p role="alert" className="text-xs text-danger" data-testid="ai-draft-error"> + {errorMessage(draftSpec.error)} + </p> + ) : null} + + {draftSpec.isSuccess ? ( + <div className="flex flex-col gap-2"> + <div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground"> + <span + className="inline-flex items-center rounded-full border border-warning/30 bg-warning/5 px-2 py-0.5 font-medium text-warning" + data-testid="ai-draft-badge" + > + Draft — review before saving + </span> + <span + className="rounded-full border border-border bg-muted/40 px-2 py-0.5 font-mono" + data-testid="ai-draft-model" + > + {draftSpec.data.model} · senior tier + </span> + {typeof usage?.cost_usd === "number" ? ( + <span + className="rounded-full border border-border bg-muted/40 px-2 py-0.5 font-mono" + data-testid="ai-draft-cost" + > + ${usage.cost_usd.toFixed(4)} + </span> + ) : null} + {streaming ? <span data-testid="ai-draft-streaming">Streaming…</span> : null} + </div> + <pre + data-testid="ai-draft-stream" + className="max-h-64 overflow-auto whitespace-pre-wrap rounded-md border border-border bg-card px-3 py-2 font-mono text-xs leading-5 text-foreground" + > + {revealed} + </pre> + {draftSpec.data.parse_error ? ( + <p className="text-xs text-danger" data-testid="ai-draft-parse-error"> + Draft didn’t fully parse: {draftSpec.data.parse_error}. You can still edit it + as Markdown. + </p> + ) : null} + </div> + ) : null} + </div> + ); +} 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 index 5bb82ccf..47949bed 100644 --- a/apps/web/src/components/spec-studio/new-spec-page.test.tsx +++ b/apps/web/src/components/spec-studio/new-spec-page.test.tsx @@ -4,7 +4,7 @@ import type { ReactNode } from "react"; import { describe, expect, it, vi } from "vitest"; import type { ForgeApiClient } from "@/lib/api/client"; -import type { EpicDTO, SpecManifest } from "@/lib/api/types"; +import type { EpicDTO, SpecDraft, SpecManifest } from "@/lib/api/types"; import { NewSpecPage } from "./new-spec-page"; @@ -17,6 +17,18 @@ const epics: EpicDTO[] = [ { 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> = {}): ForgeApiClient { return { listEpics: vi.fn(() => Promise.resolve(epics)), @@ -26,6 +38,7 @@ function makeClient(overrides: Partial<ForgeApiClient> = {}): ForgeApiClient { 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; } @@ -115,4 +128,40 @@ describe("NewSpecPage", () => { ); 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(); + }); + }); }); diff --git a/apps/web/src/components/spec-studio/new-spec-page.tsx b/apps/web/src/components/spec-studio/new-spec-page.tsx index 1c3c9310..2b5e8cdf 100644 --- a/apps/web/src/components/spec-studio/new-spec-page.tsx +++ b/apps/web/src/components/spec-studio/new-spec-page.tsx @@ -7,11 +7,14 @@ import { Button } from "@/components/ui/button"; import { ApiError, apiClient, type ForgeApiClient } from "@/lib/api/client"; import { useEpics } from "@/lib/api/hooks"; import { useCreateSpec } from "@/lib/api/spec"; -import type { SpecManifest } from "@/lib/api/types"; +import type { SpecDraft, SpecManifest } from "@/lib/api/types"; import { cn } from "@/lib/utils"; +import { AiDraftPanel } from "./ai-draft-panel"; import { GuidedMode } from "./guided-mode"; +type EntryMode = "scratch" | "ai"; + export interface NewSpecPageProps { client?: ForgeApiClient; /** Navigate to the created spec (defaults to router push to /specs/{id}). */ @@ -41,8 +44,18 @@ export function NewSpecPage({ const [epicId, setEpicId] = useState(""); const [draft, setDraft] = useState<SpecManifest>({ id: "", name: "" }); + const [entryMode, setEntryMode] = useState<EntryMode>("scratch"); const epics = epicsQuery.data ?? []; + + function handleAiDraft(result: SpecDraft) { + if (result.manifest) { + // Draft-only preview: keep the draft's own placeholder id blank until + // the spec is actually created — everything else the model wrote + // (name, requirements, acceptance criteria, ...) seeds Guided mode. + setDraft({ ...result.manifest, id: "" }); + } + } const canCreate = Boolean(epicId) && draft.name.trim().length > 0 && !createSpec.isPending; @@ -82,6 +95,41 @@ export function NewSpecPage({ </p> </header> + <div + role="tablist" + aria-label="New spec entry mode" + className="inline-flex w-fit items-center gap-1 rounded-lg border border-border bg-muted/50 p-1" + > + {( + [ + { id: "scratch", label: "Start from scratch" }, + { id: "ai", label: "Draft with AI" }, + ] as const + ).map((option) => ( + <button + key={option.id} + role="tab" + type="button" + aria-selected={entryMode === option.id} + onClick={() => setEntryMode(option.id)} + data-testid={`new-spec-entry-${option.id}`} + className={cn( + "rounded-md px-3 py-1.5 text-sm font-medium transition-colors", + "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", + entryMode === option.id + ? "bg-card text-foreground shadow-sm" + : "text-muted-foreground hover:text-foreground", + )} + > + {option.label} + </button> + ))} + </div> + + {entryMode === "ai" ? ( + <AiDraftPanel epicId={epicId || undefined} client={client} onDraft={handleAiDraft} /> + ) : null} + <label className="flex flex-col gap-1.5 text-sm"> <span className="font-medium text-foreground">Epic</span> <select diff --git a/apps/web/src/lib/api/client.ts b/apps/web/src/lib/api/client.ts index 872d66af..ebe06cff 100644 --- a/apps/web/src/lib/api/client.ts +++ b/apps/web/src/lib/api/client.ts @@ -87,6 +87,7 @@ import type { TeamMemberInput, TeamRole, SpecDashboard, + SpecDraft, SpecManifest, Sprint, SprintDTO, @@ -503,6 +504,21 @@ export class ForgeApiClient { ); } + /** + * 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<SpecDraft> { + return this.request<SpecDraft>("/spec/draft", { method: "POST", body }); + } + /** Read a project's constitution (404 if it was never initialised). */ getConstitution(projectId: string): Promise<Constitution> { return this.request<Constitution>( 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..1214b035 --- /dev/null +++ b/apps/web/src/lib/api/spec-studio.test.tsx @@ -0,0 +1,67 @@ +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 } from "./spec-studio"; +import type { SpecDraft } from "./types"; + +function makeWrapper(client: QueryClient) { + return function Wrapper({ children }: { children: ReactNode }) { + return <QueryClientProvider client={client}>{children}</QueryClientProvider>; + }; +} + +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); + }); +}); diff --git a/apps/web/src/lib/api/spec-studio.ts b/apps/web/src/lib/api/spec-studio.ts index 9f1381fa..5f456737 100644 --- a/apps/web/src/lib/api/spec-studio.ts +++ b/apps/web/src/lib/api/spec-studio.ts @@ -21,7 +21,7 @@ import { } from "@tanstack/react-query"; import { apiClient, type ForgeApiClient } from "./client"; -import type { SpecManifest } from "./types"; +import type { SpecDraft, SpecManifest } from "./types"; export const specStudioKeys = { manifest: (specId: string) => ["spec-studio", "manifest", specId] as const, @@ -110,3 +110,22 @@ export function useSaveSpecManifestYaml( 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<SpecDraft, Error, DraftSpecVariables> { + return useMutation({ + mutationFn: (body: DraftSpecVariables) => client.draftSpec(body), + }); +} diff --git a/apps/web/src/lib/api/types.ts b/apps/web/src/lib/api/types.ts index 605098c6..e81d7154 100644 --- a/apps/web/src/lib/api/types.ts +++ b/apps/web/src/lib/api/types.ts @@ -428,6 +428,35 @@ export interface SpecDashboard { specs: SpecOverview[]; } +/** + * 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; +} + // --- Observability: run traces -------------------------------------------- // // Mirrors forge_api.observability.trace.RunTrace + forge_contracts.Step, the // response shape of GET /observability/runs/{run_id}/trace. From c081a3c5f1705b21b924fc48c18b0b64c43b22bd Mon Sep 17 00:00:00 2001 From: Forge Swarm <swarm@forge.local> Date: Thu, 9 Jul 2026 05:00:54 +0200 Subject: [PATCH 12/20] feat(ss-entry): Spec Studio Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --- apps/web/src/app/(board)/specs/new/page.tsx | 12 +- .../board/depth/depth-roadmap.test.tsx | 16 +++ .../components/board/depth/depth-roadmap.tsx | 19 ++- .../spec-studio/new-spec-page.test.tsx | 89 ++++++++++++- .../components/spec-studio/new-spec-page.tsx | 121 ++++++++++++++++-- apps/web/src/lib/api/client.ts | 5 + apps/web/src/lib/api/hooks.test.tsx | 30 ++++- apps/web/src/lib/api/hooks.ts | 17 +++ .../web/src/lib/spec-studio/templates.test.ts | 73 +++++++++++ apps/web/src/lib/spec-studio/templates.ts | 101 +++++++++++++++ 10 files changed, 463 insertions(+), 20 deletions(-) create mode 100644 apps/web/src/lib/spec-studio/templates.test.ts create mode 100644 apps/web/src/lib/spec-studio/templates.ts diff --git a/apps/web/src/app/(board)/specs/new/page.tsx b/apps/web/src/app/(board)/specs/new/page.tsx index c499c410..d1495dd9 100644 --- a/apps/web/src/app/(board)/specs/new/page.tsx +++ b/apps/web/src/app/(board)/specs/new/page.tsx @@ -1,9 +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 <NewSpecPage />; + return ( + <Suspense fallback={null}> + <NewSpecPage /> + </Suspense> + ); } 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( + <DepthRoadmap tasks={tasks} epics={epics} sprints={sprints} milestones={milestones} />, + ); + 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( + <DepthRoadmap tasks={unepiced} epics={epics} sprints={sprints} milestones={milestones} />, + ); + 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 ( <> <div data-testid={`lane-${laneId}`} - className="sticky left-0 z-10 flex items-center border-b border-r border-border bg-card px-3 py-3 text-sm font-medium" + className="sticky left-0 z-10 flex items-center justify-between gap-2 border-b border-r border-border bg-card px-3 py-3 text-sm font-medium" > <span className="truncate">{label}</span> + {isRealEpic ? ( + <Link + href={`/specs/new?epicId=${encodeURIComponent(laneId)}`} + data-testid={`lane-create-spec-${laneId}`} + title={`Create a spec for ${label}`} + aria-label={`Create a spec for ${label}`} + className="shrink-0 rounded-md p-1 text-muted-foreground hover:bg-accent hover:text-foreground" + > + <FilePlus2 className="h-3.5 w-3.5" aria-hidden /> + </Link> + ) : null} </div> {columns.map((column) => { const cellTasks = cells[column.id] ?? []; 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 index 47949bed..fc7b1a08 100644 --- a/apps/web/src/components/spec-studio/new-spec-page.test.tsx +++ b/apps/web/src/components/spec-studio/new-spec-page.test.tsx @@ -1,15 +1,17 @@ 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 { 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[] = [ @@ -32,6 +34,9 @@ const aiDraft: SpecDraft = { function makeClient(overrides: Partial<ForgeApiClient> = {}): 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), ), @@ -43,6 +48,12 @@ function makeClient(overrides: Partial<ForgeApiClient> = {}): ForgeApiClient { } 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 } }, @@ -164,4 +175,80 @@ describe("NewSpecPage", () => { 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 index 2b5e8cdf..f710fede 100644 --- a/apps/web/src/components/spec-studio/new-spec-page.tsx +++ b/apps/web/src/components/spec-studio/new-spec-page.tsx @@ -1,13 +1,14 @@ "use client"; -import { useRouter } from "next/navigation"; -import { useState } from "react"; +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 { useEpics } from "@/lib/api/hooks"; +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"; @@ -15,6 +16,9 @@ import { GuidedMode } from "./guided-mode"; type EntryMode = "scratch" | "ai"; +/** Sentinel `<select>` value that reveals the inline "new epic" text field. */ +const NEW_EPIC_VALUE = "__new_epic__"; + export interface NewSpecPageProps { client?: ForgeApiClient; /** Navigate to the created spec (defaults to router push to /specs/{id}). */ @@ -39,14 +43,21 @@ export function NewSpecPage({ onCreated, }: NewSpecPageProps) { const router = useRouter(); + const searchParams = useSearchParams(); + const epicIdFromQuery = searchParams?.get("epicId") ?? ""; + const epicsQuery = useEpics(client); + const createEpic = useCreateEpic(client); const createSpec = useCreateSpec(client); - const [epicId, setEpicId] = useState(""); + const [epicId, setEpicId] = useState(epicIdFromQuery); + const [newEpicTitle, setNewEpicTitle] = useState(""); const [draft, setDraft] = useState<SpecManifest>({ id: "", name: "" }); const [entryMode, setEntryMode] = useState<EntryMode>("scratch"); + const [templateId, setTemplateId] = useState<SpecTemplateId | null>(null); - const epics = epicsQuery.data ?? []; + const epics = useMemo(() => epicsQuery.data ?? [], [epicsQuery.data]); + const creatingNewEpic = epicId === NEW_EPIC_VALUE; function handleAiDraft(result: SpecDraft) { if (result.manifest) { @@ -56,14 +67,25 @@ export function NewSpecPage({ setDraft({ ...result.manifest, id: "" }); } } + + function handleTemplate(next: SpecTemplateId) { + setTemplateId(next); + setDraft((current) => applySpecTemplate(next, current)); + } + + const hasEpicTarget = creatingNewEpic + ? newEpicTitle.trim().length > 0 + : Boolean(epicId); const canCreate = - Boolean(epicId) && draft.name.trim().length > 0 && !createSpec.isPending; + hasEpicTarget && + draft.name.trim().length > 0 && + !createSpec.isPending && + !createEpic.isPending; - function handleCreate() { - if (!canCreate) return; + function createSpecFor(resolvedEpicId: string) { createSpec.mutate( { - epic_id: epicId, + epic_id: resolvedEpicId, name: draft.name, requirements: draft.requirements, acceptance_criteria: draft.acceptance_criteria, @@ -83,6 +105,22 @@ export function NewSpecPage({ ); } + function handleCreate() { + if (!canCreate) return; + if (creatingNewEpic) { + createEpic.mutate( + { title: newEpicTitle.trim() }, + { + onSuccess: (epic) => { + if (epic.id) createSpecFor(epic.id); + }, + }, + ); + } else { + createSpecFor(epicId); + } + } + return ( <div className="flex flex-col gap-5" data-testid="new-spec-page"> <header className="flex flex-col gap-1"> @@ -127,7 +165,40 @@ export function NewSpecPage({ </div> {entryMode === "ai" ? ( - <AiDraftPanel epicId={epicId || undefined} client={client} onDraft={handleAiDraft} /> + <AiDraftPanel + epicId={creatingNewEpic ? undefined : epicId || undefined} + client={client} + onDraft={handleAiDraft} + /> + ) : null} + + {entryMode === "scratch" ? ( + <fieldset className="flex flex-col gap-2"> + <legend className="text-sm font-medium text-foreground"> + Start from a template + </legend> + <div className="flex flex-wrap gap-2" role="group" aria-label="Starter templates"> + {SPEC_TEMPLATES.map((template) => ( + <button + key={template.id} + type="button" + data-testid={`spec-template-${template.id}`} + title={template.description} + aria-pressed={templateId === template.id} + onClick={() => handleTemplate(template.id)} + className={cn( + "rounded-md border px-3 py-1.5 text-sm font-medium transition-colors", + "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", + templateId === template.id + ? "border-primary/40 bg-accent text-foreground" + : "border-border text-muted-foreground hover:text-foreground", + )} + > + {template.label} + </button> + ))} + </div> + </fieldset> ) : null} <label className="flex flex-col gap-1.5 text-sm"> @@ -148,16 +219,40 @@ export function NewSpecPage({ {epic.title} </option> ))} + <option value={NEW_EPIC_VALUE}>+ Create new epic…</option> </select> </label> + {creatingNewEpic ? ( + <label className="flex flex-col gap-1.5 text-sm"> + <span className="font-medium text-foreground">New epic title</span> + <input + type="text" + data-testid="new-spec-new-epic-title" + value={newEpicTitle} + onChange={(event) => setNewEpicTitle(event.target.value)} + placeholder="e.g. Billing v3" + className={cn( + "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", + )} + /> + </label> + ) : null} + <GuidedMode value={draft} onChange={setDraft} onSave={handleCreate} - saving={createSpec.isPending} + saving={createSpec.isPending || createEpic.isPending} dirty={canCreate} - saveError={createSpec.isError ? errorMessage(createSpec.error) : null} + saveError={ + createSpec.isError + ? errorMessage(createSpec.error) + : createEpic.isError + ? errorMessage(createEpic.error) + : null + } /> <div className="flex justify-end"> @@ -166,7 +261,7 @@ export function NewSpecPage({ disabled={!canCreate} data-testid="create-spec" > - {createSpec.isPending ? "Creating…" : "Create spec"} + {createSpec.isPending || createEpic.isPending ? "Creating…" : "Create spec"} </Button> </div> </div> diff --git a/apps/web/src/lib/api/client.ts b/apps/web/src/lib/api/client.ts index ebe06cff..3223844d 100644 --- a/apps/web/src/lib/api/client.ts +++ b/apps/web/src/lib/api/client.ts @@ -264,6 +264,11 @@ export class ForgeApiClient { return this.request<EpicDTO[]>("/board/epics", { query }); } + /** Create an epic (e.g. the standalone `/specs/new` entry, which creates its own epic). */ + createEpic(epic: EpicDTO): Promise<EpicDTO> { + return this.request<EpicDTO>("/board/epics", { method: "POST", body: epic }); + } + listSprints(query?: RequestOptions["query"]): Promise<SprintDTO[]> { return this.request<SprintDTO[]>("/board/sprints", { query }); } 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<EpicDTO, Error, EpicDTO> { + 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<IncidentDTO[]> { 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 <context>, when <action>, then <outcome>.", + 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], + }; +} From 24f5601511f2af60b64821b8ae9f71018cf7e5aa Mon Sep 17 00:00:00 2001 From: Forge Swarm <swarm@forge.local> Date: Thu, 9 Jul 2026 06:50:55 +0200 Subject: [PATCH 13/20] feat(ss-versioning): Spec Studio Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --- apps/api/forge_api/routers/spec.py | 225 ++++++++++++++- .../services/spec_version_service.py | 88 ++++++ apps/api/tests/test_rbac_tenant_r2.py | 28 ++ apps/api/tests/test_spec_router.py | 30 ++ apps/api/tests/test_spec_versioning.py | 214 ++++++++++++++ .../components/spec-studio/spec-studio.tsx | 7 +- .../spec-studio/version-history.test.tsx | 103 +++++++ .../spec-studio/version-history.tsx | 268 ++++++++++++++++++ apps/web/src/lib/api/client.ts | 32 +++ apps/web/src/lib/api/spec-studio.ts | 3 + apps/web/src/lib/api/spec-versions.ts | 64 +++++ apps/web/src/lib/api/types.ts | 58 ++++ packages/db/forge_db/models/__init__.py | 2 + packages/db/forge_db/models/spec_version.py | 47 +++ .../0032_ss_versioning_spec_version.py | 47 +++ packages/db/tests/test_models.py | 3 + packages/spec-engine/forge_spec/__init__.py | 14 + packages/spec-engine/forge_spec/diff.py | 157 ++++++++++ packages/spec-engine/tests/test_spec_diff.py | 88 ++++++ 19 files changed, 1461 insertions(+), 17 deletions(-) create mode 100644 apps/api/forge_api/services/spec_version_service.py create mode 100644 apps/api/tests/test_spec_versioning.py create mode 100644 apps/web/src/components/spec-studio/version-history.test.tsx create mode 100644 apps/web/src/components/spec-studio/version-history.tsx create mode 100644 apps/web/src/lib/api/spec-versions.ts create mode 100644 packages/db/forge_db/models/spec_version.py create mode 100644 packages/db/migrations/versions/0032_ss_versioning_spec_version.py create mode 100644 packages/spec-engine/forge_spec/diff.py create mode 100644 packages/spec-engine/tests/test_spec_diff.py diff --git a/apps/api/forge_api/routers/spec.py b/apps/api/forge_api/routers/spec.py index 9c50f0b6..359ffcae 100644 --- a/apps/api/forge_api/routers/spec.py +++ b/apps/api/forge_api/routers/spec.py @@ -19,7 +19,7 @@ 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 @@ -29,6 +29,7 @@ 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.settings import get_settings from forge_contracts import ( @@ -41,9 +42,16 @@ ValidationReport, ) from forge_contracts.exceptions import SpecGateError -from forge_db.models import Project +from forge_db.models import Project, SpecVersion from forge_orchestration_policy import Tier -from forge_spec import FileSpecEngine, SpecNotFoundError +from forge_spec import ( + FileSpecEngine, + ManifestDiff, + SpecNotFoundError, + diff_manifest, + diff_markdown, + spec_id_for_key, +) router = APIRouter( prefix="/spec", @@ -121,6 +129,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``.""" @@ -173,9 +208,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]) @@ -186,10 +228,18 @@ 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(): - return engine.write_manifest(manifest) + updated = engine.write_manifest(manifest) + _record_version(engine, db, principal, updated) + return updated @router.get( @@ -205,16 +255,24 @@ def read_spec_markdown(engine: EngineDep, spec_id: uuid.UUID) -> PlainTextRespon @router.put("/specs/{spec_id}/markdown", response_model=SpecManifest, dependencies=[WriteGate]) -def write_spec_markdown(engine: EngineDep, spec_id: uuid.UUID, body: TextContent) -> SpecManifest: +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}``. + ``PUT /spec/specs/{spec_id}``. Records a new version on success. """ with _spec_errors(): engine.read_manifest(spec_id) - return engine.save_spec_md(body.content) + updated = engine.save_spec_md(body.content) + _record_version(engine, db, principal, updated) + return updated @router.get( @@ -231,17 +289,23 @@ def read_spec_manifest_yaml(engine: EngineDep, spec_id: uuid.UUID) -> PlainTextR @router.put("/specs/{spec_id}/manifest", response_model=SpecManifest, dependencies=[WriteGate]) def write_spec_manifest_yaml( - engine: EngineDep, spec_id: uuid.UUID, body: TextContent + 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). + semantics). Records a new version on success. """ with _spec_errors(): - return engine.save_manifest_yaml(body.content) + updated = engine.save_manifest_yaml(body.content) + _record_version(engine, db, principal, updated) + return updated @router.get( @@ -296,6 +360,134 @@ 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) # # --------------------------------------------------------------------------- # @@ -463,6 +655,9 @@ def project_spec_overview( "SpecDashboard", "SpecEngineRegistry", "SpecOverview", + "SpecVersionDetail", + "SpecVersionDiff", + "SpecVersionSummary", "TextContent", "get_spec_engine", "get_spec_registry", 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_router.py b/apps/api/tests/test_spec_router.py index f5fff25d..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 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/src/components/spec-studio/spec-studio.tsx b/apps/web/src/components/spec-studio/spec-studio.tsx index 3c8b7e6f..1b1e1343 100644 --- a/apps/web/src/components/spec-studio/spec-studio.tsx +++ b/apps/web/src/components/spec-studio/spec-studio.tsx @@ -1,6 +1,6 @@ "use client"; -import { Eye, FileCode2, FileText, ListTree } from "lucide-react"; +import { Eye, FileCode2, FileText, History, ListTree } from "lucide-react"; import { useState } from "react"; import { apiClient, ApiError, type ForgeApiClient } from "@/lib/api/client"; @@ -19,15 +19,17 @@ import { cn } from "@/lib/utils"; import { GuidedMode } from "./guided-mode"; import { MarkdownMode } from "./markdown-mode"; import { ReadMode } from "./read-mode"; +import { VersionHistory } from "./version-history"; import { YamlMode } from "./yaml-mode"; -export type SpecStudioMode = "guided" | "markdown" | "yaml" | "read"; +export type SpecStudioMode = "guided" | "markdown" | "yaml" | "read" | "history"; const MODES: { id: SpecStudioMode; label: string; icon: typeof ListTree }[] = [ { id: "guided", label: "Guided", icon: ListTree }, { id: "markdown", label: "Markdown", icon: FileText }, { id: "yaml", label: "YAML", icon: FileCode2 }, { id: "read", label: "Read", icon: Eye }, + { id: "history", label: "History", icon: History }, ]; export interface SpecStudioProps { @@ -213,6 +215,7 @@ export function SpecStudio({ specId, client = apiClient }: SpecStudioProps) { approveError={approveSpec.isError ? errorMessage(approveSpec.error) : null} /> ) : null} + {mode === "history" ? <VersionHistory specId={specId} client={client} /> : null} </> )} </div> diff --git a/apps/web/src/components/spec-studio/version-history.test.tsx b/apps/web/src/components/spec-studio/version-history.test.tsx new file mode 100644 index 00000000..7c4eb876 --- /dev/null +++ b/apps/web/src/components/spec-studio/version-history.test.tsx @@ -0,0 +1,103 @@ +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 { SpecVersionDiff, SpecVersionSummary } from "@/lib/api/types"; + +import { VersionHistory } from "./version-history"; + +function renderHistory(client: ForgeApiClient, specId = "spec-uuid-1") { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + function Wrapper({ children }: { children: ReactNode }) { + return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>; + } + return render(<VersionHistory specId={specId} client={client} />, { wrapper: Wrapper }); +} + +const versions: SpecVersionSummary[] = [ + { + version_number: 2, + name: "Passwordless auth v2", + status: "draft", + created_at: "2026-07-09T12:00:00Z", + created_by: "user-1", + }, + { + version_number: 1, + name: "Passwordless auth", + status: "draft", + created_at: "2026-07-08T12:00:00Z", + created_by: "user-1", + }, +]; + +const diff: SpecVersionDiff = { + from_version: 1, + to_version: 2, + markdown: [ + { op: "equal", text: "## Goal" }, + { op: "delete", text: "Sign in" }, + { op: "insert", text: "Sign in without a password" }, + ], + manifest: { + scalar_changes: [{ field: "name", before: "Passwordless auth", after: "Passwordless auth v2" }], + requirements: [ + { id: "R2", change: "added", after: { id: "R2", text: "Support magic links" } }, + ], + acceptance_criteria: [], + open_questions: [], + decisions: [], + constraints_added: [], + constraints_removed: [], + }, +}; + +function makeClient(overrides: Partial<ForgeApiClient> = {}): ForgeApiClient { + return { + listSpecVersions: vi.fn(() => Promise.resolve(versions)), + diffSpecVersions: vi.fn(() => Promise.resolve(diff)), + ...overrides, + } as unknown as ForgeApiClient; +} + +describe("VersionHistory", () => { + it("lists versions newest first", async () => { + renderHistory(makeClient()); + expect(await screen.findByTestId("version-row-2")).toBeInTheDocument(); + expect(screen.getByTestId("version-row-1")).toBeInTheDocument(); + }); + + it("defaults the diff to the two most recent versions and renders the markdown + manifest diff", async () => { + const client = makeClient(); + renderHistory(client); + await screen.findByTestId("version-row-2"); + + await waitFor(() => expect(client.diffSpecVersions).toHaveBeenCalledWith("spec-uuid-1", 1, 2)); + + expect(await screen.findByTestId("diff-markdown")).toHaveTextContent( + "Sign in without a password", + ); + expect(screen.getByTestId("diff-field-requirements")).toHaveTextContent("added"); + expect(screen.getAllByText(/Passwordless auth v2/).length).toBeGreaterThan(0); + }); + + it("shows an empty state when the spec has no versions yet", async () => { + renderHistory(makeClient({ listSpecVersions: vi.fn(() => Promise.resolve([])) })); + expect(await screen.findByTestId("version-history-empty")).toBeInTheDocument(); + }); + + it("re-diffs when the compared versions change", async () => { + const client = makeClient(); + renderHistory(client); + await screen.findByTestId("version-row-2"); + await waitFor(() => expect(client.diffSpecVersions).toHaveBeenCalledWith("spec-uuid-1", 1, 2)); + + fireEvent.change(screen.getByTestId("diff-from-select"), { target: { value: "2" } }); + + await waitFor(() => expect(client.diffSpecVersions).toHaveBeenCalledWith("spec-uuid-1", 2, 2)); + }); +}); diff --git a/apps/web/src/components/spec-studio/version-history.tsx b/apps/web/src/components/spec-studio/version-history.tsx new file mode 100644 index 00000000..fb00008c --- /dev/null +++ b/apps/web/src/components/spec-studio/version-history.tsx @@ -0,0 +1,268 @@ +"use client"; + +import { GitCompare, History, Minus, Plus, RefreshCw } from "lucide-react"; +import { useState } from "react"; + +import { useSpecVersionDiff, useSpecVersions } from "@/lib/api/spec-versions"; +import type { ForgeApiClient } from "@/lib/api/client"; +import { apiClient } from "@/lib/api/client"; +import type { ListItemChange } from "@/lib/api/types"; +import { cn } from "@/lib/utils"; + +export interface VersionHistoryProps { + specId: string; + client?: ForgeApiClient; +} + +const FIELD_LABELS: Record<string, string> = { + requirements: "Requirements", + acceptance_criteria: "Acceptance criteria", + open_questions: "Open questions", + decisions: "Decisions", +}; + +function formatTimestamp(iso: string): string { + try { + return new Date(iso).toLocaleString(); + } catch { + return iso; + } +} + +function ChangeBadge({ change }: { change: ListItemChange["change"] }) { + const label = change === "added" ? "added" : change === "removed" ? "removed" : "modified"; + return ( + <span + className={cn( + "inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide", + change === "added" && "bg-success/10 text-success", + change === "removed" && "bg-danger/10 text-danger", + change === "modified" && "bg-warning/10 text-warning", + )} + > + {label} + </span> + ); +} + +function ListFieldDiff({ field, changes }: { field: string; changes: ListItemChange[] }) { + if (changes.length === 0) return null; + return ( + <div className="flex flex-col gap-1.5" data-testid={`diff-field-${field}`}> + <p className="text-xs font-semibold text-foreground">{FIELD_LABELS[field] ?? field}</p> + <ul className="flex flex-col gap-1"> + {changes.map((change) => ( + <li key={change.id} className="flex items-start gap-2 text-xs"> + <ChangeBadge change={change.change} /> + <span className="font-mono text-muted-foreground">{change.id}</span> + {change.change === "modified" ? ( + <span className="text-muted-foreground"> + {String(change.before?.text ?? "")} → {String(change.after?.text ?? "")} + </span> + ) : ( + <span className="text-muted-foreground"> + {String((change.after ?? change.before)?.text ?? "")} + </span> + )} + </li> + ))} + </ul> + </div> + ); +} + +/** + * Spec Studio's version history + diff (ss-versioning). Every save (Guided / + * Markdown / YAML) records an immutable snapshot on the backend; this surface + * lists them and diffs any two — a line-level `spec.md` diff plus a + * structured manifest diff (id-keyed adds/removes/changes per list field). + */ +export function VersionHistory({ specId, client = apiClient }: VersionHistoryProps) { + const versionsQuery = useSpecVersions(specId, client); + const versions = versionsQuery.data ?? []; + + const [fromVersion, setFromVersion] = useState<number | null>(null); + const [toVersion, setToVersion] = useState<number | null>(null); + // Tracks whether the "previous vs latest" default has been applied for this + // spec's version list, using React's "adjust state while rendering" pattern + // (mirrors `SpecStudio`'s per-`specId` override reset) rather than an effect + // that would set state a render late. + const [defaultedFor, setDefaultedFor] = useState<string | null>(null); + if (versions.length > 0 && defaultedFor !== specId) { + setDefaultedFor(specId); + setToVersion(versions[0]?.version_number ?? null); + setFromVersion(versions[1]?.version_number ?? versions[0]?.version_number ?? null); + } + + const diffQuery = useSpecVersionDiff(specId, fromVersion, toVersion, client); + const diff = diffQuery.data; + + if (versionsQuery.isLoading) { + return ( + <p className="text-sm text-muted-foreground" data-testid="version-history-loading"> + Loading version history… + </p> + ); + } + + if (versions.length === 0) { + return ( + <p className="text-sm text-muted-foreground" data-testid="version-history-empty"> + No versions yet — save the spec to record its first version. + </p> + ); + } + + return ( + <div className="flex flex-col gap-4" data-testid="version-history"> + <div className="flex flex-col gap-2 rounded-lg border border-border p-3"> + <div className="flex items-center gap-1.5 text-xs font-semibold text-foreground"> + <History className="h-3.5 w-3.5" aria-hidden /> + Versions + </div> + <ul className="flex flex-col gap-1"> + {versions.map((version) => ( + <li + key={version.version_number} + className="flex items-center justify-between gap-2 rounded-md px-2 py-1.5 text-xs hover:bg-muted/50" + data-testid={`version-row-${version.version_number}`} + > + <span className="flex items-center gap-2"> + <span className="font-mono font-medium text-foreground"> + v{version.version_number} + </span> + <span className="text-muted-foreground">{version.name}</span> + <span className="text-muted-foreground">· {version.status}</span> + </span> + <span className="text-muted-foreground">{formatTimestamp(version.created_at)}</span> + </li> + ))} + </ul> + </div> + + <div className="flex flex-col gap-3 rounded-lg border border-border p-3"> + <div className="flex flex-wrap items-center gap-2 text-xs"> + <GitCompare className="h-3.5 w-3.5 text-muted-foreground" aria-hidden /> + <span className="font-semibold text-foreground">Compare</span> + <label className="flex items-center gap-1.5"> + <span className="text-muted-foreground">From</span> + <select + aria-label="Compare from version" + data-testid="diff-from-select" + className="rounded-md border border-border bg-background px-1.5 py-1 text-xs" + value={fromVersion ?? ""} + onChange={(event) => setFromVersion(Number(event.target.value))} + > + {versions.map((v) => ( + <option key={v.version_number} value={v.version_number}> + v{v.version_number} + </option> + ))} + </select> + </label> + <label className="flex items-center gap-1.5"> + <span className="text-muted-foreground">To</span> + <select + aria-label="Compare to version" + data-testid="diff-to-select" + className="rounded-md border border-border bg-background px-1.5 py-1 text-xs" + value={toVersion ?? ""} + onChange={(event) => setToVersion(Number(event.target.value))} + > + {versions.map((v) => ( + <option key={v.version_number} value={v.version_number}> + v{v.version_number} + </option> + ))} + </select> + </label> + </div> + + {diffQuery.isLoading ? ( + <p className="text-xs text-muted-foreground" data-testid="diff-loading"> + <RefreshCw className="mr-1 inline h-3 w-3 animate-spin" aria-hidden /> + Diffing versions… + </p> + ) : null} + + {diff ? ( + <div className="flex flex-col gap-4" data-testid="diff-panel"> + {!diff.manifest.scalar_changes.length && + !diff.manifest.requirements.length && + !diff.manifest.acceptance_criteria.length && + !diff.manifest.open_questions.length && + !diff.manifest.decisions.length && + !diff.manifest.constraints_added.length && + !diff.manifest.constraints_removed.length ? ( + <p className="text-xs text-muted-foreground" data-testid="diff-no-changes"> + No manifest changes between v{diff.from_version} and v{diff.to_version}. + </p> + ) : ( + <div className="flex flex-col gap-3"> + {diff.manifest.scalar_changes.length > 0 ? ( + <div className="flex flex-col gap-1"> + {diff.manifest.scalar_changes.map((change) => ( + <p key={change.field} className="text-xs"> + <span className="font-semibold text-foreground">{change.field}</span>{" "} + <span className="text-danger">{String(change.before)}</span>{" "} + <span className="text-muted-foreground">→</span>{" "} + <span className="text-success">{String(change.after)}</span> + </p> + ))} + </div> + ) : null} + <ListFieldDiff field="requirements" changes={diff.manifest.requirements} /> + <ListFieldDiff + field="acceptance_criteria" + changes={diff.manifest.acceptance_criteria} + /> + <ListFieldDiff field="open_questions" changes={diff.manifest.open_questions} /> + <ListFieldDiff field="decisions" changes={diff.manifest.decisions} /> + {diff.manifest.constraints_added.length > 0 || + diff.manifest.constraints_removed.length > 0 ? ( + <div className="flex flex-col gap-1"> + <p className="text-xs font-semibold text-foreground">Constraints</p> + {diff.manifest.constraints_added.map((text) => ( + <p key={`added-${text}`} className="flex items-center gap-1 text-xs text-success"> + <Plus className="h-3 w-3" aria-hidden /> + {text} + </p> + ))} + {diff.manifest.constraints_removed.map((text) => ( + <p key={`removed-${text}`} className="flex items-center gap-1 text-xs text-danger"> + <Minus className="h-3 w-3" aria-hidden /> + {text} + </p> + ))} + </div> + ) : null} + </div> + )} + + <div className="flex flex-col gap-1"> + <p className="text-xs font-semibold text-foreground">spec.md</p> + <pre + className="max-h-96 overflow-auto rounded-md border border-border bg-muted/30 p-2 font-mono text-xs leading-relaxed" + data-testid="diff-markdown" + > + {diff.markdown.map((line, index) => ( + <div + key={index} + className={cn( + "whitespace-pre-wrap px-1", + line.op === "insert" && "bg-success/10 text-success", + line.op === "delete" && "bg-danger/10 text-danger", + )} + > + {line.op === "insert" ? "+ " : line.op === "delete" ? "- " : " "} + {line.text} + </div> + ))} + </pre> + </div> + </div> + ) : null} + </div> + </div> + ); +} diff --git a/apps/web/src/lib/api/client.ts b/apps/web/src/lib/api/client.ts index 3223844d..95383ee5 100644 --- a/apps/web/src/lib/api/client.ts +++ b/apps/web/src/lib/api/client.ts @@ -89,6 +89,9 @@ import type { SpecDashboard, SpecDraft, SpecManifest, + SpecVersionDetail, + SpecVersionDiff, + SpecVersionSummary, Sprint, SprintDTO, SprintReport, @@ -469,6 +472,35 @@ export class ForgeApiClient { ); } + /** + * 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<SpecVersionSummary[]> { + return this.request<SpecVersionSummary[]>( + `/spec/specs/${encodeURIComponent(specId)}/versions`, + ); + } + + /** Read one version's full snapshot (manifest + both serializations). */ + getSpecVersion(specId: string, versionNumber: number): Promise<SpecVersionDetail> { + return this.request<SpecVersionDetail>( + `/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<SpecVersionDiff> { + return this.request<SpecVersionDiff>( + `/spec/specs/${encodeURIComponent(specId)}/versions/${fromVersion}/diff/${toVersion}`, + ); + } + /** Run the clarification pass: surface + resolve open questions. */ clarifySpec(specId: string): Promise<SpecManifest> { return this.request<SpecManifest>( diff --git a/apps/web/src/lib/api/spec-studio.ts b/apps/web/src/lib/api/spec-studio.ts index 5f456737..46229e6c 100644 --- a/apps/web/src/lib/api/spec-studio.ts +++ b/apps/web/src/lib/api/spec-studio.ts @@ -21,6 +21,7 @@ import { } from "@tanstack/react-query"; import { apiClient, type ForgeApiClient } from "./client"; +import { specVersionKeys } from "./spec-versions"; import type { SpecDraft, SpecManifest } from "./types"; export const specStudioKeys = { @@ -75,6 +76,8 @@ function useSyncAfterSave(specId: string) { 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) }); }; } 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<SpecVersionSummary[]> { + 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<SpecVersionDetail> { + 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<SpecVersionDiff> { + 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/types.ts b/apps/web/src/lib/api/types.ts index e81d7154..eb0e4833 100644 --- a/apps/web/src/lib/api/types.ts +++ b/apps/web/src/lib/api/types.ts @@ -428,6 +428,64 @@ 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<string, unknown> | null; + after?: Record<string, unknown> | 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). 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_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/spec-engine/forge_spec/__init__.py b/packages/spec-engine/forge_spec/__init__.py index 580bc60c..744b08d4 100644 --- a/packages/spec-engine/forge_spec/__init__.py +++ b/packages/spec-engine/forge_spec/__init__.py @@ -37,6 +37,14 @@ 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, @@ -83,9 +91,12 @@ "FileSpecEngine", "GapKind", "InMemoryProjectionRepository", + "ListItemChange", + "ManifestDiff", "NoOpEvidencePort", "ProjectValidationSummary", "ProjectionRepository", + "ScalarFieldChange", "SpecEngineService", "SpecNotFoundError", "SpecParseError", @@ -93,6 +104,7 @@ "SpecSourcePort", "SpecTraceabilityMatrix", "SpecValidationRow", + "TextDiffLine", "TraceCell", "TraceabilityGap", "TraceabilityProjector", @@ -107,6 +119,8 @@ "compute_spec_rollup", "constitution_id_for", "detect_gaps", + "diff_manifest", + "diff_markdown", "dump_manifest", "generate_tasks", "load_manifest", diff --git a/packages/spec-engine/forge_spec/diff.py b/packages/spec-engine/forge_spec/diff.py new file mode 100644 index 00000000..fe1f60d6 --- /dev/null +++ b/packages/spec-engine/forge_spec/diff.py @@ -0,0 +1,157 @@ +"""Spec version diffing (ss-versioning): markdown + structured manifest diffs. + +Pure functions over two spec snapshots — no filesystem, no engine, no DB — so +they are usable both by the API layer (diffing two persisted +``forge_db.models.SpecVersion`` rows) and directly in tests. + +Two complementary views: + +* :func:`diff_markdown` — a line-level unified diff of two ``spec.md`` texts + (equal/insert/delete runs), the "what changed in prose" view. +* :func:`diff_manifest` — a structured diff of two :class:`SpecManifest` + snapshots: scalar field changes (name/status/...), plus id-keyed adds/ + removes/modifications for each list field (requirements, acceptance + criteria, open questions, decisions) and a plain added/removed set for the + id-less ``constraints`` string list. +""" + +from __future__ import annotations + +from difflib import SequenceMatcher +from typing import Any, Literal + +from pydantic import BaseModel, ConfigDict, Field + +from forge_contracts import SpecManifest + +TextDiffOp = Literal["equal", "insert", "delete"] +ListChangeKind = Literal["added", "removed", "modified"] + + +class _Model(BaseModel): + model_config = ConfigDict(populate_by_name=True, extra="ignore") + + +class TextDiffLine(_Model): + """One line of a unified line-diff, tagged with how it changed.""" + + op: TextDiffOp + text: str + + +class ListItemChange(_Model): + """One id-keyed add/remove/modify entry within a manifest list field.""" + + id: str + change: ListChangeKind + before: dict[str, Any] | None = None + after: dict[str, Any] | None = None + + +class ScalarFieldChange(_Model): + """A changed top-level scalar field (e.g. ``name``, ``status``).""" + + field: str + before: Any + after: Any + + +class ManifestDiff(_Model): + """The structured diff between two :class:`SpecManifest` snapshots.""" + + scalar_changes: list[ScalarFieldChange] = Field(default_factory=list) + requirements: list[ListItemChange] = Field(default_factory=list) + acceptance_criteria: list[ListItemChange] = Field(default_factory=list) + open_questions: list[ListItemChange] = Field(default_factory=list) + decisions: list[ListItemChange] = Field(default_factory=list) + constraints_added: list[str] = Field(default_factory=list) + constraints_removed: list[str] = Field(default_factory=list) + + @property + def has_changes(self) -> bool: + """Whether *anything* differs between the two snapshots.""" + return bool( + self.scalar_changes + or self.requirements + or self.acceptance_criteria + or self.open_questions + or self.decisions + or self.constraints_added + or self.constraints_removed + ) + + +#: Top-level scalar fields compared verbatim (order = display order). +_SCALAR_FIELDS: tuple[str, ...] = ("name", "status") + + +def diff_markdown(old_text: str, new_text: str) -> list[TextDiffLine]: + """Line-level diff of two ``spec.md`` texts (equal/insert/delete runs).""" + old_lines = old_text.splitlines() + new_lines = new_text.splitlines() + matcher = SequenceMatcher(None, old_lines, new_lines, autojunk=False) + lines: list[TextDiffLine] = [] + for tag, i1, i2, j1, j2 in matcher.get_opcodes(): + if tag == "equal": + lines.extend(TextDiffLine(op="equal", text=text) for text in old_lines[i1:i2]) + elif tag == "delete": + lines.extend(TextDiffLine(op="delete", text=text) for text in old_lines[i1:i2]) + elif tag == "insert": + lines.extend(TextDiffLine(op="insert", text=text) for text in new_lines[j1:j2]) + elif tag == "replace": + lines.extend(TextDiffLine(op="delete", text=text) for text in old_lines[i1:i2]) + lines.extend(TextDiffLine(op="insert", text=text) for text in new_lines[j1:j2]) + return lines + + +def _diff_ids(old_items: list[Any], new_items: list[Any]) -> list[ListItemChange]: + old_by_id = {item.id: item.model_dump(mode="json") for item in old_items} + new_by_id = {item.id: item.model_dump(mode="json") for item in new_items} + changes: list[ListItemChange] = [] + for item_id in old_by_id: + if item_id not in new_by_id: + changes.append(ListItemChange(id=item_id, change="removed", before=old_by_id[item_id])) + elif old_by_id[item_id] != new_by_id[item_id]: + changes.append( + ListItemChange( + id=item_id, + change="modified", + before=old_by_id[item_id], + after=new_by_id[item_id], + ) + ) + for item_id in new_by_id: + if item_id not in old_by_id: + changes.append(ListItemChange(id=item_id, change="added", after=new_by_id[item_id])) + return changes + + +def diff_manifest(old: SpecManifest, new: SpecManifest) -> ManifestDiff: + """Structured diff between two spec manifests (see :class:`ManifestDiff`).""" + scalar_changes = [ + ScalarFieldChange(field=field, before=getattr(old, field), after=getattr(new, field)) + for field in _SCALAR_FIELDS + if getattr(old, field) != getattr(new, field) + ] + old_constraints, new_constraints = set(old.constraints), set(new.constraints) + return ManifestDiff( + scalar_changes=scalar_changes, + requirements=_diff_ids(old.requirements, new.requirements), + acceptance_criteria=_diff_ids(old.acceptance_criteria, new.acceptance_criteria), + open_questions=_diff_ids(old.open_questions, new.open_questions), + decisions=_diff_ids(old.decisions, new.decisions), + constraints_added=sorted(new_constraints - old_constraints), + constraints_removed=sorted(old_constraints - new_constraints), + ) + + +__all__ = [ + "ListChangeKind", + "ListItemChange", + "ManifestDiff", + "ScalarFieldChange", + "TextDiffLine", + "TextDiffOp", + "diff_manifest", + "diff_markdown", +] diff --git a/packages/spec-engine/tests/test_spec_diff.py b/packages/spec-engine/tests/test_spec_diff.py new file mode 100644 index 00000000..1bf47c8e --- /dev/null +++ b/packages/spec-engine/tests/test_spec_diff.py @@ -0,0 +1,88 @@ +"""Tests for ``forge_spec.diff`` (ss-versioning: spec version diffing).""" + +from __future__ import annotations + +from forge_contracts import AcceptanceCriterion, Requirement, SpecManifest, SpecStatus +from forge_spec.diff import diff_manifest, diff_markdown + + +def _manifest(**overrides: object) -> SpecManifest: + base = { + "id": "SPEC-1-widget", + "name": "Widget", + "status": SpecStatus.DRAFT, + "requirements": [Requirement(id="R1", text="Do the thing")], + "acceptance_criteria": [ + AcceptanceCriterion(id="A1", req_refs=["R1"], text="Given...When...Then...") + ], + "constraints": ["Must be fast"], + } + base.update(overrides) + return SpecManifest(**base) # type: ignore[arg-type] + + +def test_diff_markdown_no_changes_is_all_equal() -> None: + text = "line one\nline two\n" + lines = diff_markdown(text, text) + assert all(line.op == "equal" for line in lines) + assert [line.text for line in lines] == ["line one", "line two"] + + +def test_diff_markdown_detects_insert_and_delete() -> None: + old = "keep\nremove me\n" + new = "keep\nadd me\n" + lines = diff_markdown(old, new) + ops = {(line.op, line.text) for line in lines} + assert ("equal", "keep") in ops + assert ("delete", "remove me") in ops + assert ("insert", "add me") in ops + + +def test_diff_manifest_no_changes_has_no_changes() -> None: + manifest = _manifest() + diff = diff_manifest(manifest, manifest) + assert diff.has_changes is False + assert diff.scalar_changes == [] + assert diff.requirements == [] + + +def test_diff_manifest_detects_scalar_change() -> None: + old = _manifest(status=SpecStatus.DRAFT) + new = _manifest(status=SpecStatus.APPROVED) + diff = diff_manifest(old, new) + assert diff.has_changes is True + assert len(diff.scalar_changes) == 1 + change = diff.scalar_changes[0] + assert change.field == "status" + assert change.before == SpecStatus.DRAFT.value or change.before == SpecStatus.DRAFT + assert change.after == SpecStatus.APPROVED.value or change.after == SpecStatus.APPROVED + + +def test_diff_manifest_detects_requirement_added_removed_modified() -> None: + old = _manifest( + requirements=[ + Requirement(id="R1", text="Original text"), + Requirement(id="R2", text="Will be removed"), + ] + ) + new = _manifest( + requirements=[ + Requirement(id="R1", text="Changed text"), + Requirement(id="R3", text="Brand new"), + ] + ) + diff = diff_manifest(old, new) + by_id = {c.id: c for c in diff.requirements} + assert by_id["R2"].change == "removed" + assert by_id["R3"].change == "added" + assert by_id["R1"].change == "modified" + assert by_id["R1"].before is not None and by_id["R1"].before["text"] == "Original text" + assert by_id["R1"].after is not None and by_id["R1"].after["text"] == "Changed text" + + +def test_diff_manifest_detects_constraints_added_removed() -> None: + old = _manifest(constraints=["A", "B"]) + new = _manifest(constraints=["B", "C"]) + diff = diff_manifest(old, new) + assert diff.constraints_added == ["C"] + assert diff.constraints_removed == ["A"] From 7f1bebcff683ac7656b29919f5d21cb788584ce9 Mon Sep 17 00:00:00 2001 From: Forge Swarm <swarm@forge.local> Date: Thu, 9 Jul 2026 07:51:33 +0200 Subject: [PATCH 14/20] feat(ss-import): Spec Studio Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --- apps/api/forge_api/routers/spec.py | 19 + .../forge_api/services/spec_import_service.py | 363 ++++++++++++++++++ apps/api/tests/test_spec_import.py | 300 +++++++++++++++ apps/web/src/lib/api/client-spec.test.tsx | 30 ++ apps/web/src/lib/api/client.ts | 14 + apps/web/src/lib/api/spec-studio.test.tsx | 57 ++- apps/web/src/lib/api/spec-studio.ts | 21 +- apps/web/src/lib/api/types.ts | 18 + 8 files changed, 819 insertions(+), 3 deletions(-) create mode 100644 apps/api/forge_api/services/spec_import_service.py create mode 100644 apps/api/tests/test_spec_import.py diff --git a/apps/api/forge_api/routers/spec.py b/apps/api/forge_api/routers/spec.py index 359ffcae..56533678 100644 --- a/apps/api/forge_api/routers/spec.py +++ b/apps/api/forge_api/routers/spec.py @@ -31,6 +31,7 @@ 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, @@ -575,6 +576,22 @@ def draft_spec_endpoint( 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 # # --------------------------------------------------------------------------- # @@ -654,6 +671,8 @@ def project_spec_overview( "SpecCreateRequest", "SpecDashboard", "SpecEngineRegistry", + "SpecImport", + "SpecImportRequest", "SpecOverview", "SpecVersionDetail", "SpecVersionDiff", 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..6a40e532 --- /dev/null +++ b/apps/api/forge_api/services/spec_import_service.py @@ -0,0 +1,363 @@ +"""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 # +# --------------------------------------------------------------------------- # + +_HEADING_RE = re.compile(r"^(#{1,6})\s+(.+?)\s*$", re.MULTILINE) +_BULLET_RE = re.compile(r"^\s*[-*]\s+(.+?)\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/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/web/src/lib/api/client-spec.test.tsx b/apps/web/src/lib/api/client-spec.test.tsx index 35934c67..ef989673 100644 --- a/apps/web/src/lib/api/client-spec.test.tsx +++ b/apps/web/src/lib/api/client-spec.test.tsx @@ -146,4 +146,34 @@ describe("ForgeApiClient spec-engine surface", () => { 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 95383ee5..2ad8d8e6 100644 --- a/apps/web/src/lib/api/client.ts +++ b/apps/web/src/lib/api/client.ts @@ -88,6 +88,7 @@ import type { TeamRole, SpecDashboard, SpecDraft, + SpecImport, SpecManifest, SpecVersionDetail, SpecVersionDiff, @@ -556,6 +557,19 @@ export class ForgeApiClient { return this.request<SpecDraft>("/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<SpecImport> { + return this.request<SpecImport>("/spec/import", { method: "POST", body }); + } + /** Read a project's constitution (404 if it was never initialised). */ getConstitution(projectId: string): Promise<Constitution> { return this.request<Constitution>( diff --git a/apps/web/src/lib/api/spec-studio.test.tsx b/apps/web/src/lib/api/spec-studio.test.tsx index 1214b035..e19ffdde 100644 --- a/apps/web/src/lib/api/spec-studio.test.tsx +++ b/apps/web/src/lib/api/spec-studio.test.tsx @@ -4,8 +4,8 @@ import type { ReactNode } from "react"; import { describe, expect, it, vi } from "vitest"; import type { ForgeApiClient } from "./client"; -import { useDraftSpec } from "./spec-studio"; -import type { SpecDraft } from "./types"; +import { useDraftSpec, useImportSpec } from "./spec-studio"; +import type { SpecDraft, SpecImport } from "./types"; function makeWrapper(client: QueryClient) { return function Wrapper({ children }: { children: ReactNode }) { @@ -65,3 +65,56 @@ describe("useDraftSpec", () => { 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 index 46229e6c..a1b0910d 100644 --- a/apps/web/src/lib/api/spec-studio.ts +++ b/apps/web/src/lib/api/spec-studio.ts @@ -22,7 +22,7 @@ import { import { apiClient, type ForgeApiClient } from "./client"; import { specVersionKeys } from "./spec-versions"; -import type { SpecDraft, SpecManifest } from "./types"; +import type { SpecDraft, SpecImport, SpecManifest } from "./types"; export const specStudioKeys = { manifest: (specId: string) => ["spec-studio", "manifest", specId] as const, @@ -132,3 +132,22 @@ export function useDraftSpec( 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<SpecImport, Error, ImportSpecVariables> { + return useMutation({ + mutationFn: (body: ImportSpecVariables) => client.importSpec(body), + }); +} diff --git a/apps/web/src/lib/api/types.ts b/apps/web/src/lib/api/types.ts index eb0e4833..ac057c2e 100644 --- a/apps/web/src/lib/api/types.ts +++ b/apps/web/src/lib/api/types.ts @@ -515,6 +515,24 @@ export interface SpecDraft { 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. From 804a840142c5008dcfe64aa99b11c52e37ae3b02 Mon Sep 17 00:00:00 2001 From: Forge Swarm <swarm@forge.local> Date: Thu, 9 Jul 2026 08:34:04 +0200 Subject: [PATCH 15/20] feat(ss-criteria): Spec Studio Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --- .../spec-studio/guided-helpers.test.ts | 58 ++++++ .../components/spec-studio/guided-helpers.ts | 76 ++++++++ .../spec-studio/guided-mode.test.tsx | 63 +++++++ .../components/spec-studio/guided-mode.tsx | 149 ++++++++++++--- .../lib/spec-studio/markdown-parse.test.ts | 17 ++ .../web/src/lib/spec-studio/markdown-parse.ts | 11 ++ packages/spec-engine/forge_spec/__init__.py | 24 +++ packages/spec-engine/forge_spec/criteria.py | 145 +++++++++++++++ packages/spec-engine/forge_spec/markdown.py | 26 ++- .../spec-engine/tests/test_spec_criteria.py | 175 ++++++++++++++++++ 10 files changed, 713 insertions(+), 31 deletions(-) create mode 100644 packages/spec-engine/forge_spec/criteria.py create mode 100644 packages/spec-engine/tests/test_spec_criteria.py diff --git a/apps/web/src/components/spec-studio/guided-helpers.test.ts b/apps/web/src/components/spec-studio/guided-helpers.test.ts index 81abf137..2d2b7954 100644 --- a/apps/web/src/components/spec-studio/guided-helpers.test.ts +++ b/apps/web/src/components/spec-studio/guided-helpers.test.ts @@ -6,11 +6,15 @@ import { addAcceptanceCriterion, addAdr, addRequirement, + classifyCriterionStyle, + composeChecklist, composeGivenWhenThen, computeChecklist, computeCoverage, computeNudges, + convertCriterionText, nextSequentialId, + parseChecklist, parseGivenWhenThen, } from "./guided-helpers"; @@ -49,6 +53,60 @@ describe("Given/When/Then round trip", () => { }); }); +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" }; diff --git a/apps/web/src/components/spec-studio/guided-helpers.ts b/apps/web/src/components/spec-studio/guided-helpers.ts index 7a19abef..9e109414 100644 --- a/apps/web/src/components/spec-studio/guided-helpers.ts +++ b/apps/web/src/components/spec-studio/guided-helpers.ts @@ -62,6 +62,82 @@ export function composeGivenWhenThen({ given, when, then }: GivenWhenThen): stri 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; diff --git a/apps/web/src/components/spec-studio/guided-mode.test.tsx b/apps/web/src/components/spec-studio/guided-mode.test.tsx index b9770142..b9498b1f 100644 --- a/apps/web/src/components/spec-studio/guided-mode.test.tsx +++ b/apps/web/src/components/spec-studio/guided-mode.test.tsx @@ -46,6 +46,69 @@ describe("GuidedMode", () => { expect(screen.getByLabelText("AC1 then")).toHaveValue("they land on the board"); }); + it("defaults a new acceptance criterion to the Given/When/Then style", () => { + render(<Harness initial={baseManifest} />); + 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(<Harness initial={baseManifest} />); + 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(<Harness initial={baseManifest} />); + 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( + <Harness + initial={{ + ...baseManifest, + acceptance_criteria: [ + { id: "AC1", text: "- [ ] a\n- [x] b", req_refs: ["R1"] }, + ], + }} + />, + ); + 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( + <Harness + initial={{ + ...baseManifest, + acceptance_criteria: [{ id: "AC1", text: "Given x When y Then z", req_refs: ["R1"] }], + }} + />, + ); + 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(<Harness initial={baseManifest} />); fireEvent.click(screen.getByTestId("guided-add-acceptance-criterion")); diff --git a/apps/web/src/components/spec-studio/guided-mode.tsx b/apps/web/src/components/spec-studio/guided-mode.tsx index 00750e02..7008fcf9 100644 --- a/apps/web/src/components/spec-studio/guided-mode.tsx +++ b/apps/web/src/components/spec-studio/guided-mode.tsx @@ -18,10 +18,15 @@ import { addAcceptanceCriterion, addAdr, addRequirement, + classifyCriterionStyle, + composeChecklist, composeGivenWhenThen, computeChecklist, computeCoverage, computeNudges, + convertCriterionText, + type CriterionStyle, + parseChecklist, parseGivenWhenThen, } from "./guided-helpers"; @@ -49,8 +54,18 @@ const EXECUTION_MODES: { value: ExecutionMode; label: string }[] = [ * 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<Record<string, CriterionStyle>>({}); const requirements = value.requirements ?? []; const criteria = value.acceptance_criteria ?? []; const constraints = value.constraints ?? []; @@ -167,16 +182,21 @@ export function GuidedMode({ value, onChange, onSave, saving = false, dirty = fa </h3> <ul className="flex flex-col gap-3" data-testid="guided-acceptance-criteria"> {criteria.map((ac, index) => { - const gwt = parseGivenWhenThen(ac.text); const refs = ac.req_refs ?? []; const linkable = requirements.filter((r) => !refs.includes(r.id)); + const style = styleOverrides[ac.id] ?? classifyCriterionStyle(ac.text); - function updateGwt(patch: Partial<typeof gwt>) { + function setText(text: string) { const next = [...criteria]; - next[index] = { ...next[index], text: composeGivenWhenThen({ ...gwt, ...patch }) }; + next[index] = { ...next[index], text }; setCriteria(next); } + function changeStyle(nextStyle: CriterionStyle) { + setStyleOverrides((prev) => ({ ...prev, [ac.id]: nextStyle })); + setText(convertCriterionText(ac.text, nextStyle)); + } + return ( <li key={ac.id} @@ -190,7 +210,19 @@ export function GuidedMode({ value, onChange, onSave, saving = false, dirty = fa > {ac.id} </span> - <span className="text-xs text-muted-foreground">Acceptance criterion</span> + <select + aria-label={`${ac.id} style`} + data-testid={`ac-style-${index}`} + value={style} + onChange={(event) => changeStyle(event.target.value as CriterionStyle)} + className="rounded-md border border-border bg-card px-2 py-1 text-xs text-muted-foreground outline-none" + > + {CRITERION_STYLES.map((option) => ( + <option key={option.value} value={option.value}> + {option.label} + </option> + ))} + </select> <button type="button" aria-label={`Remove ${ac.id}`} @@ -201,35 +233,22 @@ export function GuidedMode({ value, onChange, onSave, saving = false, dirty = fa </button> </div> - <div className="grid grid-cols-1 gap-2 sm:grid-cols-3"> + {style === "gherkin" ? ( + <GherkinEditor id={ac.id} text={ac.text} onChange={setText} /> + ) : style === "checklist" ? ( + <ChecklistEditor id={ac.id} text={ac.text} onChange={setText} /> + ) : ( <label className="flex flex-col gap-1 text-xs"> - <span className="text-muted-foreground">Given</span> + <span className="text-muted-foreground">Assertion</span> <input - aria-label={`${ac.id} given`} - value={gwt.given} - onChange={(event) => updateGwt({ given: event.target.value })} + aria-label={`${ac.id} assertion`} + value={ac.text} + onChange={(event) => setText(event.target.value)} + placeholder="The system does X" className="rounded-md border border-border bg-card px-2 py-1.5 text-sm text-foreground outline-none" /> </label> - <label className="flex flex-col gap-1 text-xs"> - <span className="text-muted-foreground">When</span> - <input - aria-label={`${ac.id} when`} - value={gwt.when} - onChange={(event) => updateGwt({ when: event.target.value })} - className="rounded-md border border-border bg-card px-2 py-1.5 text-sm text-foreground outline-none" - /> - </label> - <label className="flex flex-col gap-1 text-xs"> - <span className="text-muted-foreground">Then</span> - <input - aria-label={`${ac.id} then`} - value={gwt.then} - onChange={(event) => updateGwt({ then: event.target.value })} - className="rounded-md border border-border bg-card px-2 py-1.5 text-sm text-foreground outline-none" - /> - </label> - </div> + )} <div className="flex flex-wrap items-center gap-1.5"> {refs.map((refId) => ( @@ -557,3 +576,77 @@ function StringListField({ </div> ); } + +/** 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<typeof gwt>) => onChange(composeGivenWhenThen({ ...gwt, ...patch })); + return ( + <div className="grid grid-cols-1 gap-2 sm:grid-cols-3"> + {(["given", "when", "then"] as const).map((clause) => ( + <label key={clause} className="flex flex-col gap-1 text-xs"> + <span className="capitalize text-muted-foreground">{clause}</span> + <input + aria-label={`${id} ${clause}`} + value={gwt[clause]} + onChange={(event) => update({ [clause]: event.target.value })} + className="rounded-md border border-border bg-card px-2 py-1.5 text-sm text-foreground outline-none" + /> + </label> + ))} + </div> + ); +} + +/** 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 ( + <div className="flex flex-col gap-1.5" data-testid={`ac-checklist-${id}`}> + <ul className="flex flex-col gap-1.5"> + {items.map((item, index) => ( + <li key={index} className="flex items-center gap-2"> + <input + type="checkbox" + aria-label={`${id} item ${index + 1} done`} + checked={item.checked} + onChange={(event) => + commit(items.map((it, i) => (i === index ? { ...it, checked: event.target.checked } : it))) + } + className="h-4 w-4 shrink-0 accent-primary" + /> + <input + aria-label={`${id} item ${index + 1}`} + value={item.label} + onChange={(event) => + commit(items.map((it, i) => (i === index ? { ...it, label: event.target.value } : it))) + } + placeholder="Checklist item" + className="flex-1 rounded-md border border-border bg-card px-2 py-1.5 text-sm text-foreground outline-none" + /> + <button + type="button" + aria-label={`Remove ${id} item ${index + 1}`} + onClick={() => commit(items.filter((_, i) => i !== index))} + className="rounded-md p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground" + > + <Trash2 className="h-4 w-4" aria-hidden /> + </button> + </li> + ))} + </ul> + <Button + type="button" + variant="outline" + size="sm" + className="w-fit" + data-testid={`ac-checklist-add-${id}`} + onClick={() => commit([...items, { label: "", checked: false }])} + > + <Plus className="h-4 w-4" aria-hidden /> + Add item + </Button> + </div> + ); +} diff --git a/apps/web/src/lib/spec-studio/markdown-parse.test.ts b/apps/web/src/lib/spec-studio/markdown-parse.test.ts index 152301ab..bde00580 100644 --- a/apps/web/src/lib/spec-studio/markdown-parse.test.ts +++ b/apps/web/src/lib/spec-studio/markdown-parse.test.ts @@ -114,6 +114,23 @@ describe("parseSpecMarkdown", () => { 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); diff --git a/apps/web/src/lib/spec-studio/markdown-parse.ts b/apps/web/src/lib/spec-studio/markdown-parse.ts index f6b42e78..32e954eb 100644 --- a/apps/web/src/lib/spec-studio/markdown-parse.ts +++ b/apps/web/src/lib/spec-studio/markdown-parse.ts @@ -171,6 +171,17 @@ function parseRefs(refs: string | undefined): { reqRefs: string[]; specRef: stri 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({ diff --git a/packages/spec-engine/forge_spec/__init__.py b/packages/spec-engine/forge_spec/__init__.py index 744b08d4..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, @@ -80,16 +93,22 @@ 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", @@ -116,6 +135,9 @@ "build_validation_report", "check_implementation_gate", "classify_cell", + "classify_criterion", + "compose_checklist", + "compose_gherkin", "compute_spec_rollup", "constitution_id_for", "detect_gaps", @@ -125,6 +147,8 @@ "generate_tasks", "load_manifest", "manifest_to_dict", + "parse_checklist", + "parse_gherkin", "parse_spec_md", "render_spec_md", "slugify", 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<mark>[ xX])\] ?(?P<label>.*)$") +#: Gherkin structural keywords — their presence marks behavioural prose. +_GHERKIN_KEYWORD = re.compile(r"\b(?:given|when|then)\b", re.IGNORECASE) +#: Independent Given/When/Then clause extractors (partial edits round-trip). +_GIVEN = re.compile(r"Given\s+(.*?)(?=\s+When\s+|\s+Then\s+|$)", re.IGNORECASE | re.DOTALL) +_WHEN = re.compile(r"When\s+(.*?)(?=\s+Then\s+|$)", re.IGNORECASE | re.DOTALL) +_THEN = re.compile(r"Then\s+(.*)$", re.IGNORECASE | re.DOTALL) + + +class ChecklistItem(NamedTuple): + """One checklist entry: a ``label`` and whether its box is ``checked``.""" + + label: str + checked: bool + + +class GivenWhenThen(NamedTuple): + """The three clauses of a gherkin criterion (any may be empty).""" + + given: str + when: str + then: str + + +def _nonblank_lines(text: str) -> list[str]: + return [line.strip() for line in text.splitlines() if line.strip()] + + +def classify_criterion(text: str) -> CriterionStyle: + """Infer the authoring style of a criterion's ``text`` (never raises). + + Empty/blank text defaults to :data:`GHERKIN` (the editor's default shape). + Checklist wins over gherkin when every non-blank line is a check item, so a + checklist whose labels happen to contain ``when`` is still a checklist. + """ + lines = _nonblank_lines(text) + if not lines: + return GHERKIN + if all(_CHECK_ITEM.match(line) for line in lines): + return CHECKLIST + if _GHERKIN_KEYWORD.search(text): + return GHERKIN + return ASSERTION + + +def parse_checklist(text: str) -> list[ChecklistItem]: + """Parse checklist ``text`` into items (non-item lines become unchecked).""" + items: list[ChecklistItem] = [] + for line in _nonblank_lines(text): + match = _CHECK_ITEM.match(line) + if match is None: + items.append(ChecklistItem(label=line, checked=False)) + continue + items.append( + ChecklistItem(label=match["label"].strip(), checked=match["mark"] in ("x", "X")) + ) + return items + + +def compose_checklist(items: list[ChecklistItem]) -> str: + """Render checklist ``items`` back to canonical ``- [ ] label`` lines.""" + return "\n".join(f"- [{'x' if item.checked else ' '}] {item.label}".rstrip() for item in items) + + +def parse_gherkin(text: str) -> GivenWhenThen: + """Best-effort split of ``text`` into Given/When/Then clauses. + + When no keyword is present the whole text is treated as the ``then`` clause + (mirrors the guided editor's ``parseGivenWhenThen``). + """ + trimmed = text.strip() + given = _GIVEN.search(trimmed) + when = _WHEN.search(trimmed) + then = _THEN.search(trimmed) + if given is None and when is None and then is None: + return GivenWhenThen(given="", when="", then=trimmed) + return GivenWhenThen( + given=given.group(1).strip() if given else "", + when=when.group(1).strip() if when else "", + then=then.group(1).strip() if then else "", + ) + + +def compose_gherkin(parts: GivenWhenThen) -> str: + """Compose Given/When/Then ``parts`` back into a single criterion text.""" + chunks: list[str] = [] + if parts.given: + chunks.append(f"Given {parts.given}") + if parts.when: + chunks.append(f"When {parts.when}") + if parts.then: + chunks.append(f"Then {parts.then}") + return " ".join(chunks) + + +__all__ = [ + "ASSERTION", + "CHECKLIST", + "GHERKIN", + "ChecklistItem", + "CriterionStyle", + "GivenWhenThen", + "classify_criterion", + "compose_checklist", + "compose_gherkin", + "parse_checklist", + "parse_gherkin", +] diff --git a/packages/spec-engine/forge_spec/markdown.py b/packages/spec-engine/forge_spec/markdown.py index c7ac916c..e5ee6282 100644 --- a/packages/spec-engine/forge_spec/markdown.py +++ b/packages/spec-engine/forge_spec/markdown.py @@ -153,14 +153,25 @@ def _frontmatter(manifest: SpecManifest) -> str: return f"---\n{body}---" -def _acceptance_line(criterion: AcceptanceCriterion) -> str: +def _acceptance_lines(criterion: AcceptanceCriterion) -> list[str]: + """Render one criterion, spanning multiple lines when ``text`` is multi-line. + + The bullet header carries the id, the ``(refs)`` parenthetical and the first + line of ``text``; any further lines (e.g. a checklist's ``- [ ] item`` + entries) are emitted as 2-space *continuation* lines and folded back on + parse — so multi-line criterion styles round-trip without a schema change. + """ inner: list[str] = [] if criterion.req_refs: inner.append(", ".join(criterion.req_refs)) if criterion.spec_ref: inner.append(f"spec={criterion.spec_ref}") paren = f" ({'; '.join(inner)})" if inner else "" - return f"- **{criterion.id}**{paren}: {criterion.text}" + text_lines = criterion.text.split("\n") + head = text_lines[0] if text_lines else "" + lines = [f"- **{criterion.id}**{paren}: {head}"] + lines += [f" {cont}" for cont in text_lines[1:]] + return lines def _decision_block(adr: ADR) -> list[str]: @@ -184,7 +195,8 @@ def render_spec_md(manifest: SpecManifest) -> str: if manifest.acceptance_criteria: parts += ["", f"{_H2}Acceptance Criteria", ""] - parts += [_acceptance_line(a) for a in manifest.acceptance_criteria] + for a in manifest.acceptance_criteria: + parts += _acceptance_lines(a) if manifest.constraints: parts += ["", f"{_H2}Constraints", ""] @@ -304,6 +316,14 @@ def _parse_refs(refs: str | None) -> tuple[list[str], str | None]: def _parse_acceptance(section: _Section) -> list[AcceptanceCriterion]: out: list[AcceptanceCriterion] = [] for line_no, text in _nonblank(section): + if text.startswith(" "): # 2-space continuation of the preceding bullet + if not out: + raise SpecParseError( + "acceptance continuation line before any criterion", line=line_no + ) + prev = out[-1] + out[-1] = prev.model_copy(update={"text": f"{prev.text}\n{text[2:]}"}) + continue match = _ACCEPT_BULLET.match(text) if not match: raise SpecParseError( diff --git a/packages/spec-engine/tests/test_spec_criteria.py b/packages/spec-engine/tests/test_spec_criteria.py new file mode 100644 index 00000000..eb3e7079 --- /dev/null +++ b/packages/spec-engine/tests/test_spec_criteria.py @@ -0,0 +1,175 @@ +"""Acceptance-criterion *style* tests (ss-criteria). + +Criteria may be authored in three first-class styles — ``gherkin``, +``assertion`` and ``checklist`` — all encoded in the single ``text`` field so the +canonical :class:`SpecManifest` and its ``req_refs`` (R#) linking are untouched. +These tests pin the style classifier + (de)serialisers and prove that a +multi-line **checklist** criterion round-trips through ``spec.md`` *with its +requirement links intact*. +""" + +from __future__ import annotations + +import pytest + +from forge_contracts import AcceptanceCriterion, Requirement, SpecManifest +from forge_spec import ( + ASSERTION, + CHECKLIST, + GHERKIN, + ChecklistItem, + GivenWhenThen, + classify_criterion, + compose_checklist, + compose_gherkin, + parse_checklist, + parse_gherkin, + parse_spec_md, + render_spec_md, +) + +# --------------------------------------------------------------------------- # +# classify_criterion # +# --------------------------------------------------------------------------- # + + +@pytest.mark.parametrize( + ("text", "style"), + [ + ("", GHERKIN), + (" ", GHERKIN), + ("Given a user When they sign in Then they land on the board", GHERKIN), + ("Then it works", GHERKIN), + ("The endpoint returns 200 for a valid token", ASSERTION), + ("- [ ] Email field validates\n- [x] Password is masked", CHECKLIST), + ("- [ ] single unchecked item", CHECKLIST), + ], +) +def test_classify_criterion(text: str, style: str) -> None: + assert classify_criterion(text) == style + + +def test_checklist_wins_over_gherkin_keywords_in_labels() -> None: + # A checklist item whose label contains "when" is still a checklist. + text = "- [ ] Logs an event when the job runs\n- [ ] Retries on failure" + assert classify_criterion(text) == CHECKLIST + + +# --------------------------------------------------------------------------- # +# checklist (de)serialisation # +# --------------------------------------------------------------------------- # + + +def test_checklist_round_trips() -> None: + items = [ + ChecklistItem(label="Email field validates", checked=False), + ChecklistItem(label="Password is masked", checked=True), + ] + text = compose_checklist(items) + assert text == "- [ ] Email field validates\n- [x] Password is masked" + assert parse_checklist(text) == items + + +def test_checklist_empty_label_has_no_trailing_space() -> None: + assert compose_checklist([ChecklistItem(label="", checked=False)]) == "- [ ]" + + +def test_parse_checklist_tolerates_non_item_lines() -> None: + assert parse_checklist("plain line") == [ChecklistItem(label="plain line", checked=False)] + + +# --------------------------------------------------------------------------- # +# gherkin (de)serialisation # +# --------------------------------------------------------------------------- # + + +def test_gherkin_round_trips() -> None: + parts = GivenWhenThen(given="a user", when="they sign in", then="they land on the board") + text = compose_gherkin(parts) + assert text == "Given a user When they sign in Then they land on the board" + assert parse_gherkin(text) == parts + + +def test_gherkin_unstructured_text_becomes_then_clause() -> None: + assert parse_gherkin("just some prose") == GivenWhenThen( + given="", when="", then="just some prose" + ) + + +# --------------------------------------------------------------------------- # +# spec.md round-trip for multi-line / checklist criteria (R# links intact) # +# --------------------------------------------------------------------------- # + + +def _mixed_style_manifest() -> SpecManifest: + return SpecManifest( + id="SPEC-7", + name="Login flow", + requirements=[ + Requirement(id="R1", text="Users can sign in"), + Requirement(id="R2", text="Sign-in form is accessible"), + ], + acceptance_criteria=[ + AcceptanceCriterion( + id="A1", + req_refs=["R1"], + text="Given valid credentials When submitted Then the board loads", + ), + AcceptanceCriterion( + id="A2", + req_refs=["R1"], + text="The API returns 200 for a valid session token", + ), + AcceptanceCriterion( + id="A3", + req_refs=["R2"], + text=( + "- [ ] Email field validates\n" + "- [x] Password is masked\n" + "- [ ] Submit disabled when empty" + ), + spec_ref="SPEC-2", + ), + ], + ) + + +def test_mixed_style_manifest_round_trips() -> None: + manifest = _mixed_style_manifest() + assert parse_spec_md(render_spec_md(manifest)) == manifest + + +def test_render_is_stable_for_mixed_styles() -> None: + rendered = render_spec_md(_mixed_style_manifest()) + assert render_spec_md(parse_spec_md(rendered)) == rendered + + +def test_checklist_criterion_renders_as_continuation_lines() -> None: + rendered = render_spec_md(_mixed_style_manifest()) + # Header carries id + refs + spec_ref + first item; rest are 2-space bullets. + assert "- **A3** (R2; spec=SPEC-2): - [ ] Email field validates" in rendered + assert "\n - [x] Password is masked\n" in rendered + + +def test_checklist_criterion_keeps_req_refs_after_round_trip() -> None: + parsed = parse_spec_md(render_spec_md(_mixed_style_manifest())) + a3 = parsed.acceptance_criteria[2] + assert a3.req_refs == ["R2"] + assert a3.spec_ref == "SPEC-2" + assert classify_criterion(a3.text) == CHECKLIST + assert parse_checklist(a3.text) == [ + ChecklistItem(label="Email field validates", checked=False), + ChecklistItem(label="Password is masked", checked=True), + ChecklistItem(label="Submit disabled when empty", checked=False), + ] + + +def test_dangling_acceptance_continuation_raises() -> None: + from forge_spec import SpecParseError + + text = ( + "---\nid: SPEC-1\n---\n\n## Goal\n\nName\n\n" + "## Acceptance Criteria\n\n - [ ] orph continuation\n" + ) + with pytest.raises(SpecParseError): + parse_spec_md(text) From d1a5dddd7b251c0cac9b96da252642c2770c7baf Mon Sep 17 00:00:00 2001 From: Forge Swarm <swarm@forge.local> Date: Thu, 9 Jul 2026 08:54:51 +0200 Subject: [PATCH 16/20] =?UTF-8?q?docs:=20Spec=20Studio=20progress=20ledger?= =?UTF-8?q?=20+=20design=20doc=20=E2=80=94=20Phase=202=20shipped,=20gate?= =?UTF-8?q?=20re-verified?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Records the 14-slice Spec Studio build (ss-parser through ss-criteria) in docs/ADAPTIVE_SPEC_PROGRESS.md alongside the existing Adaptive Orchestration ledger: what shipped, refuted/repaired counts per slice, what's parked (reject/request-changes persistence, real-time co-editing still design-only), and a full whole-repo gate re-verification (ruff, ruff-format, mypy, full pytest on real pgvector — 3980 passed/0 failed — bandit, gitleaks, web lint/build/test/typecheck, and an isolated upgrade+downgrade check of the new spec_version migration). Updates docs/spec-studio/DESIGN.md's status/§2.2 table to reflect the dual-format round-trip + editor as built, leaving only real-time co-editing (§4, Yjs) as the remaining design-only item. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- docs/ADAPTIVE_SPEC_PROGRESS.md | 317 +++++++++++++++++++++++++-------- docs/spec-studio/DESIGN.md | 63 +++---- 2 files changed, 267 insertions(+), 113 deletions(-) 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/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. From 577980f321c93c51b9bf9f0b7af6b5407c3f1883 Mon Sep 17 00:00:00 2001 From: Forge Swarm <swarm@forge.local> Date: Thu, 9 Jul 2026 09:23:18 +0200 Subject: [PATCH 17/20] ci: re-trigger spec-studio checks (prior runs were cancelled) From 017519ab1c7716d104ffd239ef1f646ae48048f7 Mon Sep 17 00:00:00 2001 From: Forge Swarm <swarm@forge.local> Date: Thu, 9 Jul 2026 09:31:50 +0200 Subject: [PATCH 18/20] fix(spec-import): linear-time heading/bullet regexes (CodeQL polynomial-redos) --- apps/api/forge_api/services/spec_import_service.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/api/forge_api/services/spec_import_service.py b/apps/api/forge_api/services/spec_import_service.py index 6a40e532..83d84e0b 100644 --- a/apps/api/forge_api/services/spec_import_service.py +++ b/apps/api/forge_api/services/spec_import_service.py @@ -206,8 +206,12 @@ def _manifest_from_loose_yaml(data: dict[str, Any]) -> SpecManifest: # Loose-markdown normalization # # --------------------------------------------------------------------------- # -_HEADING_RE = re.compile(r"^(#{1,6})\s+(.+?)\s*$", re.MULTILINE) -_BULLET_RE = re.compile(r"^\s*[-*]\s+(.+?)\s*$") +# 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]+(.+)$", re.MULTILINE) +_BULLET_RE = re.compile(r"^[ \t]*[-*][ \t]+(.+)$") #: Heading text (lowercased, trailing ':' stripped) -> the bucket it feeds. _SECTION_ALIASES: dict[str, str] = { From 4b61a7ed46ad731476d6d0d16b51b1ec3f178969 Mon Sep 17 00:00:00 2001 From: Forge Swarm <swarm@forge.local> Date: Thu, 9 Jul 2026 09:38:37 +0200 Subject: [PATCH 19/20] fix: CodeQL findings (chunking ReDoS, SAML open-redirect bypass, ignore vuln fixtures) + helm-kind chart deps - chunking._HEADING_RE: greedy capture, strip ATX hashes in code (no lazy+trailing overlap) - saml._safe_next: reject backslash/CRLF/tab so /\\host can't become //host - codeql: paths-ignore tests/security/fixtures (deliberate planted-vuln samples) - helm-kind e2e: helm repo add bitnami + helm dependency build before install --- .github/workflows/codeql.yml | 6 ++++++ .github/workflows/helm-chart.yml | 9 +++++++++ apps/api/forge_api/routers/saml.py | 17 +++++++++++++++-- .../knowledge-core/forge_knowledge/chunking.py | 8 ++++++-- 4 files changed, 36 insertions(+), 4 deletions(-) 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/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/packages/knowledge-core/forge_knowledge/chunking.py b/packages/knowledge-core/forge_knowledge/chunking.py index 2e0586d4..6e31de1d 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]+(.*)$") _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)) From 7e36b870250345bd26d209ea212795e69ee5f0cc Mon Sep 17 00:00:00 2001 From: Forge Swarm <swarm@forge.local> Date: Thu, 9 Jul 2026 09:48:03 +0200 Subject: [PATCH 20/20] fix: disjoint-class regexes to actually clear CodeQL polynomial-redos MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [ \t]+(.+) still overlapped (. matches tab/space) → backtracking. Anchoring the capture at \S (non-whitespace) makes the separator and capture disjoint: verified linear (200k-tab input matches in ~2.6ms). --- apps/api/forge_api/services/spec_import_service.py | 4 ++-- packages/knowledge-core/forge_knowledge/chunking.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/api/forge_api/services/spec_import_service.py b/apps/api/forge_api/services/spec_import_service.py index 83d84e0b..97e09915 100644 --- a/apps/api/forge_api/services/spec_import_service.py +++ b/apps/api/forge_api/services/spec_import_service.py @@ -210,8 +210,8 @@ def _manifest_from_loose_yaml(data: dict[str, Any]) -> SpecManifest: # `\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]+(.+)$", re.MULTILINE) -_BULLET_RE = re.compile(r"^[ \t]*[-*][ \t]+(.+)$") +_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] = { diff --git a/packages/knowledge-core/forge_knowledge/chunking.py b/packages/knowledge-core/forge_knowledge/chunking.py index 6e31de1d..d5de3c6b 100644 --- a/packages/knowledge-core/forge_knowledge/chunking.py +++ b/packages/knowledge-core/forge_knowledge/chunking.py @@ -59,7 +59,7 @@ # 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]+(.*)$") +_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.