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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ 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`.

### closedloop-core v1.5.2

#### Fixed
- `prd-graph-helper` resolves the newest usable `closedloop-core` installation from the Codex and Claude plugin caches each time it runs instead of pinning itself to the versioned cache path that created it. Source checkouts remain a version-aware fallback, so deleting an old plugin cache no longer leaves the launcher unable to start.
- A dependency-graph helper whose plugin cache is removed while it is running keeps refreshing with its renderer already loaded in memory and logs a one-time upgrade hint. Regression tests remove both a launcher's original cache and a live helper's cache before exercising their command and HTTP refresh surfaces.

### vibe v1.0.75

The open fleet lessons from the 2026-10-21 and 2026-10-22 orchestration runs, each with a mechanical check where one fits, and a watcher bug found while checking them.
Expand Down
2 changes: 1 addition & 1 deletion plugins/closedloop-core/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "closedloop-core",
"description": "Shared ClosedLoop planning, intelligence, review, QA, measurement, and PR monitoring",
"version": "1.5.1",
"version": "1.5.2",
"author": {"name": "ClosedLoop", "email": "support@closedloop.ai"}
}
2 changes: 1 addition & 1 deletion plugins/closedloop-core/.codex-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "closedloop-core",
"version": "1.5.1",
"version": "1.5.2",
"description": "Shared ClosedLoop planning, intelligence, review, QA, measurement, and PR monitoring",
"author": {"name": "ClosedLoop", "email": "support@closedloop.ai"},
"homepage": "https://github.com/closedloop-ai/claude-plugins",
Expand Down
6 changes: 3 additions & 3 deletions plugins/closedloop-core/skills/prd-dependency-graph/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,8 +84,8 @@ Your job is to pick the scope and options, run them, name the columns, and check

The graph's top-right corner has zoom controls, a gear (options), and a refresh icon. The gear and refresh icon need the local helper running.

- **Started for the user:** `--new` and `--once` start the helper in the background when it is not running, and replace a helper left running from another install of this skill (for example after a plugin update). `--ensure` does only that. The helper's log is `~/.local/share/prd-dependency-graph/helper.log`.
- **Starting it by hand** (for example after a reboot): `prd-graph-helper`. Every run writes that launcher to `~/.local/bin/prd-graph-helper`, pointing at the installed `scripts/refresh_server.py`, so the command survives plugin updates even though the installed path changes with each version. The page's "helper not running" dialog shows `prd-graph-helper`, or `~/.local/bin/prd-graph-helper` when that folder is not on PATH. A file already at that path that the skill did not write is left alone. If an older helper is running, the dialog says it is out of date and shows `prd-graph-helper --ensure`, which replaces it.
- **Started for the user:** `--new` and `--once` start the helper in the background when it is not running, and replace a helper left running from another install of this skill (for example after a plugin update). `--ensure` does only that. A running helper whose versioned plugin cache is removed keeps using its renderer already in memory and writes one upgrade hint to the log instead of failing refreshes. The helper's log is `~/.local/share/prd-dependency-graph/helper.log`.
- **Starting it by hand** (for example after a reboot): `prd-graph-helper`. Every run writes that launcher to `~/.local/bin/prd-graph-helper`. The launcher resolves the newest usable `closedloop-core` cache in Codex or Claude at execution time and falls back to the install that wrote it, so the command survives plugin updates even though installed paths change with each version. The page's "helper not running" dialog shows `prd-graph-helper`, or `~/.local/bin/prd-graph-helper` when that folder is not on PATH. A file already at that path that the skill did not write is left alone. If an older helper is running, the dialog says it is out of date and shows `prd-graph-helper --ensure`, which replaces it.
- **Where it listens:** `127.0.0.1:8765` only (the graph's `port`).
- **What a refresh does:**
- the page POSTs its graph id and scope to `/v2/refresh`, and, when it was opened from a file, the file's own path;
Expand All @@ -95,7 +95,7 @@ The graph's top-right corner has zoom controls, a gear (options), and a refresh
- **Pages that are not files** (for example a report served from `http://localhost`): the page sends its config instead of a path, the helper sends the new graph back, the page swaps it in place, and the dialog says only that view was updated and no file on disk changed. Pages on any other website cannot refresh.
- **Options (gear):** one checkbox, "Show the full chain of outside blockers". Flipping it fetches and redraws immediately (no separate refresh) and saves `outside_blockers_depth` (`"all"` or `1`) in the page. If the helper is not running, the checkbox flips back and a dialog explains. Add future options to this menu.
- **Dialogs:** after a refresh, a dialog lists what changed (status, owner, tickets or links added or removed) or says nothing changed. If the helper is not running, a dialog says so and shows the start command. Other failures also show in a dialog.
- **Styles and scripts** travel inside the refreshed block, so a page made by an older renderer picks up the new look after one refresh. The helper reloads `render_graph.py` on every refresh. Changes to `refresh_server.py` in the same install need a helper restart (stop it, then `--ensure`); a helper from a different install is replaced by the next `--new`, `--once` or `--ensure`.
- **Styles and scripts** travel inside the refreshed block, so a page made by an older renderer picks up the new look after one refresh. The helper reloads `render_graph.py` on every refresh while that install exists. If a plugin update removes the install underneath a running helper, it keeps using the loaded renderer until `prd-graph-helper --ensure` replaces it. Changes to `refresh_server.py` in the same install need a helper restart (stop it, then `--ensure`); a helper from a different install is replaced by the next `--new`, `--once` or `--ensure`.
- **Other commands:**
- `--once <page.html>` refreshes a page or report from the terminal without the server (`--graph <graph-id>` picks one graph in a report). `--once <graph-id>` refreshes an older graph from its config file.
- `--export-config <page.html>` prints the config a page carries.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@

