Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion plugins/platform/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "platform",
"description": "ClosedLoop Platform plugin",
"version": "1.1.5",
"version": "1.1.6",
"dependencies": ["closedloop-core"],
"author": {
"name": "ClosedLoop",
Expand Down
2 changes: 2 additions & 0 deletions plugins/platform/skills/upload-artifact/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

---
Expand Down
Original file line number Diff line number Diff line change
@@ -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"
21 changes: 21 additions & 0 deletions plugins/platform/skills/upload-artifact/scripts/upload_artifact.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion plugins/vibe/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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"}
}
2 changes: 1 addition & 1 deletion plugins/vibe/.codex-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion plugins/vibe/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 23 additions & 3 deletions plugins/vibe/skills/cl-policy/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<slug>` 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/<route>" \
-H "Authorization: Bearer $CLOSEDLOOP_API_KEY" \
-H "Content-Type: application/json" \
--data @<payload-file>)"
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/
Expand Down
Loading
Loading