@caddis/cli 0.4.72 → 0.4.74
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bundles/antigravity-plugin/plugin.json +1 -1
- package/bundles/antigravity-plugin/scripts/caddis_coordinate.py +123 -6
- package/bundles/antigravity-plugin/scripts/caddis_fleet.py +46 -1
- package/bundles/antigravity-plugin/scripts/caddis_workstreams.py +9 -0
- package/bundles/antigravity-plugin/scripts/templates/coordinator.md +5 -2
- package/bundles/antigravity-plugin/skills/_registry.md +1 -0
- package/bundles/antigravity-plugin/skills/coordinate/SKILL.md +29 -5
- package/bundles/antigravity-plugin/skills/flow-chart-from-counts/SKILL.md +193 -0
- package/bundles/antigravity-plugin/skills/flow-chart-from-counts/assets/viewer-template.html +190 -0
- package/bundles/antigravity-plugin/skills/flow-chart-from-counts/references/edge-list-sql.md +105 -0
- package/bundles/antigravity-plugin/skills/flow-chart-from-counts/scripts/check-render.mjs +139 -0
- package/bundles/antigravity-plugin/skills/flow-chart-from-counts/scripts/edges_to_mermaid.py +418 -0
- package/bundles/antigravity-plugin/skills/handoff/SKILL.md +37 -2
- package/bundles/codex/.codex/skills/media/flow-chart-from-counts/SKILL.md +193 -0
- package/bundles/codex/.codex/skills/media/flow-chart-from-counts/assets/viewer-template.html +190 -0
- package/bundles/codex/.codex/skills/media/flow-chart-from-counts/references/edge-list-sql.md +105 -0
- package/bundles/codex/.codex/skills/media/flow-chart-from-counts/scripts/check-render.mjs +139 -0
- package/bundles/codex/.codex/skills/media/flow-chart-from-counts/scripts/edges_to_mermaid.py +418 -0
- package/bundles/manifest.json +2 -2
- package/package.json +1 -1
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Turn a from,to,count edge list into a first-draft Mermaid flowchart.
|
|
3
|
+
|
|
4
|
+
The draft applies the count and layout rules of the flow-chart-from-counts skill:
|
|
5
|
+
|
|
6
|
+
- counts go in quoted brackets on arrows: |"(1,234)"|
|
|
7
|
+
- paths under --min are not drawn; the note names how many and how big they were
|
|
8
|
+
- the chart is drawn as a TREE: each box keeps one parent edge (the biggest path into it); every other
|
|
9
|
+
path into it becomes a small dashed stub under its own source, "to X (n)". Shared targets are what
|
|
10
|
+
make the layout cross lines. --no-tree keeps shared targets as real edges.
|
|
11
|
+
- each source that ends gets its own end box, so no long lines cross the chart to one shared box
|
|
12
|
+
- a loop back to an earlier step becomes a "back to X (n)" stub, not an edge
|
|
13
|
+
- edges and nodes are written largest first: the layout takes that as its first guess for left to right
|
|
14
|
+
- duplicate from,to rows are added together into one path
|
|
15
|
+
- node types become classDefs named t_<type> (they never clash with the built-in classes endn, stub and
|
|
16
|
+
startn); --in-scope types get a thick border, --optional types a dashed one
|
|
17
|
+
|
|
18
|
+
It is a draft. Read the rendered chart and fix the layout by hand (SKILL.md, "Layout fixes").
|
|
19
|
+
|
|
20
|
+
Usage:
|
|
21
|
+
python edges_to_mermaid.py edges.csv [--nodes nodes.csv] [--min 25] [--in-scope T1,T2]
|
|
22
|
+
[--optional T3] [--direction TD] [--end-label "Ends"] [--no-tree]
|
|
23
|
+
[--out chart.mmd] [--strict]
|
|
24
|
+
|
|
25
|
+
edges.csv columns: from,to,count, where count is DISTINCT cases per pair (also accepted: from_step,
|
|
26
|
+
to_step,case_count). Do not feed it path occurrences (COUNT(*)): see references/edge-list-sql.md.
|
|
27
|
+
nodes.csv columns: name,type[,count]; count is the distinct cases in that box.
|
|
28
|
+
The Mermaid source goes to --out FILE (written as UTF-8) or, without --out, to stdout (also UTF-8).
|
|
29
|
+
The note on hidden paths and any warning go to stderr. With --strict, any self-check warning gives exit 1
|
|
30
|
+
(the chart is still written). Exit codes: 0 ok, 1 strict warning, 2 bad input.
|
|
31
|
+
Standard library only.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import argparse
|
|
37
|
+
import csv
|
|
38
|
+
import hashlib
|
|
39
|
+
import re
|
|
40
|
+
import sys
|
|
41
|
+
from collections import defaultdict
|
|
42
|
+
from dataclasses import dataclass, field
|
|
43
|
+
from pathlib import Path
|
|
44
|
+
|
|
45
|
+
START = "START"
|
|
46
|
+
END = "END"
|
|
47
|
+
|
|
48
|
+
# Light fills with dark text: they keep contrast on a light canvas in both page themes.
|
|
49
|
+
PALETTE = [
|
|
50
|
+
("#dbe4ee", "#475569"),
|
|
51
|
+
("#bbf7d0", "#15803d"),
|
|
52
|
+
("#f6e7d6", "#b45309"),
|
|
53
|
+
("#e9d5ff", "#7e22ce"),
|
|
54
|
+
("#fde68a", "#a16207"),
|
|
55
|
+
("#cffafe", "#0e7490"),
|
|
56
|
+
("#fecdd3", "#be123c"),
|
|
57
|
+
]
|
|
58
|
+
TEXT = "#1c1917"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(frozen=True)
|
|
62
|
+
class Edge:
|
|
63
|
+
src: str
|
|
64
|
+
dst: str
|
|
65
|
+
count: int
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass
|
|
69
|
+
class Result:
|
|
70
|
+
mermaid: str
|
|
71
|
+
hidden_paths: int
|
|
72
|
+
hidden_total: int
|
|
73
|
+
note: str
|
|
74
|
+
warnings: list[str] = field(default_factory=list)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def node_id(name: str) -> str:
|
|
78
|
+
"""A stable Mermaid id: a readable slug plus a 6-character hash.
|
|
79
|
+
|
|
80
|
+
A collision is very unlikely but not impossible. convert() checks for one and raises ValueError.
|
|
81
|
+
"""
|
|
82
|
+
if name == START:
|
|
83
|
+
return "start"
|
|
84
|
+
slug = re.sub(r"[^A-Za-z0-9]+", "_", name).strip("_").lower()[:24] or "node"
|
|
85
|
+
digest = hashlib.sha1(name.encode("utf-8")).hexdigest()[:6]
|
|
86
|
+
return f"n_{slug}_{digest}"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _label(text: str) -> str:
|
|
90
|
+
"""Escape a name for a quoted Mermaid label. '#' goes first: it starts every entity code."""
|
|
91
|
+
text = " ".join(text.splitlines())
|
|
92
|
+
for raw, code in (("#", "#35;"), ('"', "#quot;"), ("<", "#lt;"), (">", "#gt;"), ("[", "#91;"), ("]", "#93;"), ("&", "#amp;")):
|
|
93
|
+
text = text.replace(raw, code)
|
|
94
|
+
return text
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def merge_edges(edges: list[Edge]) -> list[Edge]:
|
|
98
|
+
"""Add the counts of duplicate (from, to) pairs. First-seen order is kept."""
|
|
99
|
+
total: dict[tuple[str, str], int] = {}
|
|
100
|
+
for e in edges:
|
|
101
|
+
total[(e.src, e.dst)] = total.get((e.src, e.dst), 0) + e.count
|
|
102
|
+
return [Edge(a, b, n) for (a, b), n in total.items()]
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _n(count: int) -> str:
|
|
106
|
+
return f"{count:,}"
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _order_sources(edges: list[Edge]) -> list[str]:
|
|
110
|
+
"""Sources in breadth-first order from START, then any left over, so the text reads top-down."""
|
|
111
|
+
out: dict[str, list[Edge]] = defaultdict(list)
|
|
112
|
+
for e in edges:
|
|
113
|
+
out[e.src].append(e)
|
|
114
|
+
roots = [START] if START in out else sorted({e.src for e in edges} - {e.dst for e in edges})
|
|
115
|
+
seen: list[str] = []
|
|
116
|
+
queue = list(roots)
|
|
117
|
+
while queue:
|
|
118
|
+
cur = queue.pop(0)
|
|
119
|
+
if cur in seen or cur == END:
|
|
120
|
+
continue
|
|
121
|
+
seen.append(cur)
|
|
122
|
+
queue.extend(e.dst for e in sorted(out.get(cur, []), key=lambda x: -x.count))
|
|
123
|
+
seen += sorted(s for s in out if s not in seen)
|
|
124
|
+
return [s for s in seen if s in out]
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _back_edges(edges: list[Edge], roots: list[str]) -> set[Edge]:
|
|
128
|
+
"""Edges that point to a step still open on the depth-first path: the loop-backs."""
|
|
129
|
+
out: dict[str, list[Edge]] = defaultdict(list)
|
|
130
|
+
for e in edges:
|
|
131
|
+
out[e.src].append(e)
|
|
132
|
+
back: set[Edge] = set()
|
|
133
|
+
state: dict[str, int] = {} # 1 = on the path, 2 = done
|
|
134
|
+
|
|
135
|
+
def visit(node: str) -> None:
|
|
136
|
+
state[node] = 1
|
|
137
|
+
for e in sorted(out.get(node, []), key=lambda x: -x.count):
|
|
138
|
+
if e.dst == END:
|
|
139
|
+
continue
|
|
140
|
+
if state.get(e.dst) == 1:
|
|
141
|
+
back.add(e)
|
|
142
|
+
elif e.dst not in state:
|
|
143
|
+
visit(e.dst)
|
|
144
|
+
state[node] = 2
|
|
145
|
+
|
|
146
|
+
for r in roots:
|
|
147
|
+
if r not in state:
|
|
148
|
+
visit(r)
|
|
149
|
+
for s in list(out):
|
|
150
|
+
if s not in state:
|
|
151
|
+
visit(s)
|
|
152
|
+
return back
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _reachable(edges: list[Edge], roots: list[str]) -> set[str]:
|
|
156
|
+
out: dict[str, list[str]] = defaultdict(list)
|
|
157
|
+
for e in edges:
|
|
158
|
+
out[e.src].append(e.dst)
|
|
159
|
+
seen: set[str] = set()
|
|
160
|
+
stack = list(roots)
|
|
161
|
+
while stack:
|
|
162
|
+
cur = stack.pop()
|
|
163
|
+
if cur in seen:
|
|
164
|
+
continue
|
|
165
|
+
seen.add(cur)
|
|
166
|
+
stack.extend(out.get(cur, []))
|
|
167
|
+
return seen
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def convert(
|
|
171
|
+
edges: list[Edge],
|
|
172
|
+
min_count: int = 1,
|
|
173
|
+
box_counts: dict[str, int] | None = None,
|
|
174
|
+
node_types: dict[str, str] | None = None,
|
|
175
|
+
in_scope: set[str] | frozenset[str] = frozenset(),
|
|
176
|
+
optional: set[str] | frozenset[str] = frozenset(),
|
|
177
|
+
direction: str = "TD",
|
|
178
|
+
end_label: str = "Ends",
|
|
179
|
+
tree: bool = True,
|
|
180
|
+
) -> Result:
|
|
181
|
+
box_counts = box_counts or {}
|
|
182
|
+
node_types = node_types or {}
|
|
183
|
+
edges = merge_edges(edges)
|
|
184
|
+
seen_ids: dict[str, str] = {}
|
|
185
|
+
for name in {n for e in edges for n in (e.src, e.dst) if n != END}:
|
|
186
|
+
other = seen_ids.setdefault(node_id(name), name)
|
|
187
|
+
if other != name:
|
|
188
|
+
raise ValueError(f"{other!r} and {name!r} would get the same id; rename one of them")
|
|
189
|
+
|
|
190
|
+
small = [e for e in edges if e.count < min_count]
|
|
191
|
+
kept = [e for e in edges if e.count >= min_count]
|
|
192
|
+
has_start = any(e.src == START for e in edges)
|
|
193
|
+
roots = [START] if has_start else sorted({e.src for e in edges} - {e.dst for e in edges})
|
|
194
|
+
live = _reachable(kept, roots)
|
|
195
|
+
orphaned = [e for e in kept if e.src not in live]
|
|
196
|
+
kept = [e for e in kept if e.src in live]
|
|
197
|
+
|
|
198
|
+
back = _back_edges(kept, roots)
|
|
199
|
+
# Tree mode: the biggest forward path into a box is its one parent edge; the rest become stubs.
|
|
200
|
+
stubbed: set[Edge] = set()
|
|
201
|
+
if tree:
|
|
202
|
+
into: dict[str, list[Edge]] = defaultdict(list)
|
|
203
|
+
for e in kept:
|
|
204
|
+
if e.dst != END and e not in back:
|
|
205
|
+
into[e.dst].append(e)
|
|
206
|
+
for incoming in into.values():
|
|
207
|
+
incoming.sort(key=lambda x: (-x.count, x.src))
|
|
208
|
+
stubbed.update(incoming[1:])
|
|
209
|
+
layout = [e for e in kept if e not in back and e not in stubbed]
|
|
210
|
+
lines = [f"flowchart {direction}"]
|
|
211
|
+
|
|
212
|
+
# Declare nodes in the same largest-first order as the edges: Mermaid places the first-declared
|
|
213
|
+
# child of a rank on the left, so declaration order is part of the layout.
|
|
214
|
+
names: list[str] = []
|
|
215
|
+
ordered = sorted(layout, key=lambda x: -x.count)
|
|
216
|
+
for src in _order_sources(layout):
|
|
217
|
+
for n in [src] + [e.dst for e in ordered if e.src == src]:
|
|
218
|
+
if n != END and n not in names:
|
|
219
|
+
names.append(n)
|
|
220
|
+
for e in kept:
|
|
221
|
+
for n in (e.src, e.dst):
|
|
222
|
+
if n != END and n not in names:
|
|
223
|
+
names.append(n)
|
|
224
|
+
start_total = sum(e.count for e in kept if e.src == START) + sum(e.count for e in small if e.src == START)
|
|
225
|
+
for name in names:
|
|
226
|
+
if name == START:
|
|
227
|
+
lines.append(f' start(["Start<br/>({_n(start_total)})"])')
|
|
228
|
+
continue
|
|
229
|
+
text = _label(name)
|
|
230
|
+
if name in box_counts:
|
|
231
|
+
text += f"<br/>({_n(box_counts[name])})"
|
|
232
|
+
lines.append(f' {node_id(name)}["{text}"]')
|
|
233
|
+
|
|
234
|
+
by_src: dict[str, list[Edge]] = defaultdict(list)
|
|
235
|
+
for e in kept:
|
|
236
|
+
by_src[e.src].append(e)
|
|
237
|
+
end_ids: list[str] = []
|
|
238
|
+
stub_ids: list[str] = []
|
|
239
|
+
sources = _order_sources(layout)
|
|
240
|
+
sources += [s for s in _order_sources(kept) if s not in sources]
|
|
241
|
+
for src in sources:
|
|
242
|
+
sid = node_id(src)
|
|
243
|
+
for e in sorted(by_src[src], key=lambda x: -x.count):
|
|
244
|
+
arrow = f'{sid} -->|"({_n(e.count)})"|'
|
|
245
|
+
if e.dst == END:
|
|
246
|
+
eid = f"end_{sid}"
|
|
247
|
+
end_ids.append(eid)
|
|
248
|
+
lines.append(f' {arrow} {eid}(["{_label(end_label)}"])')
|
|
249
|
+
elif e in back or e in stubbed:
|
|
250
|
+
word = "back to" if e in back else "to"
|
|
251
|
+
bid = f"{word.split()[0]}_{sid}_{node_id(e.dst)}"
|
|
252
|
+
stub_ids.append(bid)
|
|
253
|
+
lines.append(f' {sid} --> {bid}(["{word} {_label(e.dst)} ({_n(e.count)})"])')
|
|
254
|
+
else:
|
|
255
|
+
lines.append(f" {arrow} {node_id(e.dst)}")
|
|
256
|
+
|
|
257
|
+
types = []
|
|
258
|
+
for name in names:
|
|
259
|
+
t = node_types.get(name)
|
|
260
|
+
if t and t not in types:
|
|
261
|
+
types.append(t)
|
|
262
|
+
for i, t in enumerate(types):
|
|
263
|
+
fill, stroke = PALETTE[i % len(PALETTE)]
|
|
264
|
+
extra = ""
|
|
265
|
+
if t in in_scope:
|
|
266
|
+
extra = ",stroke-width:3px"
|
|
267
|
+
elif t in optional:
|
|
268
|
+
extra = ",stroke-width:2px,stroke-dasharray:6 4"
|
|
269
|
+
lines.append(f" classDef t_{t} fill:{fill},stroke:{stroke},color:{TEXT}{extra};")
|
|
270
|
+
if end_ids:
|
|
271
|
+
lines.append(f" classDef endn fill:#eceae6,stroke:#78716c,color:{TEXT};")
|
|
272
|
+
if stub_ids:
|
|
273
|
+
lines.append(" classDef stub fill:#ffffff,stroke:#a8a29e,stroke-dasharray:3 3,color:#57534e;")
|
|
274
|
+
if has_start and START in names:
|
|
275
|
+
lines.append(f" classDef startn fill:#ffffff,stroke:#57534e,stroke-width:2px,color:{TEXT};")
|
|
276
|
+
for t in types:
|
|
277
|
+
ids = [node_id(n) for n in names if node_types.get(n) == t]
|
|
278
|
+
lines.append(f" class {','.join(ids)} t_{t};")
|
|
279
|
+
if end_ids:
|
|
280
|
+
lines.append(f" class {','.join(end_ids)} endn;")
|
|
281
|
+
if stub_ids:
|
|
282
|
+
lines.append(f" class {','.join(stub_ids)} stub;")
|
|
283
|
+
if has_start and START in names:
|
|
284
|
+
lines.append(" class start startn;")
|
|
285
|
+
|
|
286
|
+
hidden_total = sum(e.count for e in small)
|
|
287
|
+
note_parts = []
|
|
288
|
+
if small:
|
|
289
|
+
note_parts.append(
|
|
290
|
+
f"Not drawn: {len(small)} paths with fewer than {_n(min_count)} each "
|
|
291
|
+
f"({_n(hidden_total)} in total): "
|
|
292
|
+
+ "; ".join(f"{e.src} to {e.dst} ({_n(e.count)})" for e in sorted(small, key=lambda x: -x.count))
|
|
293
|
+
+ "."
|
|
294
|
+
)
|
|
295
|
+
if orphaned:
|
|
296
|
+
note_parts.append(f"Also not drawn: {len(orphaned)} paths that start from a step that is not drawn.")
|
|
297
|
+
note = " ".join(note_parts)
|
|
298
|
+
if note:
|
|
299
|
+
lines.append(f" %% {note}")
|
|
300
|
+
# Self-check: an arrow counts distinct cases, so it can never be larger than the box at either end.
|
|
301
|
+
# When it is, the edge list counts path occurrences (COUNT(*)), not cases.
|
|
302
|
+
warnings = []
|
|
303
|
+
for e in kept:
|
|
304
|
+
for end in (e.src, e.dst):
|
|
305
|
+
if end in box_counts and e.count > box_counts[end]:
|
|
306
|
+
warnings.append(
|
|
307
|
+
f"{e.src} to {e.dst} ({_n(e.count)}) is larger than the box {end} ({_n(box_counts[end])}): "
|
|
308
|
+
"the edge list counts path occurrences, not distinct cases"
|
|
309
|
+
)
|
|
310
|
+
return Result("\n".join(lines) + "\n", len(small), hidden_total, note, warnings)
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def build(edges: list[Edge], **kwargs) -> str:
|
|
314
|
+
return convert(edges, **kwargs).mermaid
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _col(row: dict[str, str], *names: str, where: str = "csv") -> str:
|
|
318
|
+
lower = {k.strip().lower(): v for k, v in row.items() if k}
|
|
319
|
+
for n in names:
|
|
320
|
+
if n in lower:
|
|
321
|
+
if lower[n] is None:
|
|
322
|
+
raise ValueError(f"{where}: the row has no value for {n!r} (the row is shorter than the header)")
|
|
323
|
+
return lower[n].strip()
|
|
324
|
+
raise ValueError(f"{where}: missing column, expected one of {names}; got {sorted(lower)}")
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def read_edges(path: Path) -> list[Edge]:
|
|
328
|
+
edges = []
|
|
329
|
+
with path.open(encoding="utf-8-sig", newline="") as fh:
|
|
330
|
+
for line_no, row in enumerate(csv.DictReader(fh), start=2):
|
|
331
|
+
where = f"{path}:{line_no}"
|
|
332
|
+
raw = _col(row, "count", "case_count", "n", where=where)
|
|
333
|
+
try:
|
|
334
|
+
count = int(raw.replace(",", ""))
|
|
335
|
+
except ValueError as exc:
|
|
336
|
+
raise ValueError(f"{path}:{line_no}: count is not a whole number: {raw!r}") from exc
|
|
337
|
+
edges.append(
|
|
338
|
+
Edge(_col(row, "from", "from_step", "src", where=where), _col(row, "to", "to_step", "dst", where=where), count)
|
|
339
|
+
)
|
|
340
|
+
return merge_edges(edges)
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def read_nodes(path: Path) -> tuple[dict[str, str], dict[str, int]]:
|
|
344
|
+
types: dict[str, str] = {}
|
|
345
|
+
counts: dict[str, int] = {}
|
|
346
|
+
with path.open(encoding="utf-8-sig", newline="") as fh:
|
|
347
|
+
for line_no, row in enumerate(csv.DictReader(fh), start=2):
|
|
348
|
+
where = f"{path}:{line_no}"
|
|
349
|
+
name = _col(row, "name", "step", "node", where=where)
|
|
350
|
+
t = _col(row, "type", "kind", where=where)
|
|
351
|
+
if t:
|
|
352
|
+
types[name] = re.sub(r"[^A-Za-z0-9_]", "_", t)
|
|
353
|
+
lower = {k.strip().lower(): v for k, v in row.items() if k}
|
|
354
|
+
raw = (lower.get("count") or lower.get("case_count") or "").strip()
|
|
355
|
+
if raw:
|
|
356
|
+
try:
|
|
357
|
+
counts[name] = int(raw.replace(",", ""))
|
|
358
|
+
except ValueError as exc:
|
|
359
|
+
raise ValueError(f"{where}: count is not a whole number: {raw!r}") from exc
|
|
360
|
+
return types, counts
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def main(argv: list[str] | None = None) -> int:
|
|
364
|
+
ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
|
|
365
|
+
ap.add_argument("edges", type=Path)
|
|
366
|
+
ap.add_argument("--nodes", type=Path)
|
|
367
|
+
ap.add_argument("--min", type=int, default=1, dest="min_count")
|
|
368
|
+
ap.add_argument("--in-scope", default="")
|
|
369
|
+
ap.add_argument("--optional", default="")
|
|
370
|
+
ap.add_argument("--direction", default="TD", choices=["TD", "LR"])
|
|
371
|
+
ap.add_argument("--end-label", default="Ends")
|
|
372
|
+
ap.add_argument("--no-tree", action="store_true", help="keep shared targets as real edges, no stubs")
|
|
373
|
+
ap.add_argument("--out", type=Path, help="write the Mermaid source to this file as UTF-8 (default: stdout)")
|
|
374
|
+
ap.add_argument("--strict", action="store_true", help="exit 1 when the self-check finds an arrow larger than a box")
|
|
375
|
+
args = ap.parse_args(argv)
|
|
376
|
+
if hasattr(sys.stdout, "reconfigure"):
|
|
377
|
+
sys.stdout.reconfigure(encoding="utf-8")
|
|
378
|
+
if hasattr(sys.stderr, "reconfigure"):
|
|
379
|
+
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
|
|
380
|
+
|
|
381
|
+
try:
|
|
382
|
+
edges = read_edges(args.edges)
|
|
383
|
+
types, counts = read_nodes(args.nodes) if args.nodes else ({}, {})
|
|
384
|
+
except (OSError, ValueError) as exc:
|
|
385
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
386
|
+
return 2
|
|
387
|
+
split = lambda s: {x.strip() for x in s.split(",") if x.strip()}
|
|
388
|
+
try:
|
|
389
|
+
result = convert(
|
|
390
|
+
edges,
|
|
391
|
+
min_count=args.min_count,
|
|
392
|
+
box_counts=counts,
|
|
393
|
+
node_types=types,
|
|
394
|
+
in_scope=split(args.in_scope),
|
|
395
|
+
optional=split(args.optional),
|
|
396
|
+
direction=args.direction,
|
|
397
|
+
end_label=args.end_label,
|
|
398
|
+
tree=not args.no_tree,
|
|
399
|
+
)
|
|
400
|
+
if args.out:
|
|
401
|
+
args.out.write_text(result.mermaid, encoding="utf-8", newline="\n")
|
|
402
|
+
else:
|
|
403
|
+
sys.stdout.write(result.mermaid)
|
|
404
|
+
except (OSError, ValueError) as exc:
|
|
405
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
406
|
+
return 2
|
|
407
|
+
if result.note:
|
|
408
|
+
print(result.note, file=sys.stderr)
|
|
409
|
+
for w in result.warnings:
|
|
410
|
+
print(f"warning: {w}", file=sys.stderr)
|
|
411
|
+
if args.strict and result.warnings:
|
|
412
|
+
print(f"error: --strict: {len(result.warnings)} self-check warning(s)", file=sys.stderr)
|
|
413
|
+
return 1
|
|
414
|
+
return 0
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
if __name__ == "__main__":
|
|
418
|
+
raise SystemExit(main())
|
|
@@ -146,7 +146,9 @@ history, and the relay has a 4,000-character budget (`caddis_budget.py`).
|
|
|
146
146
|
> Your session id is printed at session start as `[caddis] this session's id: <id>`; use it for
|
|
147
147
|
> `session_id` and `--session-id`. Never guess one: a wrong id disables session matching.
|
|
148
148
|
>
|
|
149
|
-
> 1. **Name the workstream
|
|
149
|
+
> 1. **Name the workstream.** First run the coordinator check of Step 9. When it says this session is the
|
|
150
|
+
> repo's coordinator, `<name>` is the config's `relay`: do not run `resolve` and do not ask. Else name it
|
|
151
|
+
> from the active plan's `feature`, else the branch (the script prints nothing
|
|
150
152
|
> on the default branch with no plan — run `caddis_workstreams.py list` (`python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_workstreams.py" list`) to show existing workstreams, then ask the user which one to use or for a short name):
|
|
151
153
|
> `python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_workstreams.py" resolve --plan <feature> --branch <branch> --default-branch <main>`
|
|
152
154
|
> 2. **Legacy repo:** if `.caddis/relay.md` exists and does not start with
|
|
@@ -398,7 +400,40 @@ Then, in a shared checkout, `Docs PR needed:` with the `PR:` line and the file l
|
|
|
398
400
|
Step 9 prints after this report. It is the last output of the handoff.
|
|
399
401
|
|
|
400
402
|
## Step 9 — print the resume prompt (always the last output)
|
|
401
|
-
The owner should never have to ask for the resume prompt. After the checklist report,
|
|
403
|
+
The owner should never have to ask for the resume prompt. After the checklist report, check for a
|
|
404
|
+
coordinator first.
|
|
405
|
+
|
|
406
|
+
**Coordinator check.** Step 3, point 1 also uses this check. When the repo has `.caddis/coordinator.md`,
|
|
407
|
+
find out if this session is the repo's coordinator:
|
|
408
|
+
1. Read the config. It must have `relay`:
|
|
409
|
+
```
|
|
410
|
+
python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_coordinate.py" config show --path . --json
|
|
411
|
+
```
|
|
412
|
+
Exit 1, or no `relay`: this session is not treated as the coordinator here. Use the normal block.
|
|
413
|
+
2. Find the repo id `<R>` (`<account>` is the repo's GitHub account, from the repo's `AGENTS.md`):
|
|
414
|
+
```
|
|
415
|
+
GH_TOKEN=$(gh auth token -u <account>) python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_coordinate.py" repo-id --json
|
|
416
|
+
```
|
|
417
|
+
Keep `repo` as `<R>`.
|
|
418
|
+
3. Find this session's pid. Keep `pid`:
|
|
419
|
+
```
|
|
420
|
+
python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_coordinate.py" session --session-id <session-id> --json
|
|
421
|
+
```
|
|
422
|
+
4. Read the lock holder. It never takes or changes the lock:
|
|
423
|
+
```
|
|
424
|
+
python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_coordinate.py" lock holder --repo <R> --json
|
|
425
|
+
```
|
|
426
|
+
5. This session is the coordinator when the holder's `sessionId` is this session's id, OR the holder's `pid`
|
|
427
|
+
is this session's `pid` (the same coordinator process, for example after a `/clear` that never ran start).
|
|
428
|
+
|
|
429
|
+
When this session is the coordinator, `<name>` is the config's `relay`, and you add `--coordinator` to the
|
|
430
|
+
`resume-block` call below. The block then prints only `/clear`, `/rename coordinator` and
|
|
431
|
+
`/caddis:coordinate start`, and no resume prompt: `/caddis:coordinate start` reads the relay and its tasks
|
|
432
|
+
itself. `resume-block --coordinator` refuses (exit 1) a `--name` that is not the config's `relay`. In all
|
|
433
|
+
other cases (no `.caddis/coordinator.md`, no `relay`, `{"holder": null}`, another holder, an exit 1), run
|
|
434
|
+
`resume-block` as written.
|
|
435
|
+
|
|
436
|
+
Then run:
|
|
402
437
|
```
|
|
403
438
|
python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_fleet.py" resume-block --name <name> --session-id <session-id> --root .
|
|
404
439
|
```
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: flow-chart-from-counts
|
|
3
|
+
description: Turn a from-to-count edge list into a zoomable Mermaid flow chart page with counts, a legend and a render check.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# flow-chart-from-counts
|
|
7
|
+
|
|
8
|
+
Turns counted paths (N cases went from step A to step B) into a flow chart page that a
|
|
9
|
+
reader can trust: counts on every arrow, a legend for the box types, a title with the date and
|
|
10
|
+
time zone, a note that names what is not drawn, and a viewer with zoom, pan and full screen.
|
|
11
|
+
|
|
12
|
+
Paths below are relative to the folder that holds this file.
|
|
13
|
+
|
|
14
|
+
## When to use it
|
|
15
|
+
|
|
16
|
+
- "Draw how cases move through the process", "show the journey with counts", "flow chart of
|
|
17
|
+
calls / orders / tickets / sessions from this table".
|
|
18
|
+
- You have, or can query, rows of `from, to, count`, or an event table with one row per step.
|
|
19
|
+
- A Sankey or a funnel was asked for, but the paths branch and rejoin: a flow chart shows that
|
|
20
|
+
better.
|
|
21
|
+
|
|
22
|
+
Use something else for:
|
|
23
|
+
|
|
24
|
+
- An interactive page from a JSON model with lanes, playback and an inspector: `flow-studio`.
|
|
25
|
+
- Mermaid syntax for other diagram kinds (sequence, ERD, class): `mermaid-diagrams`.
|
|
26
|
+
- A diagram of a SQL object's structure: `db-diagram`.
|
|
27
|
+
|
|
28
|
+
## What is in the folder
|
|
29
|
+
|
|
30
|
+
| file | use |
|
|
31
|
+
|---|---|
|
|
32
|
+
| `scripts/edges_to_mermaid.py` | CSV edge list to first-draft Mermaid source (standard library only) |
|
|
33
|
+
| `assets/viewer-template.html` | page with the zoom/pan/full-screen viewer, theme tokens and one example chart |
|
|
34
|
+
| `scripts/check-render.mjs` | headless render check: fails on no SVG, error text, too few nodes, page errors; saves screenshots |
|
|
35
|
+
| `references/edge-list-sql.md` | SQL pattern: event table to edge list and box counts, and the checks to run on it |
|
|
36
|
+
|
|
37
|
+
## Process
|
|
38
|
+
|
|
39
|
+
1. **Get the edge list.** From an event table, use `references/edge-list-sql.md`. Run its checks
|
|
40
|
+
(START rows add up to the cases; path occurrences in equal path occurrences out per step).
|
|
41
|
+
Count arrows as distinct cases per pair. Get the box counts (distinct cases per step) at the
|
|
42
|
+
same time.
|
|
43
|
+
2. **Name the node types** with the reader, or from the process owner's own glossary: for
|
|
44
|
+
example automatic step, in-scope step, optional step, hand-off, end. Write a `nodes.csv`
|
|
45
|
+
(`name,type,count`).
|
|
46
|
+
3. **Make the first draft:**
|
|
47
|
+
```
|
|
48
|
+
python scripts/edges_to_mermaid.py edges.csv --nodes nodes.csv --min 25 \
|
|
49
|
+
--in-scope scope --optional choice --out chart.mmd --strict
|
|
50
|
+
```
|
|
51
|
+
`--out` writes UTF-8 (do not use `>` in Windows PowerShell 5.1: it can write UTF-16). The note
|
|
52
|
+
on hidden paths goes to stderr. Keep it: it goes under the chart. `--strict` exits 1 on a
|
|
53
|
+
`warning:` line, which means an arrow is larger than its box: fix the query before you draw.
|
|
54
|
+
Duplicate `from,to` rows are added together. Node types become classes named `t_<type>`.
|
|
55
|
+
4. **Put it in the page.** Copy `assets/viewer-template.html`. Paste the source into the
|
|
56
|
+
`<script type="text/plain">` block. Set `data-title` (what, which day) and `data-sub` (the UTC
|
|
57
|
+
window, the unit of the counts, the minimum path size). Replace the legend and the note.
|
|
58
|
+
For more charts, add one `.fv` block and one source script per chart.
|
|
59
|
+
5. **Render and check:**
|
|
60
|
+
```
|
|
61
|
+
node <skill folder>/scripts/check-render.mjs page.html --out shots [--channel msedge]
|
|
62
|
+
```
|
|
63
|
+
Run it from a folder whose `node_modules` has `playwright`. It exits 1 when a check fails.
|
|
64
|
+
6. **Look at the screenshots** (`chart-N.png` is the chart at Fit, `chart-1-full.png` is full
|
|
65
|
+
screen) and run the tidy checklist below. If a rule fails, change the layout with the layout
|
|
66
|
+
fixes, and go back to step 5.
|
|
67
|
+
7. **Show** the page and the screenshots only when every checklist rule passes. Say which labels
|
|
68
|
+
are your reading (see Wording). If a rule cannot be met, say which rule and why.
|
|
69
|
+
|
|
70
|
+
## Tidy checklist (the chart must pass it before you show it)
|
|
71
|
+
|
|
72
|
+
A clean, tidy chart is an acceptance rule, not a nice-to-have. A reader who has to trace a
|
|
73
|
+
line across the page stops trusting the numbers on it.
|
|
74
|
+
|
|
75
|
+
1. **No crossing lines.** No line passes through a box or a label. Use the tree rule with
|
|
76
|
+
dashed stubs, and split shared targets (layout fixes 1 and 2).
|
|
77
|
+
2. **No overlaps.** No label or box sits on another. Edge labels are short: `(1,234)` and at
|
|
78
|
+
most a few words. Long text goes in the note under the chart, not on the chart.
|
|
79
|
+
3. **Rows and side flows.** Boxes of the same kind line up in one row where the flow allows
|
|
80
|
+
(layout fix 5). A side flow sits in its own dashed box at the bottom, pushed down with an
|
|
81
|
+
invisible link (layout fix 4).
|
|
82
|
+
4. **One look per type.** One box shape per type, a small fixed palette (5 to 7 classes), a
|
|
83
|
+
legend that matches the chart exactly (stubs included), and the title with the date and the
|
|
84
|
+
time zone on the chart.
|
|
85
|
+
5. **Readable at Fit and at full screen** on a 1280 x 900 screen. Nothing is clipped.
|
|
86
|
+
6. **Checked by eye.** You opened both screenshots and checked rules 1 to 5 on them. The render
|
|
87
|
+
check's warnings (box overlaps, labels on boxes) help, but they cannot see a crossing line.
|
|
88
|
+
|
|
89
|
+
## Count rules
|
|
90
|
+
|
|
91
|
+
- **Arrows and boxes both count distinct cases.** An arrow says how many distinct cases took
|
|
92
|
+
that path (`COUNT(DISTINCT case_id)` per from-to pair). A box says how many distinct cases
|
|
93
|
+
reached it. Do not draw path occurrences (`COUNT(*)`): a case that loops counts twice, and a
|
|
94
|
+
reader who sees a box of 8,538 followed by an arrow of 8,541 stops trusting the chart. Label
|
|
95
|
+
every number as cases.
|
|
96
|
+
- **Self-check: no arrow into or out of a box exceeds that box's count.** If one does, the
|
|
97
|
+
query counts occurrences. The converter warns on stderr when this happens.
|
|
98
|
+
- **Say in the note that arrows into a box can sum to a little more than the box**, because a
|
|
99
|
+
case can reach it by two paths.
|
|
100
|
+
- **Put counts in brackets, with thousands separators:** `(1,234)`. In Mermaid an edge label that
|
|
101
|
+
holds brackets must be quoted: `A -->|"(1,234)"| B`. Unquoted, `(` starts a node shape and the
|
|
102
|
+
chart fails to parse.
|
|
103
|
+
- **Hide small paths with a stated minimum.** Choose a minimum (for example 25), drop the paths
|
|
104
|
+
under it, and write a note that names them and their total: "Not drawn because fewer than 25
|
|
105
|
+
cases took them: X to Y (12), ... (41 in total)." A chart that drops paths without a note looks
|
|
106
|
+
complete and is not.
|
|
107
|
+
- **Merge small boxes into one named box, and say so.** When you merge, the count is a sum of
|
|
108
|
+
rounded or filtered parts: write `(about 600)`, and say in the note what the box holds.
|
|
109
|
+
- **Put the date and the time zone on the chart.** Write the title as HTML inside the zoomable
|
|
110
|
+
stage (the template does this from `data-title` and `data-sub`), so it stays with the chart in
|
|
111
|
+
full screen and in screenshots. Do not use a frontmatter `title:` as well: it would show a second title. Give the
|
|
112
|
+
local day and the UTC window: `Mon 5 Oct 2026 (4 Oct 23:00 to 5 Oct 23:00 UTC)`.
|
|
113
|
+
- **Name the unit.** "Counts in brackets are calls / orders / cases." A count with no unit gets
|
|
114
|
+
read as the wrong one.
|
|
115
|
+
|
|
116
|
+
## Node types and the legend
|
|
117
|
+
|
|
118
|
+
- One `classDef` per node type, and one `class id1,id2 type;` line per type. The converter writes
|
|
119
|
+
these from `nodes.csv`.
|
|
120
|
+
- **In-scope boxes get a thick border** (`stroke-width:3px`), so the reader finds the subject of
|
|
121
|
+
the chart first.
|
|
122
|
+
- **Optional or owner-choice boxes get a dashed border** (`stroke-dasharray:6 4`): a step that is
|
|
123
|
+
a decision still open, or one that runs only for some cases.
|
|
124
|
+
- End boxes share one quiet grey class. Stubs share one class: white fill, thin dashed grey
|
|
125
|
+
border (`stroke-dasharray:3 3`), so they read as pointers, not steps.
|
|
126
|
+
- Put a legend above the chart with one swatch per type, with the same fill, border width and
|
|
127
|
+
dash as the chart. Use light fills with dark text. Keep the chart canvas light in dark mode
|
|
128
|
+
too (the template's `--canvas` token does this), or the dark text loses contrast.
|
|
129
|
+
|
|
130
|
+
## Layout fixes that work
|
|
131
|
+
|
|
132
|
+
Mermaid's layout (dagre) puts each node on a rank and orders each rank to cut crossings. It does
|
|
133
|
+
not know what the reader looks for. These fixes move it.
|
|
134
|
+
|
|
135
|
+
1. **Draw the chart as a tree.** Give every box exactly one parent edge: the biggest path into
|
|
136
|
+
it. For every other path into that box, add a small dashed stub under its own source,
|
|
137
|
+
labelled with the target and the count: `B --> toB_C(["to C (412)"])`. Shared targets and
|
|
138
|
+
back-edges are what make the layout cross lines; a tree with stubs has none. Add a legend
|
|
139
|
+
entry for stubs ("dashed: the path continues at the box named"). The converter does this by
|
|
140
|
+
default (`--no-tree` turns it off).
|
|
141
|
+
2. **Give each source its own end box.** One shared "Ends" box pulls a long line from every step
|
|
142
|
+
across the chart. Write `A --> endA(["Ends"])` and `B --> endB(["Ends"])` instead. The
|
|
143
|
+
converter does this. Apply the same fix to any shared target that sits far from its sources:
|
|
144
|
+
a copy of the box next to each source is easier to read than one box with crossing lines.
|
|
145
|
+
3. **Do not draw a loop-back as an edge.** A line back up the chart crosses everything. End the
|
|
146
|
+
path in a stub: `A --> backA(["back to Triage (190)"])`. The converter does this for every
|
|
147
|
+
edge that points back to a step on the path.
|
|
148
|
+
4. **Put a side flow in its own subgraph and push it down.** For example the steps after a
|
|
149
|
+
hand-off. Draw them in `subgraph AFTER[...] direction LR ... end` and add an invisible link
|
|
150
|
+
from a low node of the main flow: `X2 ~~~ AFTER`. The subgraph then sits under the main
|
|
151
|
+
flow, not beside it.
|
|
152
|
+
5. **Put same-kind boxes in one row.** Group them in a subgraph, and lengthen the edges into it
|
|
153
|
+
(`---->`, one dash per extra rank) so they land on the same rank.
|
|
154
|
+
6. **Replace a long jump with a short stub or a dotted link.** When an edge must reach a box
|
|
155
|
+
far away, end it in a small stub node near the source, or draw it dotted (`-.->`) with a
|
|
156
|
+
label such as `"few"`, so the eye can tell it is secondary.
|
|
157
|
+
7. **Order the edges from a node in the order you want the boxes, left to right**, and declare
|
|
158
|
+
the nodes in the same order. Dagre takes this as its first guess. It can still swap them to
|
|
159
|
+
cut crossings: check the screenshot.
|
|
160
|
+
8. **Quote every label that holds brackets, quotes or `<br/>`:** `["Text<br/>(12)"]`,
|
|
161
|
+
`|"(12)"|`. Write a quote inside a label as `#quot;`.
|
|
162
|
+
|
|
163
|
+
Change one thing at a time and re-render. Two fixes at once hide which one helped.
|
|
164
|
+
|
|
165
|
+
## Wording
|
|
166
|
+
|
|
167
|
+
- **Use the process owner's words for box labels** when a glossary or a list of step names
|
|
168
|
+
exists. A reader checks the chart against those names.
|
|
169
|
+
- **Write neutral endings.** "Case ends" or "Call ends" means the data shows no further step.
|
|
170
|
+
Do not write a guess such as "hangs up" or "gives up": the data does not say who ended it.
|
|
171
|
+
Say what "ends" means in the note.
|
|
172
|
+
- **Mark your own reading.** When a label or a grouping is your interpretation, not a name from
|
|
173
|
+
the source, say so in the note ("'Team queues' is our grouping of four queues").
|
|
174
|
+
|
|
175
|
+
## Verify
|
|
176
|
+
|
|
177
|
+
`scripts/check-render.mjs` fails (exit 1) when a chart has no SVG, shows an error message, has
|
|
178
|
+
fewer nodes than `--min-nodes`, or the page throws. Make it fail once on purpose (break one
|
|
179
|
+
line of the source) to see that it can. It also counts box overlaps and arrow labels on boxes
|
|
180
|
+
as warnings. Those are hints: open the screenshots and run the tidy checklist by eye, because
|
|
181
|
+
the check cannot see a crossing line.
|
|
182
|
+
|
|
183
|
+
Also check the numbers, not only the drawing: the START count equals the cases in the window,
|
|
184
|
+
and no arrow into or out of a box is larger than the box (the converter warns when one is).
|
|
185
|
+
|
|
186
|
+
## Publishing
|
|
187
|
+
|
|
188
|
+
- As an Artifact: follow the artifact-design rules. Mermaid loads from
|
|
189
|
+
`https://cdnjs.cloudflare.com/ajax/libs/mermaid/10.9.1/mermaid.min.js` (an exact version on the
|
|
190
|
+
allowed CDN). The template already has theme tokens for light and dark, an explicit body
|
|
191
|
+
background, and a 16px side gutter for phone width.
|
|
192
|
+
- In an app: render with the app's own Mermaid package and keep the same source, title and note.
|
|
193
|
+
Keep the count rules; they are what makes the chart trustworthy.
|