From 26cab2fe40c66f69c883e7d16b15a34cfd6631b7 Mon Sep 17 00:00:00 2001 From: Daniel Ochoa Date: Mon, 26 Oct 2026 14:41:24 -0500 Subject: [PATCH] fix(core): draw links between drawn graph tickets - fetch_graph now reads the BLOCKS links of every drawn ticket the walk did not already read, and draws each link whose two ends are both on the graph. A ticket the scope blocks that also blocks another such ticket (ISS-12711 and ISS-12712 blocking ISS-12713 on PRD-800) now gets its arrow and appears in hover and pinned chains. - Links already read during the walk are reused, so each drawn ticket is read once. No ticket is added to the graph. - New pages, --once, the refresh button and the outside blockers option all build links through fetch_graph, so a refresh keeps them. - Tests cover the A blocks X, Y, Z and Y blocks Z shape at depths 1, all and 0, a page refresh, and the option toggle. The launcher test no longer hardcodes the next plugin version. - Bump closedloop-core to 1.5.3 and add a changelog entry. Testing: ruff, pyright, pytest plugins/, node --test over plugins, regenerated PRD-800 against the live API and ran check_graph.cjs on it Risks: one extra paged links call per downstream ticket and, at depth 1, per outside blocker --- CHANGELOG.md | 5 + .../.claude-plugin/plugin.json | 2 +- .../closedloop-core/.codex-plugin/plugin.json | 2 +- .../skills/prd-dependency-graph/SKILL.md | 1 + .../scripts/refresh_server.py | 44 ++++--- .../closedloop-core/tools/python/conftest.py | 3 + .../python/test_prd_dependency_graph_links.py | 111 ++++++++++++++++++ .../test_prd_dependency_graph_portable.py | 9 +- 8 files changed, 158 insertions(+), 19 deletions(-) create mode 100644 plugins/closedloop-core/tools/python/test_prd_dependency_graph_links.py diff --git a/CHANGELOG.md b/CHANGELOG.md index a9f2e3ba..de64f0af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ 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.3 + +#### Fixed +- `prd-dependency-graph` draws every BLOCKS link between two tickets the graph shows, not only links that touch an in-scope ticket or lead into an outside blocker. A ticket the scope blocks that also blocks another such ticket now gets that arrow, so hovering or pinning a ticket shows all of its drawn blockers. With one layer of outside blockers, links between those blockers and from them to downstream tickets are drawn too. New pages, `--once`, the refresh button and the outside-blockers option all build links the same way. The links of each drawn ticket not already read during the walk are read once, and no ticket is added to the graph. + ### vibe v1.0.84 A mid-run scope change or answer sent to a Claude writer is now acknowledged by id and watched, so a writer stuck in one long turn is caught within the hour instead of when the operator asks (ISS-13096). diff --git a/plugins/closedloop-core/.claude-plugin/plugin.json b/plugins/closedloop-core/.claude-plugin/plugin.json index 22ae7553..25b79478 100644 --- a/plugins/closedloop-core/.claude-plugin/plugin.json +++ b/plugins/closedloop-core/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "closedloop-core", "description": "Shared ClosedLoop planning, intelligence, review, QA, measurement, and PR monitoring", - "version": "1.5.2", + "version": "1.5.3", "author": {"name": "ClosedLoop", "email": "support@closedloop.ai"} } diff --git a/plugins/closedloop-core/.codex-plugin/plugin.json b/plugins/closedloop-core/.codex-plugin/plugin.json index daae80d1..91f2f1d7 100644 --- a/plugins/closedloop-core/.codex-plugin/plugin.json +++ b/plugins/closedloop-core/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "closedloop-core", - "version": "1.5.2", + "version": "1.5.3", "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", diff --git a/plugins/closedloop-core/skills/prd-dependency-graph/SKILL.md b/plugins/closedloop-core/skills/prd-dependency-graph/SKILL.md index 492d917c..a2d72b2f 100644 --- a/plugins/closedloop-core/skills/prd-dependency-graph/SKILL.md +++ b/plugins/closedloop-core/skills/prd-dependency-graph/SKILL.md @@ -33,6 +33,7 @@ Your job is to pick the scope and options, run them, name the columns, and check - **Tickets outside the scope** are drawn dashed, and the legend reads "outside ": - tickets that in-scope tickets block are shown one layer deep; - tickets that block the scope are followed upstream `outside_blockers_depth` layers (default 1; `"all"` follows the chain to its roots). +- **Links between tickets on the graph:** once the tickets above are chosen, every BLOCKS link between two of them is drawn, wherever they sit. A ticket that the scope blocks and that also blocks another such ticket gets that arrow, and it is part of both tickets' hover and pinned chains. These links never add a ticket to the graph. ## Steps diff --git a/plugins/closedloop-core/skills/prd-dependency-graph/scripts/refresh_server.py b/plugins/closedloop-core/skills/prd-dependency-graph/scripts/refresh_server.py index f2abe951..691ef771 100644 --- a/plugins/closedloop-core/skills/prd-dependency-graph/scripts/refresh_server.py +++ b/plugins/closedloop-core/skills/prd-dependency-graph/scripts/refresh_server.py @@ -246,6 +246,10 @@ def fetch_graph(cfg, api): Outside tickets that an in-scope ticket blocks are shown one layer deep. Outside blockers are followed upstream `outside_blockers_depth` layers (default 1; "all" follows the chain to its roots). + + Once the drawn tickets are known, every BLOCKS link between two of them is drawn too, wherever + they sit (for example one downstream ticket blocking another). That pass reads the links of each + drawn ticket not already read during the walk, once, and adds links only, never tickets. """ source = cfg["refresh"] app_base = source.get("app_issue_base", "") @@ -255,40 +259,52 @@ def fetch_graph(cfg, api): edges = set() frontier = list(tickets) # level 0 is the scope; level n is n outside blockers up level = 0 - fetched = set() + links_of: dict[str, list] = {} # every BLOCKS list read, reused by the closing pass + + def endpoints(link): + return (link.get("source") or {}).get("slug"), (link.get("target") or {}).get("slug") + with ThreadPoolExecutor(max_workers=8) as pool: + def read_links(slugs): + links_of.update(pool.map(lambda slug: (slug, api.links(slug, "BLOCKS", "both")), slugs)) + while frontier: - fetched.update(frontier) - results = list(pool.map(lambda slug: (slug, api.links(slug, "BLOCKS", "both")), frontier)) + read_links(frontier) found = set() - for queried, link_list in results: + for queried in frontier: in_scope = queried in tickets - for link in link_list: - src, dst = link.get("source") or {}, link.get("target") or {} - s, d = src.get("slug"), dst.get("slug") + for link in links_of[queried]: + s, d = endpoints(link) if not s or not d: continue if not in_scope and d != queried: continue # from an outside ticket, only follow what blocks it if s not in tickets and s not in nodes and limit == 0: continue # outside blockers turned off - for end, slug in ((src, s), (dst, d)): + for end, slug in ((link.get("source") or {}, s), (link.get("target") or {}, d)): if slug not in nodes: node = endpoint_node(end, app_base) node["external"] = True nodes[slug] = node edges.add((s, d)) - if s not in tickets and s not in fetched: + if s not in tickets and s not in links_of: found.add(s) level += 1 if limit is not None and level >= limit: break frontier = sorted(found) - produces = set() - root = scope_root(api, source, app_base, tickets) - if root and root["id"] not in nodes: - nodes[root["id"]] = root - produces = {(root["id"], t) for t in tickets} + produces = set() + root = scope_root(api, source, app_base, tickets) + if root and root["id"] not in nodes: + nodes[root["id"]] = root + produces = {(root["id"], t) for t in tickets} + # A link touching the root is also in the list of the ticket at its other end. + read_links(sorted(slug for slug in nodes if slug not in links_of and not nodes[slug].get("root"))) + for link_list in links_of.values(): + for link in link_list: + s, d = endpoints(link) + if s in nodes and d in nodes: + edges.add((s, d)) return nodes, edges, produces diff --git a/plugins/closedloop-core/tools/python/conftest.py b/plugins/closedloop-core/tools/python/conftest.py index 10fe9d1e..0dd360cd 100644 --- a/plugins/closedloop-core/tools/python/conftest.py +++ b/plugins/closedloop-core/tools/python/conftest.py @@ -41,6 +41,7 @@ def __init__(self): self.projects = {"PRO-5": ["ISS-50", "ISS-51"]} self.blocks = [("ISS-1", "ISS-2"), ("ISS-2", "ISS-3"), ("ISS-90", "ISS-3"), ("ISS-50", "ISS-51")] self.calls = 0 + self.link_queries: list[str] = [] self.bases: list[str] = [] self.keys: list[str] = [] @@ -81,6 +82,8 @@ def get(self, path, params): def links(self, artifact, link_type, direction): self.world.calls += 1 + if link_type == "BLOCKS": + self.world.link_queries.append(artifact) if link_type == "PRODUCES": return [{"source": {"slug": artifact}, "target": self.world.end(s)} for s in self.world.children.get(artifact, [])] diff --git a/plugins/closedloop-core/tools/python/test_prd_dependency_graph_links.py b/plugins/closedloop-core/tools/python/test_prd_dependency_graph_links.py new file mode 100644 index 00000000..165f016b --- /dev/null +++ b/plugins/closedloop-core/tools/python/test_prd_dependency_graph_links.py @@ -0,0 +1,111 @@ +"""Every BLOCKS link between two tickets the graph draws is drawn, wherever those tickets sit. + +PRD-7 has one ticket, A (ISS-10). A blocks X, Y and Z (ISS-11, ISS-12, ISS-13), which are outside +the scope, and Y blocks Z. B1 and B2 (ISS-20, ISS-21) block A from outside; B1 also blocks B2 and Y. +W (ISS-14) blocks Z and V (ISS-15) waits on Z; neither touches the scope, so neither is drawn. +""" + +import re + +import pytest +from conftest import embedded, new_graph, refresh_body + +SCOPE_EDGES = {("ISS-10", "ISS-11"), ("ISS-10", "ISS-12"), ("ISS-10", "ISS-13"), ("ISS-12", "ISS-13")} +BLOCKER_EDGES = {("ISS-20", "ISS-10"), ("ISS-21", "ISS-10"), ("ISS-20", "ISS-21"), ("ISS-20", "ISS-12")} +LEGEND = re.compile(r"Arrow: A blocks B\. (\d+) BLOCKS links, all drawn\.") + + +@pytest.fixture +def world(graph): + api = graph.api + for slug, name in (("ISS-10", "Scope ticket"), ("ISS-11", "Downstream X"), ("ISS-12", "Downstream Y"), + ("ISS-13", "Downstream Z"), ("ISS-14", "Unrelated W"), ("ISS-15", "After Z"), + ("ISS-20", "Blocker one"), ("ISS-21", "Blocker two")): + api.tickets[slug] = (name, "Lee", "TODO") + api.children["PRD-7"] = ["ISS-10"] + api.blocks.extend([("ISS-10", "ISS-11"), ("ISS-10", "ISS-12"), ("ISS-10", "ISS-13"), ("ISS-12", "ISS-13"), + ("ISS-14", "ISS-13"), ("ISS-13", "ISS-15"), + ("ISS-20", "ISS-10"), ("ISS-21", "ISS-10"), ("ISS-20", "ISS-21"), ("ISS-20", "ISS-12")]) + return graph + + +def edges_of(cfg): + return {(e["from"], e["to"]) for e in cfg["edges"]} + + +def upstream(edges, ticket): + """The chain a pinned ticket lights up in purple: everything that blocks it, transitively.""" + seen, todo = set(), [ticket] + while todo: + current = todo.pop() + for s, d in edges: + if d == current and s not in seen: + seen.add(s) + todo.append(s) + return seen + + +def legend_count(text): + match = LEGEND.search(text) + assert match, "the legend states the BLOCKS link count" + return int(match.group(1)) + + +def drawn_edges(text): + """BLOCKS arrows in the page; the page's hover chains are built from these paths.""" + return set(re.findall(r'