diff --git a/CHANGELOG.md b/CHANGELOG.md index bb5f6322..71dfcddc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,17 @@ All notable changes to the claude-plugins project will be documented in this fil The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Entries are listed newest-first; each plugin section is treated as released when merged to `main`. +### platform v1.1.6 + +#### Changed +- `upload_artifact.py` adds an `operationReceipt` field to its JSON output for every document it creates or versions, carrying the `clop_` operation id the server returned. The field sits ahead of `response`, and is omitted when the server returns no receipt, so the existing output keys are unchanged. +- The `upload-artifact` skill reports the operation id when the script output has one. + +### vibe v1.0.83 + +#### Changed +- `cl-policy`'s direct-API fallback prints the `clop_` operation id from each write response's `operationReceipt` on stderr in the same shell call, reading it from `data` or the body root. Stdout keeps the response or the caller's usual filter, and a response without a receipt prints nothing extra. + ### code v2.0.2 PreToolUse and SubagentStop each run one registered script, which runs that event's handler scripts and merges their results. diff --git a/plugins/platform/.claude-plugin/plugin.json b/plugins/platform/.claude-plugin/plugin.json index fbc6a72f..3df06672 100644 --- a/plugins/platform/.claude-plugin/plugin.json +++ b/plugins/platform/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "platform", "description": "ClosedLoop Platform plugin", - "version": "1.1.5", + "version": "1.1.6", "dependencies": ["closedloop-core"], "author": { "name": "ClosedLoop", diff --git a/plugins/platform/skills/upload-artifact/SKILL.md b/plugins/platform/skills/upload-artifact/SKILL.md index 260d5695..e9c9e4f0 100644 --- a/plugins/platform/skills/upload-artifact/SKILL.md +++ b/plugins/platform/skills/upload-artifact/SKILL.md @@ -97,6 +97,8 @@ Parse the JSON output and report to the user: - Document slug (e.g. `PLN-376`) and ID - Content length (characters) - Upload status +- Operation id (`operationReceipt.operationId`, a `clop_` id), only when the + output has an `operationReceipt`; the server omits it when receipts are off - Verification results (if `--verify` was used) --- diff --git a/plugins/platform/skills/upload-artifact/scripts/test_upload_artifact.py b/plugins/platform/skills/upload-artifact/scripts/test_upload_artifact.py new file mode 100644 index 00000000..57903afa --- /dev/null +++ b/plugins/platform/skills/upload-artifact/scripts/test_upload_artifact.py @@ -0,0 +1,177 @@ +"""Operation receipt output of upload_artifact.py (ISS-12714).""" + +from __future__ import annotations + +import asyncio +import json +import re +import sys +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from dataclasses import dataclass, field +from pathlib import Path +from typing import cast + +import pytest +from mcp import ClientSession + +import upload_artifact +from upload_artifact import _Args, _create_document, _version_document + +OPERATION_ID = "clop_01a14224-9b59-77ba-b518-16aaa9880991" +RECEIPT = {"operationId": OPERATION_ID, "outcome": "committed"} + +# The session-side shell-claim matcher's pattern, as written in symphony-alpha +# packages/lib/harness/tool-result-facts.ts (OPERATION_ID_RE). +SHELL_CLAIM_RE = re.compile( + r"clop_[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}" +) + +# The keys callers parsed before receipts existed, in their printed order. +CREATE_KEYS = [ + "mode", + "artifact_id", + "document_id", + "slug", + "content_length", + "status", + "response", +] + + +@dataclass +class _Text: + text: str + + +@dataclass +class _Result: + content: list[_Text] + is_error: bool = False + + +@dataclass +class _FakeSession: + body: dict + calls: list[tuple[str, dict]] = field(default_factory=list) + + async def call_tool(self, name: str, arguments: dict) -> _Result: + self.calls.append((name, arguments)) + return _Result(content=[_Text(json.dumps(self.body))]) + + +def _create_args(**overrides: str) -> _Args: + args = _Args() + args.title = "Plan" + args.type = "PRD" + args.project_id = None + args.workstream_id = None + for key, value in overrides.items(): + setattr(args, key, value) + return args + + +def _create(body: dict) -> dict: + session = cast(ClientSession, _FakeSession(body)) + return asyncio.run(_create_document(session, _create_args(), "hello")) + + +def _version(body: dict) -> dict: + session = cast(ClientSession, _FakeSession(body)) + return asyncio.run(_version_document(session, "PRD-7", "hello")) + + +def test_create_prints_the_receipt_from_the_mcp_body_root() -> None: + output = _create({"id": "d1", "slug": "PRD-7", "operationReceipt": RECEIPT}) + + assert output["operationReceipt"] == RECEIPT + assert SHELL_CLAIM_RE.findall(json.dumps(output, indent=2)) == [ + OPERATION_ID, + OPERATION_ID, + ] + + +def test_version_prints_the_receipt_from_the_mcp_body_root() -> None: + output = _version({"id": "v1", "slug": "PRD-7", "operationReceipt": RECEIPT}) + + assert output["operationReceipt"] == RECEIPT + assert output["mode"] == "version" + + +def test_receipt_inside_a_data_envelope_is_printed() -> None: + output = _create({"data": {"id": "d1", "slug": "PRD-7", "operationReceipt": RECEIPT}}) + + assert output["operationReceipt"] == RECEIPT + + +def test_receipt_precedes_the_echoed_response() -> None: + output = _create({"id": "d1", "slug": "PRD-7", "operationReceipt": RECEIPT}) + + keys = list(output) + assert keys.index("operationReceipt") < keys.index("response") + assert [key for key in keys if key != "operationReceipt"] == CREATE_KEYS + + +@pytest.mark.parametrize( + "body", + [ + {"id": "d1", "slug": "PRD-7"}, + {"id": "d1", "slug": "PRD-7", "operationReceipt": {"outcome": "committed"}}, + {"id": "d1", "slug": "PRD-7", "operationReceipt": "clop_not-an-object"}, + ], +) +def test_create_output_is_unchanged_without_a_receipt(body: dict) -> None: + output = _create(body) + + assert list(output) == CREATE_KEYS + assert output["document_id"] == "d1" + assert output["slug"] == "PRD-7" + assert output["status"] == "success" + + +def test_version_output_is_unchanged_without_a_receipt() -> None: + output = _version({"id": "v1", "slug": "PRD-7"}) + + assert list(output) == CREATE_KEYS + assert output["mode"] == "version" + assert output["artifact_id"] == "PRD-7" + + +def test_main_prints_one_parseable_json_document_with_the_id( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + content_file = tmp_path / "plan.md" + content_file.write_text("hello", encoding="utf-8") + fake = _FakeSession({"id": "d1", "slug": "PRD-7", "operationReceipt": RECEIPT}) + + @asynccontextmanager + async def fake_connect(_args: _Args) -> AsyncIterator[_FakeSession]: + yield fake + + monkeypatch.setattr(upload_artifact, "_connect", fake_connect) + monkeypatch.setattr( + sys, + "argv", + [ + "upload_artifact.py", + "--url", + "http://localhost:3010/mcp", + "--api-key", + "sk_live_test", + "--file", + str(content_file), + "--title", + "Plan", + "--type", + "PRD", + ], + ) + + upload_artifact.main() + + printed = capsys.readouterr().out + assert json.loads(printed)["operationReceipt"] == RECEIPT + assert OPERATION_ID in SHELL_CLAIM_RE.findall(printed) + assert fake.calls[0][0] == "create-document" diff --git a/plugins/platform/skills/upload-artifact/scripts/upload_artifact.py b/plugins/platform/skills/upload-artifact/scripts/upload_artifact.py index b44c0c68..a4d31e3d 100644 --- a/plugins/platform/skills/upload-artifact/scripts/upload_artifact.py +++ b/plugins/platform/skills/upload-artifact/scripts/upload_artifact.py @@ -139,6 +139,7 @@ async def _version_document( "artifact_id": document_id, "document_id": document_id, "slug": data.get("slug", parsed.get("slug") if isinstance(parsed, dict) else None), + **_read_operation_receipt(parsed), "content_length": len(content), "status": "success", "response": parsed, @@ -175,6 +176,7 @@ async def _create_document( "artifact_id": document_id, "document_id": document_id, "slug": data.get("slug", parsed.get("slug")), + **_read_operation_receipt(parsed), "content_length": len(content), "status": "success", "response": parsed, @@ -240,6 +242,25 @@ async def _verify_document( } +def _read_operation_receipt(parsed: object) -> dict: + """The write's operation receipt, or nothing when the response has none. + + ISS-12714: printed in the result so the session that ran this script names + the write it made (PRD-800). The MCP server puts the receipt on the body + root and the REST API inside ``data``. It sits ahead of ``response`` so a + transcript that keeps only the head of a long result still holds it. + """ + if not isinstance(parsed, dict): + return {} + receipt = parsed.get("operationReceipt") + if receipt is None: + data = parsed.get("data") + receipt = data.get("operationReceipt") if isinstance(data, dict) else None + if isinstance(receipt, dict) and isinstance(receipt.get("operationId"), str): + return {"operationReceipt": receipt} + return {} + + def _format_exception(exc: Exception | ExceptionGroup) -> dict: # type: ignore[type-arg] """Format an exception (including ExceptionGroups) for JSON output.""" import traceback diff --git a/plugins/vibe/.claude-plugin/plugin.json b/plugins/vibe/.claude-plugin/plugin.json index b42590d8..4a77eda8 100644 --- a/plugins/vibe/.claude-plugin/plugin.json +++ b/plugins/vibe/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "vibe", "description": "Vibe sessions, handoff, seed refresh, and ClosedLoop ticket workflows", - "version": "1.0.82", + "version": "1.0.83", "dependencies": ["closedloop-core"], "author": {"name": "ClosedLoop", "email": "support@closedloop.ai"} } diff --git a/plugins/vibe/.codex-plugin/plugin.json b/plugins/vibe/.codex-plugin/plugin.json index 49dfcd61..fae00b8d 100644 --- a/plugins/vibe/.codex-plugin/plugin.json +++ b/plugins/vibe/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "vibe", - "version": "1.0.82", + "version": "1.0.83", "description": "Vibe sessions, handoff, seed refresh, and ClosedLoop ticket workflows", "author": {"name": "ClosedLoop", "email": "support@closedloop.ai"}, "homepage": "https://github.com/closedloop-ai/claude-plugins", diff --git a/plugins/vibe/README.md b/plugins/vibe/README.md index 36861571..f8d255b5 100644 --- a/plugins/vibe/README.md +++ b/plugins/vibe/README.md @@ -523,7 +523,7 @@ harness switch instead of another worker doing its work. ## Runtime Files -Version `1.0.82` contains 12 skills, 16 agents, and one hook manifest +Version `1.0.83` contains 12 skills, 16 agents, and one hook manifest (`hooks/hooks.json`). It has no standalone commands, root-level shell scripts, or production Python tools under `tools/python/`; that directory contains the skill-contract test modules and diff --git a/plugins/vibe/skills/cl-policy/SKILL.md b/plugins/vibe/skills/cl-policy/SKILL.md index 36adf317..3d72c7f5 100644 --- a/plugins/vibe/skills/cl-policy/SKILL.md +++ b/plugins/vibe/skills/cl-policy/SKILL.md @@ -34,13 +34,33 @@ that ClosedLoop or its MCP server is unavailable. contracts from current repository routes or official current documentation instead of guessing. A ticket slug can be read through `GET https://api.closedloop.ai/documents/` in the current platform. -6. Treat an inactive localhost MCP URL, a missing initial registration, or a +6. When a fallback request writes (`POST`, `PUT`, `PATCH`, `DELETE`), print + the `clop_` operation id from the response's `operationReceipt` in the same + shell call, so the session that made the write is tied to it (PRD-800). + Keep the response in a variable, print it or your usual `jq` filter of it + on stdout as before, then print the id on stderr; stdout stays exactly what + it was. The receipt is optional: when the response has none, the second + command prints nothing and still succeeds. Keep the redirections in this + order. + + ```bash + response="$(curl -sS -X POST "https://api.closedloop.ai/" \ + -H "Authorization: Bearer $CLOSEDLOOP_API_KEY" \ + -H "Content-Type: application/json" \ + --data @)" + printf '%s\n' "$response" + printf '%s\n' "$response" \ + | jq -r '(.data? // .) | objects | .operationReceipt | objects | .operationId | strings' \ + >&2 2>/dev/null + ``` + +7. Treat an inactive localhost MCP URL, a missing initial registration, or a stale read-only intelligence index as an access-path issue, not proof of a ClosedLoop outage. Continue through the next valid fallback and state which source established live state. -7. Report a genuine access blocker only after applicable discovery, Codex CLI +8. Report a genuine access blocker only after applicable discovery, Codex CLI MCP, and authenticated API paths have been exhausted or are unavailable. -8. For ticket intelligence, discover the read-only `closedloop-graph` MCP by +9. For ticket intelligence, discover the read-only `closedloop-graph` MCP by capability rather than by configured server name, using only discovery mechanisms supported by the current host. Treat it as the preferred indexed discovery surface for bounded PRD/plan/ diff --git a/plugins/vibe/tools/python/test_cl_policy_operation_receipt_contract.py b/plugins/vibe/tools/python/test_cl_policy_operation_receipt_contract.py new file mode 100644 index 00000000..6be0bcef --- /dev/null +++ b/plugins/vibe/tools/python/test_cl_policy_operation_receipt_contract.py @@ -0,0 +1,90 @@ +"""The cl-policy direct-API write snippet prints the operation id (ISS-12714). + +Runs the documented commands verbatim against canned API responses, so the +test covers what an agent copies, not a restatement of it. +""" + +from __future__ import annotations + +import json +import os +import re +import subprocess +from pathlib import Path + +import pytest + +SKILL = Path(__file__).resolve().parents[2] / "skills" / "cl-policy" / "SKILL.md" +OPERATION_ID = "clop_01a14224-9b59-77ba-b518-16aaa9880991" +RECEIPT = {"operationId": OPERATION_ID, "outcome": "committed"} + +# The session-side shell-claim matcher's pattern, as written in symphony-alpha +# packages/lib/harness/tool-result-facts.ts (OPERATION_ID_RE). +SHELL_CLAIM_RE = re.compile( + r"clop_[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}" +) +BASH_BLOCK_RE = re.compile(r"```bash\n(.*?)```", re.DOTALL) + + +def _print_commands() -> str: + """The snippet after its curl assignment, which the test replaces.""" + blocks = [ + block for block in BASH_BLOCK_RE.findall(SKILL.read_text()) if "operationReceipt" in block + ] + assert len(blocks) == 1 + lines = [line.strip() for line in blocks[0].splitlines()] + end = next(i for i, line in enumerate(lines) if line.endswith(')"')) + assert lines[0].startswith('response="$(curl ') + return "\n".join(lines[end + 1 :]) + + +def _run(response: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + ["bash", "-c", _print_commands()], + capture_output=True, + text=True, + env={**os.environ, "response": response}, + check=False, + ) + + +@pytest.mark.parametrize( + "body", + [ + {"success": True, "data": {"id": "d1", "slug": "ISS-7", "operationReceipt": RECEIPT}}, + {"success": True, "data": {"deleted": True, "operationReceipt": RECEIPT}}, + {"id": "d1", "slug": "ISS-7", "operationReceipt": RECEIPT}, + ], +) +def test_write_response_receipt_id_is_printed_in_the_matcher_form(body: dict) -> None: + response = json.dumps(body, indent=2) + result = _run(response) + + assert result.returncode == 0 + assert result.stdout == response + "\n" + assert result.stderr == OPERATION_ID + "\n" + assert SHELL_CLAIM_RE.findall(result.stdout + result.stderr)[-1] == OPERATION_ID + + +@pytest.mark.parametrize( + "body", + [ + {"success": True, "data": {"id": "d1", "slug": "ISS-7"}}, + {"success": True, "data": [{"id": "d1"}]}, + {"success": True, "data": {"operationReceipt": {"outcome": "committed"}}}, + {"success": False, "error": "Forbidden"}, + ], +) +def test_response_without_a_receipt_prints_only_the_response(body: dict) -> None: + response = json.dumps(body) + result = _run(response) + + assert result.returncode == 0 + assert result.stdout == response + "\n" + assert result.stderr == "" + + +def test_release_and_both_manifests_are_1083() -> None: + plugin = SKILL.parents[2] + for manifest in (".claude-plugin/plugin.json", ".codex-plugin/plugin.json"): + assert json.loads((plugin / manifest).read_text())["version"] == "1.0.83" diff --git a/plugins/vibe/tools/python/test_vibe_1082_one_hook_per_event_contract.py b/plugins/vibe/tools/python/test_vibe_1082_one_hook_per_event_contract.py index 2cfa4900..103517ee 100644 --- a/plugins/vibe/tools/python/test_vibe_1082_one_hook_per_event_contract.py +++ b/plugins/vibe/tools/python/test_vibe_1082_one_hook_per_event_contract.py @@ -2,7 +2,6 @@ from __future__ import annotations -import json from pathlib import Path from vibe_contract_text import hook_guard_routed @@ -11,11 +10,6 @@ ROOT = Path(__file__).resolve().parents[2] -def test_release_and_both_manifests_are_1082() -> None: - for manifest in (".claude-plugin/plugin.json", ".codex-plugin/plugin.json"): - assert json.loads((ROOT / manifest).read_text())["version"] == "1.0.82" - - def test_every_guard_runs_from_its_events_one_script() -> None: for guard in ("kill-guard", "foreground-guard", "merge-guard", "shell-safety-guard"): assert hook_guard_routed(ROOT, "PreToolUse", guard, tool="Bash"), guard