- commit
- 0759278322b9007c57357e76f063b195c444cb03
- parent
- 406608f94cf52eb41e6b0af7610eef45bd0371c7
- author
- dami etoile <dami.etoile@gmail.com>
| M | __init__.py | 8 | +++- |
| M | dashboard/ | 20 | ++++++---- |
| A | openspec/ | 2 | + |
| A | openspec/ | 3 | ++ |
| A | openspec/ | 35 | ++++++++++++++++++ |
| A | openspec/ | 32 | ++++++++++++++++ |
| A | openspec/ | 29 | +++++++++++++++ |
| A | openspec/ | 24 | ++++++++++++ |
| A | openspec/ | 24 | ++++++++++++ |
| M | schemas.py | 2 | +- |
| A | tests/ | 60 | ++++++++++++++++++++++++++++++ |
| M | tools.py | 58 | ++++++++++++++++++++++++++++- |
@@ -2,8 +2,12 @@
22 33 from __future__ import annotations44 5-from . import schemas6-from . import tools5+try:6+ from . import schemas7+ from . import tools8+except ImportError: # pragma: no cover - pytest may import the repo root as a top-level module9+ import schemas # type: ignore10+ import tools # type: ignore711 812 _TOOLS = (913 ("openspec_list", schemas.OPENSPEC_LIST, tools.openspec_list, "📋"),@@ -351,6 +351,14 @@ def _source_payload(source: dict[str, Any]) -> dict[str, Any]:
351351 }352352 353353 354+def _ensure_openspec_layout(root: Path) -> None:355+ """Create every OpenSpec directory root used by this plugin."""356+ openspec_root = root / "openspec"357+ (openspec_root / "changes" / "archive").mkdir(parents=True, exist_ok=True)358+ (openspec_root / "specs").mkdir(parents=True, exist_ok=True)359+ (openspec_root / "ideas").mkdir(parents=True, exist_ok=True)360+361+354362 # ---------------------------------------------------------------------------355363 # Detail helpers356364 # ---------------------------------------------------------------------------@@ -803,14 +811,10 @@ def init_source(source_id: str):
803811 raise HTTPException(status_code=500, detail=f"openspec init failed: {detail}")804812 except subprocess.TimeoutExpired:805813 raise HTTPException(status_code=504, detail="openspec init timed out")806- else:807- # Fallback: create the minimal directory structure.808- try:809- (root / "openspec" / "changes").mkdir(parents=True, exist_ok=True)810- (root / "openspec" / "specs").mkdir(parents=True, exist_ok=True)811- (root / "openspec" / "ideas").mkdir(parents=True, exist_ok=True)812- except OSError as exc:813- raise HTTPException(status_code=500, detail=f"Failed to create openspec/ structure: {exc}")814+ try:815+ _ensure_openspec_layout(root)816+ except OSError as exc:817+ raise HTTPException(status_code=500, detail=f"Failed to create openspec/ structure: {exc}")814818 return {"ok": True, "message": "OpenSpec initialized", "source": _source_payload(source)}815819 816820 @@ -0,0 +1,35 @@
1+## Context2+3+The plugin wraps the upstream OpenSpec CLI, but the CLI currently has two behaviors that are awkward for the Hermes plugin UX:4+5+- `openspec init --tools none` does not create every directory the plugin supports (`ideas/` in particular).6+- `openspec instructions` requires at least one change even when the requested artifact guidance can be derived from schema templates.7+8+The plugin should not fork OpenSpec behavior, but it can normalize its own integration boundaries so dashboard and agent workflows are predictable.9+10+## Goals / Non-Goals11+12+**Goals:**13+14+- Make dashboard-initialized repos immediately compatible with plugin scans and normal OpenSpec list commands.15+- Keep fallback initialization and CLI-backed initialization producing the same directory layout.16+- Let agents request spec-writing guidance in a fresh repository without first creating a dummy change.17+- Preserve raw CLI command behavior wherever the CLI successfully returns useful output.18+19+**Non-Goals:**20+21+- Change the upstream OpenSpec CLI.22+- Add a new agent tool for initialization.23+- Invent a full replacement for `openspec instructions`.24+25+## Decisions26+27+- Add a local `_ensure_openspec_layout(root)` helper in the dashboard API and call it after both CLI init and fallback init.28+- Keep `.gitkeep` files out of runtime init paths; create directories only. Repo-local `.gitkeep` files are only for versioning empty directories in this plugin repository.29+- Normalize only the high-confidence artifact alias `spec -> specs`.30+- If `openspec instructions` fails because no changes exist, call `openspec templates --json`, read the requested artifact template, and return it as structured fallback stdout while preserving the original reason.31+32+## Risks / Trade-offs33+34+- Template fallback is less rich than CLI enriched instructions tied to a concrete change. The response explicitly marks `fallback: template` so callers know the difference.35+- Fallback depends on the OpenSpec CLI's `templates --json` output. If that output changes, the regression test should catch the break.@@ -0,0 +1,32 @@
1+## Why2+3+Fresh OpenSpec repositories exposed three maintainer-visible workflow problems:4+5+1. Before initialization, OpenSpec list operations fail with `No OpenSpec changes directory found`; the plugin should make dashboard initialization create a complete, CLI-listable OpenSpec layout.6+2. `openspec init --tools none` creates `changes/` and `specs/` but not the plugin-supported `ideas/` directory, and may omit `changes/archive/` in fallback paths; the dashboard should normalize the layout after either CLI init or fallback init.7+3. `openspec_instructions` can fail in freshly initialized repositories because the OpenSpec CLI requires an existing change, and the tool description advertised `spec` even though the CLI artifact is `specs`; the agent tool should accept the natural alias and return useful template guidance when no change exists.8+9+## What Changes10+11+- Add layout normalization after dashboard source initialization so initialized sources always contain `openspec/changes/`, `openspec/changes/archive/`, `openspec/specs/`, and `openspec/ideas/`.12+- Add fallback behavior to `openspec_instructions` for no-change repositories by returning the package template for the requested artifact.13+- Normalize `artifact="spec"` to `artifact="specs"` for the tool and schema description.14+- Add regression tests for the initialization layout and fresh-repo instruction fallback.15+- Make the plugin package importable under pytest from the repository root so regression tests can run locally.16+17+## Capabilities18+19+### New Capabilities20+- None21+22+### Modified Capabilities23+- `dashboard-sources`: source initialization must normalize all plugin-supported OpenSpec directories.24+- `agent-tools`: instructions lookup must tolerate fresh repositories and the `spec` alias.25+26+## Impact27+28+- `dashboard/plugin_api.py`29+- `tools.py`30+- `schemas.py`31+- `__init__.py`32+- `tests/test_openspec_workflow_fixes.py`@@ -0,0 +1,29 @@
1+## MODIFIED Requirements2+3+### Requirement: Artifact listing and inspection4+The plugin SHALL provide tools that map directly to OpenSpec list/show/status/instructions workflows while smoothing known CLI rough edges that would otherwise block fresh-repository agent workflows.5+6+#### Scenario: List changes or specs7+- **WHEN** an agent calls `openspec_list` with kind `changes` or `specs`8+- **THEN** the tool invokes the OpenSpec CLI JSON list command for that kind and respects supported sort options9+10+#### Scenario: Show a change or spec11+- **WHEN** an agent calls `openspec_show` with a target name and optional flags12+- **THEN** the tool invokes the OpenSpec CLI JSON show command with the requested type, delta, requirements, scenario, or requirement filters13+14+#### Scenario: Validate artifacts15+- **WHEN** an agent calls `openspec_validate`16+- **THEN** the tool invokes the OpenSpec CLI validation command with strict JSON non-interactive output and the requested target or scope17+18+#### Scenario: Retrieve implementation instructions19+- **WHEN** an agent calls `openspec_instructions`20+- **THEN** the tool returns OpenSpec's enriched instructions for the requested artifact, schema, and optional change21+22+#### Scenario: Accept singular spec artifact alias23+- **WHEN** an agent calls `openspec_instructions` with artifact `spec`24+- **THEN** the tool treats it as the OpenSpec CLI artifact `specs`25+26+#### Scenario: Fresh repository instructions fallback27+- **GIVEN** a repository has been initialized but contains no changes28+- **WHEN** an agent calls `openspec_instructions` for a known artifact29+- **THEN** the tool returns `ok: true` with template-backed guidance for that artifact instead of failing only because no change exists@@ -0,0 +1,24 @@
1+## MODIFIED Requirements2+3+### Requirement: Source initialization4+The dashboard API SHALL initialize a registered source that exists but lacks an `openspec/` directory and SHALL normalize the resulting OpenSpec layout to include all directory roots used by the plugin.5+6+#### Scenario: OpenSpec CLI available7+- **GIVEN** the OpenSpec CLI binary is available8+- **WHEN** the dashboard initializes an uninitialized source9+- **THEN** the API runs `openspec init <repo-root> --tools none`, ensures `openspec/changes`, `openspec/changes/archive`, `openspec/specs`, and `openspec/ideas` all exist, and returns the refreshed source payload10+11+#### Scenario: OpenSpec CLI unavailable12+- **GIVEN** the OpenSpec CLI binary is unavailable13+- **WHEN** the dashboard initializes an uninitialized source14+- **THEN** the API creates `openspec/changes`, `openspec/changes/archive`, `openspec/specs`, and `openspec/ideas` and returns the refreshed source payload15+16+#### Scenario: Already initialized source17+- **GIVEN** the source already has an `openspec/` directory18+- **WHEN** the dashboard initializes the source19+- **THEN** the API returns success with an `Already initialized` message and does not modify existing artifacts20+21+#### Scenario: CLI-listable initialized source22+- **GIVEN** a source has been initialized through the dashboard API23+- **WHEN** an agent or maintainer runs OpenSpec list commands in that source24+- **THEN** the required OpenSpec directories exist so list operations are not blocked by a missing `changes` root@@ -0,0 +1,24 @@
1+## 1. Specs2+3+- [x] 1.1 Create an OpenSpec change for initialization/instructions fixes4+- [x] 1.2 Add requirement deltas for dashboard source initialization5+- [x] 1.3 Add requirement deltas for agent instruction fallback6+7+## 2. Tests8+9+- [x] 2.1 Add regression test for `artifact=spec` instructions in a fresh initialized repo10+- [x] 2.2 Add regression test for canonical dashboard initialization directories11+- [x] 2.3 Add regression test for fallback initialization layout without OpenSpec CLI12+13+## 3. Implementation14+15+- [x] 3.1 Normalize source initialization layout after CLI and fallback init16+- [x] 3.2 Add `openspec_instructions` fresh-repo template fallback17+- [x] 3.3 Normalize `spec` artifact alias to `specs`18+- [x] 3.4 Keep repo-root pytest collection/import working19+20+## 4. Validation21+22+- [x] 4.1 Run targeted pytest regression tests23+- [x] 4.2 Run OpenSpec strict validation24+- [x] 4.3 Check git status and report remaining issues@@ -154,7 +154,7 @@ OPENSPEC_INSTRUCTIONS = {
154154 "workdir": WORKDIR_PROP,155155 "artifact": {156156 "type": "string",157- "description": "Artifact/instruction name, e.g. proposal, design, tasks, spec, apply.",157+ "description": "Artifact/instruction name, e.g. proposal, design, tasks, specs, apply. 'spec' is accepted as an alias for 'specs'.",158158 },159159 "change": {"type": "string", "description": "Optional change id/name."},160160 "schema": {"type": "string", "description": "Optional schema override."},@@ -0,0 +1,60 @@
1+import json2+import os3+import shutil4+import subprocess5+from pathlib import Path6+7+import pytest8+9+import tools10+from dashboard import plugin_api11+12+13+def test_openspec_instructions_accepts_spec_alias_without_changes(tmp_path):14+ exe = tools._openspec_bin()15+ if not exe:16+ pytest.skip("openspec CLI is not available")17+18+ subprocess.run([exe, "init", "--tools", "none", str(tmp_path)], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)19+20+ result = json.loads(tools.openspec_instructions({"workdir": str(tmp_path), "artifact": "spec"}))21+22+ assert result["ok"] is True23+ assert result["stdout"]["artifact"] == "specs"24+ assert result["stdout"]["fallback"] == "template"25+ assert "## ADDED Requirements" in result["stdout"]["content"]26+ assert "No changes found" in result["stdout"]["reason"]27+28+29+def test_dashboard_init_layout_ensures_plugin_supported_directories(tmp_path):30+ openspec_root = tmp_path / "openspec"31+ (openspec_root / "changes").mkdir(parents=True)32+ (openspec_root / "specs").mkdir()33+34+ plugin_api._ensure_openspec_layout(tmp_path)35+36+ assert (openspec_root / "changes").is_dir()37+ assert (openspec_root / "changes" / "archive").is_dir()38+ assert (openspec_root / "specs").is_dir()39+ assert (openspec_root / "ideas").is_dir()40+41+42+def test_dashboard_init_fallback_layout_matches_supported_scan_roots(tmp_path, monkeypatch):43+ monkeypatch.setattr(plugin_api, "_find_openspec_bin", lambda: None)44+ source = {"token": "os_test", "id": "os_test", "name": "demo", "path": str(tmp_path), "created_at": 0}45+46+ class FakeRegistry:47+ def get_source(self, source_id):48+ return source if source_id == "os_test" else None49+50+ monkeypatch.setattr(plugin_api, "_registry", FakeRegistry())51+52+ response = plugin_api.init_source("os_test")53+54+ assert response["ok"] is True55+ openspec_root = tmp_path / "openspec"56+ assert (openspec_root / "changes").is_dir()57+ assert (openspec_root / "changes" / "archive").is_dir()58+ assert (openspec_root / "specs").is_dir()59+ assert (openspec_root / "ideas").is_dir()60+ assert response["source"]["valid"] is True@@ -12,6 +12,8 @@ from typing import Any
1212 1313 MAX_OUTPUT_CHARS = 50_0001414 15+_ARTIFACT_ALIASES = {"spec": "specs"}16+1517 1618 def _openspec_bin() -> str | None:1719 configured = os.getenv("OPENSPEC_BIN", "").strip()@@ -48,6 +50,11 @@ def _json_or_text(text: str) -> Any:
4850 return stripped4951 5052 53+def _normalize_artifact(value: str) -> str:54+ artifact = (value or "").strip()55+ return _ARTIFACT_ALIASES.get(artifact, artifact)56+57+5158 def _run(args: list[str], workdir: Any = None) -> str:5259 exe = _openspec_bin()5360 if not exe:@@ -104,6 +111,46 @@ def _run(args: list[str], workdir: Any = None) -> str:
104111 })105112 106113 114+def _template_instruction_fallback(artifact: str, reason: str, workdir: Any = None, schema: str = "") -> str | None:115+ """Return template-backed instructions when OpenSpec needs a change first."""116+ if "No changes found" not in reason:117+ return None118+ artifact = _normalize_artifact(artifact)119+ if not artifact:120+ return None121+122+ templates_args = ["templates", "--json"]123+ if schema:124+ templates_args.extend(["--schema", schema])125+ templates_result = json.loads(_run(templates_args, workdir))126+ if not templates_result.get("ok") or not isinstance(templates_result.get("stdout"), dict):127+ return None128+129+ entry = templates_result["stdout"].get(artifact)130+ if not isinstance(entry, dict):131+ return None132+ path = Path(str(entry.get("path") or "")).expanduser()133+ if not path.is_file():134+ return None135+ try:136+ content = path.read_text(encoding="utf-8", errors="replace")137+ except OSError:138+ return None139+140+ fallback = dict(templates_result)141+ fallback["ok"] = True142+ fallback["stdout"] = {143+ "artifact": artifact,144+ "fallback": "template",145+ "reason": reason,146+ "templatePath": str(path),147+ "source": entry.get("source"),148+ "content": content,149+ }150+ fallback["stderr"] = ""151+ return json.dumps(fallback)152+153+107154 def _registry_module():108155 """Import the plugin-local OpenSpec registry (``plugins.openspec.registry``).109156 @@ -338,7 +385,7 @@ def openspec_status(args: dict, **kwargs) -> str:
338385 339386 def openspec_instructions(args: dict, **kwargs) -> str:340387 cmd = ["instructions", "--json"]341- artifact = str(args.get("artifact") or "").strip()388+ artifact = _normalize_artifact(str(args.get("artifact") or ""))342389 if artifact:343390 cmd.append(artifact)344391 change = str(args.get("change") or "").strip()@@ -347,4 +394,11 @@ def openspec_instructions(args: dict, **kwargs) -> str:
347394 schema = str(args.get("schema") or "").strip()348395 if schema:349396 cmd.extend(["--schema", schema])350- return _run(cmd, args.get("workdir"))397+398+ result = _run(cmd, args.get("workdir"))399+ parsed = json.loads(result)400+ if parsed.get("ok"):401+ return result402+ reason = "\n".join(str(parsed.get(key) or "") for key in ("stderr", "stdout", "error"))403+ fallback = _template_instruction_fallback(artifact, reason, args.get("workdir"), schema)404+ return fallback or result