prd-graph-helper

That launcher, in ~/.local/bin, is rewritten on every run to point at this
script, so it keeps working when the plugin updates. `--ensure` starts the
helper in the background and exits; its log is
That launcher, in ~/.local/bin, resolves the newest installed copy of this
script each time it runs, so it keeps working when a versioned plugin cache is
replaced. `--ensure` starts the helper in the background and exits; its log is
~/.local/share/prd-dependency-graph/helper.log.

Pages are portable. Each graph block carries its own config (scope, options,
Expand Down Expand Up @@ -85,6 +85,7 @@
LOOPBACK_HOSTS = {"localhost", "127.0.0.1", "::1"}
ME_TTL_SECONDS = 600
LOG_HINT = "~/.local/share/prd-dependency-graph/helper.log"
_RENDERER_SOURCE_WARNING_WRITTEN = False


def api_key():
Expand Down Expand Up @@ -471,13 +472,37 @@ def config_path(graph_id):
return DATA_DIR / f"{graph_id}.json"


def reload_renderer() -> None:
"""Reload the renderer when its install still exists, otherwise keep the loaded copy.

Plugin managers may remove a versioned cache directory while this helper is
still running. The module already in memory is complete, so losing its source
must not turn every subsequent page refresh into a 500 response.
"""
global _RENDERER_SOURCE_WARNING_WRITTEN
source_name = getattr(render_graph, "__file__", None)
source = Path(source_name) if isinstance(source_name, str) and source_name else None
if source is not None and source.is_file():
try:
importlib.reload(render_graph) # pick up renderer edits within the same install
return
except ModuleNotFoundError:
# The cache may have disappeared between the existence check and reload.
if source.is_file():
raise
if not _RENDERER_SOURCE_WARNING_WRITTEN:
sys.stderr.write("refresh-helper: the plugin install was removed; continuing with the loaded renderer. "
"Run prd-graph-helper --ensure to start the newest installed helper.\n")
_RENDERER_SOURCE_WARNING_WRITTEN = True


def refresh(graph_id, token=None, outside=None, gate=None) -> dict[str, Any]:
"""Refresh a graph that still has a config file (made before pages carried their own)."""
path = config_path(graph_id)
if not path.exists():
raise FileNotFoundError(f"no graph config at {path}")
cfg = json.loads(path.read_text(encoding="utf-8"))
importlib.reload(render_graph) # pick up renderer edits without restarting the helper
reload_renderer()
if token is not None and not hmac.compare_digest(str(cfg["refresh"].get("token", "")), token):
raise PermissionError("bad token")
if outside is not None:
Expand Down Expand Up @@ -650,7 +675,7 @@ def refresh_file(path, graph_id=None, scope=None, outside=None, replacement=None
real, text, info = read_target(path)
# Errors name the file as the request gave it: a symlink's target name is not the requester's to learn.
name = Path(path).name
importlib.reload(render_graph)
reload_renderer()
graph_id = choose_graph(text, graph_id, name)
embedded = render_graph.read_embedded(text, graph_id)
if scope is not None and (embedded is None or str((embedded.get("refresh") or {}).get("scope")) != scope):
Expand Down Expand Up @@ -978,11 +1003,65 @@ def main():
pass


def install_launcher():
"""Write ~/.local/bin/prd-graph-helper pointing at this script; rewritten when the script moves."""
def launcher_body() -> str:
"""Return a launcher that resolves the newest surviving plugin cache at execution time."""
try:
fallback_version = json.loads((SCRIPT.parents[3] / ".claude-plugin" / "plugin.json")
.read_text(encoding="utf-8")).get("version")
except (OSError, ValueError, AttributeError):
fallback_version = None
if not isinstance(fallback_version, str):
fallback_version = "0.0.0-dev"
return f'''#!/usr/bin/env python3
{LAUNCHER_MARK}: written by the prd-dependency-graph skill; resolves plugin updates at runtime.
import os
import re
import sys
from pathlib import Path

FALLBACK = {str(SCRIPT)!r}
FALLBACK_VERSION = {fallback_version!r}
PLUGIN_CACHE = Path("plugins/cache/closedloop-ai/closedloop-core")
HELPER = Path("skills/prd-dependency-graph/scripts/refresh_server.py")


def version_key(name):
match = re.fullmatch(r"(\\d+)\\.(\\d+)\\.(\\d+)(?:-([0-9A-Za-z.-]+))?", name)
if not match:
return (-1, -1, -1, 0, name)
prerelease = match.group(4)
return (*(int(match.group(i)) for i in range(1, 4)), 1 if prerelease is None else 0, prerelease or "")


roots = [
Path(os.environ.get("CODEX_HOME") or Path.home() / ".codex"),
Path(os.environ.get("CLAUDE_CONFIG_DIR") or Path.home() / ".claude"),
]
candidates = []
if Path(FALLBACK).is_file():
candidates.append((version_key(FALLBACK_VERSION), 1, FALLBACK))
for root in roots:
cache = root.expanduser() / PLUGIN_CACHE
try:
installs = list(cache.iterdir())
except OSError:
continue
for install in installs:
helper = install / HELPER
if helper.is_file():
candidates.append((version_key(install.name), 0, str(helper)))

if not candidates:
raise SystemExit("prd-graph-helper: no installed dependency-graph helper was found; run the skill once to repair it")
script = max(candidates)[2]
os.execv(sys.executable, [sys.executable, script, *sys.argv[1:]])
'''


def install_launcher() -> None:
"""Write ~/.local/bin/prd-graph-helper without pinning it to one cache version."""
launcher = render_graph.LAUNCHER
body = (f"#!/bin/sh\n{LAUNCHER_MARK}: written by the prd-dependency-graph skill, rewritten on every run.\n"
f"exec python3 {shlex.quote(str(SCRIPT))} \"$@\"\n")
body = launcher_body()
try:
if launcher.exists():
current = launcher.read_text(encoding="utf-8", errors="replace")
Expand Down
25 changes: 17 additions & 8 deletions plugins/closedloop-core/tools/python/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import sys
import threading
import urllib.request
from contextlib import contextmanager
from http.server import ThreadingHTTPServer
from pathlib import Path
from types import SimpleNamespace
Expand Down Expand Up @@ -109,7 +110,6 @@ def graph(tmp_path, monkeypatch, api):
server = importlib.import_module("refresh_server")
monkeypatch.setattr(server, "Api", api.client)
monkeypatch.setattr(server, "api_key", lambda: API_KEY)
monkeypatch.setattr(server, "install_launcher", lambda: None)
monkeypatch.setattr(server, "ensure_helper", lambda port: "helper not started in tests")
monkeypatch.setattr(server, "GATE", server.Gate())
monkeypatch.setattr(urllib.request, "urlopen", _no_network)
Expand All @@ -118,10 +118,10 @@ def graph(tmp_path, monkeypatch, api):
sys.modules.pop(name, None)


@pytest.fixture
def helper(graph):
"""A helper serving on a free loopback port in this process; yields a request function."""
httpd = ThreadingHTTPServer(("127.0.0.1", 0), graph.server.Handler)
@contextmanager
def serving(server):
"""Serve one loaded refresh-helper module on a free loopback port."""
httpd = ThreadingHTTPServer(("127.0.0.1", 0), server.Handler)
thread = threading.Thread(target=httpd.serve_forever, daemon=True)
thread.start()
port = httpd.server_address[1]
Expand Down Expand Up @@ -150,9 +150,18 @@ def request(method, path, body=None, origin="null", content_type="application/js
return SimpleNamespace(status=resp.status, headers={k.lower(): v for k, v in resp.getheaders()},
raw=payload, json=json.loads(payload) if payload else None)

yield request
httpd.shutdown()
httpd.server_close()
try:
yield request
finally:
httpd.shutdown()
httpd.server_close()


@pytest.fixture
def helper(graph):
"""A helper serving on a free loopback port in this process; yields a request function."""
with serving(graph.server) as request:
yield request


def new_graph(graph, scope, *, output=None, embed_into=None, graph_id=None, outside_depth="1"):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,10 @@
import shutil
import subprocess
import sys
from types import SimpleNamespace

import pytest
from conftest import SCRIPTS, embedded, new_graph, refresh_body
from conftest import API_KEY, FakeClosedLoop, SCRIPTS, embedded, new_graph, refresh_body, serving

LEGACY_PAGE = ('<!doctype html><html><body><div class="wrap"><h1>PRD-9 dependency graph</h1>'
'<div class="depgraph-block" data-refresh-url="http://127.0.0.1:8765/refresh?graph=prd-9&amp;'
Expand Down Expand Up @@ -229,3 +230,68 @@ def test_scripts_run_as_commands(graph, tmp_path):
assert render.returncode == 0, render.stderr
assert json.loads(render.stdout)["edges"] == 50
assert helper_help.returncode == 0 and "--export-config" in helper_help.stdout


def test_launcher_follows_plugin_update_after_old_cache_is_removed(graph, tmp_path, monkeypatch):
codex_home = tmp_path / "codex-home"
monkeypatch.setenv("CODEX_HOME", str(codex_home))

def cached_helper(version):
script = (codex_home / "plugins" / "cache" / "closedloop-ai" / "closedloop-core" / version
/ "skills" / "prd-dependency-graph" / "scripts" / "refresh_server.py")
script.parent.mkdir(parents=True)
script.write_text("import json, sys\nprint(json.dumps({'version': %r, 'args': sys.argv[1:]}))\n" % version,
encoding="utf-8")
return script

old_script = cached_helper("1.5.1")
graph.server.install_launcher()
new_script = cached_helper("1.5.3")
shutil.rmtree(old_script.parents[3])
launcher_env = dict(os.environ, PATH=os.path.dirname(sys.executable) + os.pathsep + os.environ["PATH"])

result = subprocess.run([str(graph.render.LAUNCHER), "--ensure"], capture_output=True, text=True, timeout=30,
env=launcher_env)

assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {"version": "1.5.3", "args": ["--ensure"]}
assert new_script.exists()

shutil.rmtree(new_script.parents[3])
fallback = subprocess.run([str(graph.render.LAUNCHER), "--help"], capture_output=True, text=True, timeout=30,
env=launcher_env)
assert fallback.returncode == 0, fallback.stderr
assert "--export-config" in fallback.stdout


def test_running_helper_refreshes_after_its_plugin_cache_is_removed(tmp_path, monkeypatch):
install = tmp_path / "cache" / "closedloop-core" / "1.5.1"
scripts = install / "skills" / "prd-dependency-graph" / "scripts"
shutil.copytree(SCRIPTS, scripts)
home = tmp_path / "home"
home.mkdir()
monkeypatch.setenv("HOME", str(home))
monkeypatch.syspath_prepend(str(scripts))
for name in ("render_graph", "refresh_server"):
sys.modules.pop(name, None)
server = __import__("refresh_server")
api = FakeClosedLoop()
monkeypatch.setattr(server, "Api", api.client)
monkeypatch.setattr(server, "api_key", lambda: API_KEY)
monkeypatch.setattr(server, "GATE", server.Gate())
page = tmp_path / "prd-9-dependency-graph.html"
server.create(SimpleNamespace(new="PRD-9", graph_id=None, output=str(page), embed_into=None, port=8765,
include_plans=False, outside_depth="1", me=None))

try:
with serving(server) as request:
shutil.rmtree(install)
api.tickets["ISS-3"] = ("Ship the page", "Ada", "IN_REVIEW")
response = request("POST", "/v2/refresh", refresh_body(page))

assert response.status == 200, response.json
config = server.render_graph.read_embedded(page.read_text(encoding="utf-8"), "prd-9")
assert next(node for node in config["nodes"] if node["id"] == "ISS-3")["status"] == "IN_REVIEW"
finally:
for name in ("render_graph", "refresh_server"):
sys.modules.pop(name, None)
Loading