From edca7525e1e0cb2d868e0160f9d53facef7f4e4d Mon Sep 17 00:00:00 2001 From: GeneAI Date: Sat, 23 May 2026 11:38:13 -0400 Subject: [PATCH 1/2] fix(specs): _STATUS_VALUE_RE accepts both **Status**: and **Status:** MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The regex previously only matched `**Status**:` (colon outside asterisks). Real specs in this repo overwhelmingly use `**Status:**` (colon inside) — a quick grep showed ~7 of 8 status lines in that form. The dashboard's Status column and the MCP gui_get_spec_status tool were silently returning None for those specs. _STATUS_RE — old: r"^\s*\*\*Status\*\*:.*$" — new: r"^\s*\*\*Status(?:\*\*:|:\*\*).*$" _STATUS_VALUE_RE — old: r"\*\*Status\*\*:\s*(\S+)" — new: r"\*\*Status(?:\*\*:|:\*\*)\s*(\S+)" The alternation accepts either order of the closing asterisks and colon. Malformed look-alikes (`**Status:`, `**Status: foo` without closing asterisks, `**Statuss**: foo`, `## Status: foo`) still get correctly rejected. Tests: - test_cowork_specs.py: • test_status_regex_accepts_both_markdown_emphasis_styles (4 parametrized happy paths covering both formats, the dashed-phrase form, and indented lines) • test_status_regex_rejects_malformed_lines (4 parametrized negative cases) - test_mcp_tools.py: • test_get_spec_reads_colon_inside_status_format — covers the MCP path, not just the FastAPI route The PUT-status route's substitution still rewrites in the `**Status**:` form; that's a separate normalization choice and isn't changed here. Surfaced during PR #52's MCP Phase 2 work and flagged via a follow-up chip; this closes the chip's intent. Co-Authored-By: Claude Opus 4.7 --- CHANGELOG.md | 16 +++++++ sidecar/attune_gui/routes/cowork_specs.py | 11 ++++- sidecar/tests/test_cowork_specs.py | 58 +++++++++++++++++++++++ sidecar/tests/test_mcp_tools.py | 21 ++++++++ 4 files changed, 104 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a63c3c..b2823a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,22 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +### Fixed + +- **`**Status:**` (colon-inside-asterisks) format now parses + correctly.** `_STATUS_RE` and `_STATUS_VALUE_RE` in + `routes/cowork_specs.py` previously only matched the + colon-outside form (`**Status**:`). Real specs in this repo + overwhelmingly use `**Status:**` instead — a quick grep showed + ~7 of 8 status lines in that form. The dashboard's Status + column and the MCP `gui_get_spec_status` tool were silently + returning `None` for those specs. Loosened both regexes to + accept either emphasis style. Added 8 parametrized tests in + `test_cowork_specs.py` (4 happy paths, 4 malformed-line + rejections) and 1 MCP-path test to lock in both behaviors. + Surfaced during PR #52's MCP Phase 2 work; tracked via the + follow-up chip spawned at the time. + ### Added - **MCP server — Phase 5 docs.** New "## MCP integration" section diff --git a/sidecar/attune_gui/routes/cowork_specs.py b/sidecar/attune_gui/routes/cowork_specs.py index 1a68198..969c402 100644 --- a/sidecar/attune_gui/routes/cowork_specs.py +++ b/sidecar/attune_gui/routes/cowork_specs.py @@ -35,8 +35,15 @@ router = APIRouter(prefix="/api/cowork", tags=["cowork-specs"]) -_STATUS_RE = re.compile(r"^\s*\*\*Status\*\*:.*$", re.MULTILINE) -_STATUS_VALUE_RE = re.compile(r"\*\*Status\*\*:\s*(\S+)") +# Match both `**Status**:` (colon outside asterisks) and `**Status:**` +# (colon inside asterisks). Both are valid markdown for the same intent +# and both appear in the wild across specs in this repo — `**Status:**` +# is actually the more common form. The old regex only matched the +# colon-outside variant, silently returning ``None`` for ~7 of every 8 +# specs in the dashboard's Status column and in the MCP +# ``gui_get_spec_status`` tool. +_STATUS_RE = re.compile(r"^\s*\*\*Status(?:\*\*:|:\*\*).*$", re.MULTILINE) +_STATUS_VALUE_RE = re.compile(r"\*\*Status(?:\*\*:|:\*\*)\s*(\S+)") _PHASE_FILES = ("requirements.md", "design.md", "tasks.md") _PHASE_LABELS = { "requirements.md": "Requirements", diff --git a/sidecar/tests/test_cowork_specs.py b/sidecar/tests/test_cowork_specs.py index 25c62f9..6c3a5f1 100644 --- a/sidecar/tests/test_cowork_specs.py +++ b/sidecar/tests/test_cowork_specs.py @@ -114,6 +114,64 @@ def test_spec_with_no_phase_files_handled( assert s["status"] is None +@pytest.mark.parametrize( + ("source", "expected"), + [ + ("# Spec\n\n**Status**: approved\n", "approved"), # colon outside + ("# Spec\n\n**Status:** approved\n", "approved"), # colon inside (the common form) + ("# Spec\n\n**Status:** Closed — final\n", "Closed"), # dashed phrase, common in real specs + ("# Spec\n **Status:** pending\n", "pending"), # indented + ], +) +def test_status_regex_accepts_both_markdown_emphasis_styles( + client: TestClient, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + source: str, + expected: str, +) -> None: + """Pre-fix bug: _STATUS_VALUE_RE only matched ``**Status**:`` (colon + outside). Real specs in the repo overwhelmingly use ``**Status:**`` + (colon inside), which was silently returning ``None``. Both forms + must now parse to the same status value.""" + specs_root = tmp_path / "specs" + feat = specs_root / "alpha" + feat.mkdir(parents=True) + (feat / "requirements.md").write_text(source) + + monkeypatch.setattr(cowork_specs, "_specs_roots", lambda: [specs_root]) + + body = client.get("/api/cowork/specs", headers={"Origin": "http://localhost:5173"}).json() + alpha = next(s for s in body["specs"] if s["feature"] == "alpha") + assert alpha["status"] == expected + + +@pytest.mark.parametrize( + "bad_source", + [ + "# Spec\n\n**Status:\n", # missing trailing **: + "# Spec\n\n**Status: approved\n", # missing closing ** + "# Spec\n\n**Statuss**: approved\n", # double-s typo + "# Spec\n\n## Status: approved\n", # heading, not bold + ], +) +def test_status_regex_rejects_malformed_lines( + client: TestClient, monkeypatch: pytest.MonkeyPatch, tmp_path: Path, bad_source: str +) -> None: + """The looser regex must still reject lines that aren't valid + bold-Status declarations.""" + specs_root = tmp_path / "specs" + feat = specs_root / "alpha" + feat.mkdir(parents=True) + (feat / "requirements.md").write_text(bad_source) + + monkeypatch.setattr(cowork_specs, "_specs_roots", lambda: [specs_root]) + + body = client.get("/api/cowork/specs", headers={"Origin": "http://localhost:5173"}).json() + alpha = next(s for s in body["specs"] if s["feature"] == "alpha") + assert alpha["status"] is None + + # --------------------------------------------------------------------------- # _specs_root resolution # --------------------------------------------------------------------------- diff --git a/sidecar/tests/test_mcp_tools.py b/sidecar/tests/test_mcp_tools.py index 53d3149..b7baab5 100644 --- a/sidecar/tests/test_mcp_tools.py +++ b/sidecar/tests/test_mcp_tools.py @@ -112,6 +112,27 @@ async def test_get_spec_unknown_feature_errors(app, specs_root) -> None: assert "'nope' not found" in result["error"] +@pytest.mark.asyncio +async def test_get_spec_reads_colon_inside_status_format(app, tmp_path, monkeypatch) -> None: + """Confidence test for the loosened ``_STATUS_VALUE_RE`` — the common + ``**Status:**`` (colon inside asterisks) format must read correctly + through the MCP path, not just the FastAPI route.""" + root = tmp_path / "specs" + feat = root / "colon-inside" + feat.mkdir(parents=True) + # The format that real specs in this repo overwhelmingly use: + (feat / "requirements.md").write_text( + "# colon-inside\n\n**Status:** Closed — done\n", encoding="utf-8" + ) + monkeypatch.setenv("ATTUNE_SPECS_ROOT", str(root)) + + result = await app.call_tool( + "gui_get_spec_status", {"feature": "colon-inside", "phase": "requirements"} + ) + assert result["success"] is True + assert result["status"] == "Closed" + + # --------------------------------------------------------------------------- # gui_get_spec_status # --------------------------------------------------------------------------- From ac6b55241ec092f160e2d055f2c39d6ea6670d4e Mon Sep 17 00:00:00 2001 From: GeneAI Date: Sat, 23 May 2026 11:38:41 -0400 Subject: [PATCH 2/2] docs(living-docs): regenerate sidecar templates for status-regex fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit source_hash drift only — no narrative change. Co-Authored-By: Claude Opus 4.7 --- .help/templates/sidecar/concept.md | 4 ++-- .help/templates/sidecar/reference.md | 7 +++++-- .help/templates/sidecar/task.md | 4 ++-- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/.help/templates/sidecar/concept.md b/.help/templates/sidecar/concept.md index 6c6e3d7..2dbd36b 100644 --- a/.help/templates/sidecar/concept.md +++ b/.help/templates/sidecar/concept.md @@ -1,8 +1,8 @@ --- feature: sidecar depth: concept -generated_at: 2026-05-23T15:26:20.582683+00:00 -source_hash: 278c82dcb0488307aba1af37c2e2b49ddd2ba1b93775befbe9147bf96965936d +generated_at: 2026-05-23T15:38:15.234638+00:00 +source_hash: a1d71c4c13ab81ddb75e5a3c1d6096e69128d047613aaee8b3ccf4f65d356a74 status: generated --- diff --git a/.help/templates/sidecar/reference.md b/.help/templates/sidecar/reference.md index 412ffd7..6ccd44e 100644 --- a/.help/templates/sidecar/reference.md +++ b/.help/templates/sidecar/reference.md @@ -1,8 +1,8 @@ --- feature: sidecar depth: reference -generated_at: 2026-05-23T15:26:20.594051+00:00 -source_hash: 278c82dcb0488307aba1af37c2e2b49ddd2ba1b93775befbe9147bf96965936d +generated_at: 2026-05-23T15:38:15.246052+00:00 +source_hash: a1d71c4c13ab81ddb75e5a3c1d6096e69128d047613aaee8b3ccf4f65d356a74 status: generated --- @@ -271,6 +271,8 @@ status: generated | `test_specs_skips_dot_dirs()` | — | `sidecar/tests/test_cowork_specs.py` | | `test_specs_returns_empty_when_no_root()` | — | `sidecar/tests/test_cowork_specs.py` | | `test_spec_with_no_phase_files_handled()` | — | `sidecar/tests/test_cowork_specs.py` | +| `test_status_regex_accepts_both_markdown_emphasis_styles()` | Pre-fix bug: _STATUS_VALUE_RE only matched ``**Status**:`` (colon | `sidecar/tests/test_cowork_specs.py` | +| `test_status_regex_rejects_malformed_lines()` | The looser regex must still reject lines that aren't valid | `sidecar/tests/test_cowork_specs.py` | | `test_specs_root_env_var_wins()` | — | `sidecar/tests/test_cowork_specs.py` | | `test_specs_root_falls_back_to_workspace()` | — | `sidecar/tests/test_cowork_specs.py` | | `test_specs_root_falls_back_to_workspace_docs_specs()` | Workspaces that keep specs at ``docs/specs/`` (attune-rag, attune-ai layout) | `sidecar/tests/test_cowork_specs.py` | @@ -541,6 +543,7 @@ status: generated | `test_get_spec_returns_phase_contents()` | — | `sidecar/tests/test_mcp_tools.py` | | `test_get_spec_rejects_invalid_slug()` | — | `sidecar/tests/test_mcp_tools.py` | | `test_get_spec_unknown_feature_errors()` | — | `sidecar/tests/test_mcp_tools.py` | +| `test_get_spec_reads_colon_inside_status_format()` | Confidence test for the loosened ``_STATUS_VALUE_RE`` — the common | `sidecar/tests/test_mcp_tools.py` | | `test_get_spec_status_returns_most_advanced()` | — | `sidecar/tests/test_mcp_tools.py` | | `test_get_spec_status_explicit_phase()` | — | `sidecar/tests/test_mcp_tools.py` | | `test_get_spec_status_rejects_invalid_phase()` | — | `sidecar/tests/test_mcp_tools.py` | diff --git a/.help/templates/sidecar/task.md b/.help/templates/sidecar/task.md index 58c2a65..961ddb8 100644 --- a/.help/templates/sidecar/task.md +++ b/.help/templates/sidecar/task.md @@ -1,8 +1,8 @@ --- feature: sidecar depth: task -generated_at: 2026-05-23T15:26:20.589168+00:00 -source_hash: 278c82dcb0488307aba1af37c2e2b49ddd2ba1b93775befbe9147bf96965936d +generated_at: 2026-05-23T15:38:15.241623+00:00 +source_hash: a1d71c4c13ab81ddb75e5a3c1d6096e69128d047613aaee8b3ccf4f65d356a74 status: generated ---