@caddis/cli 0.4.73 → 0.4.75
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 +106 -4
- package/bundles/antigravity-plugin/scripts/templates/coordinator.md +5 -3
- package/bundles/antigravity-plugin/skills/_registry.md +1 -0
- package/bundles/antigravity-plugin/skills/coordinate/SKILL.md +41 -8
- 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/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
|
@@ -962,7 +962,10 @@ def session_lookup(sessions: list[dict], *, session_id: str | None = None, name:
|
|
|
962
962
|
|
|
963
963
|
CONFIG_KEYS = ("schema", "default_branch", "merge_method", "required_checks", "not_required", "docs_only",
|
|
964
964
|
"main_workflow", "reviews", "peer_ack", "deploy", "owner_reserved", "stall", "quiet")
|
|
965
|
-
OPTIONAL_CONFIG_KEYS = ("relay",) #
|
|
965
|
+
OPTIONAL_CONFIG_KEYS = ("relay", "start_mode") # relay: `.caddis/relay/<relay>.md`; start_mode: see START_MODES
|
|
966
|
+
# The merge mode a start begins in. "target" in the reviewed, merged config is the owner's standing consent
|
|
967
|
+
# for the coordinator to run `gh pr merge` (command section 2). Missing means "interim".
|
|
968
|
+
START_MODES = ("interim", "target")
|
|
966
969
|
CONFIG_SCHEMA = 1
|
|
967
970
|
_PLACEHOLDER = "<fill:"
|
|
968
971
|
_FENCE = re.compile(r"^ {0,3}(`{3,}|~{3,})[ \t]*([^\s`]*)") # a Markdown code fence and its info string
|
|
@@ -1018,8 +1021,9 @@ def load_config(repo_root: Path) -> dict:
|
|
|
1018
1021
|
"""Read `<repo_root>/.caddis/coordinator.md` and return its json block.
|
|
1019
1022
|
|
|
1020
1023
|
Raise FileNotFoundError when the file is missing, ValueError when there is no json block, a key is missing,
|
|
1021
|
-
`schema` is not 1, a value is still a `<fill: ...>` placeholder,
|
|
1022
|
-
workstream name (`caddis_workstreams.is_plain_name`: lower case `a-z0-9_-`, no Windows device name)
|
|
1024
|
+
`schema` is not 1, a value is still a `<fill: ...>` placeholder, the optional `relay` is not a plain
|
|
1025
|
+
workstream name (`caddis_workstreams.is_plain_name`: lower case `a-z0-9_-`, no Windows device name), or the
|
|
1026
|
+
optional `start_mode` is not one of START_MODES."""
|
|
1023
1027
|
path = config_path(repo_root)
|
|
1024
1028
|
try:
|
|
1025
1029
|
text = path.read_text(encoding="utf-8")
|
|
@@ -1040,9 +1044,92 @@ def load_config(repo_root: Path) -> dict:
|
|
|
1040
1044
|
if "relay" in config and not _plain_workstream_name(config["relay"]):
|
|
1041
1045
|
raise ValueError(f"the config relay must be a plain workstream name such as coordinator, "
|
|
1042
1046
|
f"not {config['relay']!r}")
|
|
1047
|
+
if "start_mode" in config and config["start_mode"] not in START_MODES:
|
|
1048
|
+
raise ValueError(f"the config start_mode must be one of {', '.join(START_MODES)}, "
|
|
1049
|
+
f"not {config['start_mode']!r}")
|
|
1043
1050
|
return config
|
|
1044
1051
|
|
|
1045
1052
|
|
|
1053
|
+
_CONFIG_REL = ".caddis/coordinator.md"
|
|
1054
|
+
|
|
1055
|
+
|
|
1056
|
+
def _start_mode_at(repo_root: Path, rev: str) -> str | None:
|
|
1057
|
+
"""`start_mode` in the config at `rev`: a mode, None (no file or no key) or "invalid"."""
|
|
1058
|
+
shown = _run(["git", "-C", str(repo_root), "show", f"{rev}:{_CONFIG_REL}"])
|
|
1059
|
+
if shown.returncode != 0:
|
|
1060
|
+
return None
|
|
1061
|
+
try:
|
|
1062
|
+
block = _config_block(shown.stdout)
|
|
1063
|
+
except ValueError:
|
|
1064
|
+
return "invalid"
|
|
1065
|
+
if not isinstance(block, dict):
|
|
1066
|
+
return "invalid"
|
|
1067
|
+
if "start_mode" not in block:
|
|
1068
|
+
return None
|
|
1069
|
+
return block["start_mode"] if block["start_mode"] in START_MODES else "invalid"
|
|
1070
|
+
|
|
1071
|
+
|
|
1072
|
+
def _origin_default_branch(repo_root: Path) -> str | None:
|
|
1073
|
+
"""The default branch from `refs/remotes/origin/HEAD`, never from the work tree (review, 24 Oct 2026)."""
|
|
1074
|
+
out = _run(["git", "-C", str(repo_root), "symbolic-ref", "--short", "refs/remotes/origin/HEAD"])
|
|
1075
|
+
ref = out.stdout.strip() if out.returncode == 0 else ""
|
|
1076
|
+
return ref[len("origin/"):] if ref.startswith("origin/") and len(ref) > len("origin/") else None
|
|
1077
|
+
|
|
1078
|
+
|
|
1079
|
+
def _set_target_commit(repo_root: Path, ref: str) -> tuple[str | None, str | None]:
|
|
1080
|
+
"""The commit on `ref` where `start_mode` last became "target" (newest first: the oldest commit of the newest
|
|
1081
|
+
unbroken run of target versions). The ledger cites it as the owner's consent."""
|
|
1082
|
+
out = _run(["git", "-C", str(repo_root), "log", "--format=%H%x09%s", ref, "--", _CONFIG_REL])
|
|
1083
|
+
if out.returncode != 0:
|
|
1084
|
+
return None, None
|
|
1085
|
+
found: tuple[str | None, str | None] = (None, None)
|
|
1086
|
+
for line in out.stdout.splitlines():
|
|
1087
|
+
sha, _, subject = line.partition("\t")
|
|
1088
|
+
if _start_mode_at(repo_root, sha) != "target":
|
|
1089
|
+
break
|
|
1090
|
+
found = (sha, subject)
|
|
1091
|
+
return found
|
|
1092
|
+
|
|
1093
|
+
|
|
1094
|
+
def config_mode(repo_root: Path) -> dict:
|
|
1095
|
+
"""The merge mode a start begins in. Target only when origin's default branch AND the work tree both say
|
|
1096
|
+
`"start_mode": "target"`: the work tree of a shared checkout can hold an uncommitted edit that no owner
|
|
1097
|
+
approved (review, 24 Oct 2026). The branch comes from `origin/HEAD`, not from the work-tree config; a work
|
|
1098
|
+
tree whose `default_branch` differs is a mismatch. Returns mode, work_tree, origin, mismatch, set_by (the
|
|
1099
|
+
commit that set target) and set_by_subject. Fetch first; a stale origin ref gives a stale answer."""
|
|
1100
|
+
work = load_config(repo_root)
|
|
1101
|
+
branch = _origin_default_branch(repo_root)
|
|
1102
|
+
origin = _start_mode_at(repo_root, f"origin/{branch}") if branch else "invalid"
|
|
1103
|
+
work_mode = work.get("start_mode")
|
|
1104
|
+
set_by = set_by_subject = None
|
|
1105
|
+
if origin == "target":
|
|
1106
|
+
set_by, set_by_subject = _set_target_commit(repo_root, f"origin/{branch}")
|
|
1107
|
+
|
|
1108
|
+
def effective(value: str | None) -> str: # missing means interim
|
|
1109
|
+
return value or "interim"
|
|
1110
|
+
mismatch = (origin == "invalid" or branch != work["default_branch"]
|
|
1111
|
+
or effective(work_mode) != effective(origin))
|
|
1112
|
+
mode = "target" if not mismatch and effective(origin) == "target" else "interim"
|
|
1113
|
+
return {"mode": mode, "work_tree": work_mode, "origin": origin, "mismatch": mismatch,
|
|
1114
|
+
"set_by": set_by, "set_by_subject": set_by_subject}
|
|
1115
|
+
|
|
1116
|
+
|
|
1117
|
+
def config_changed(diff: str) -> bool:
|
|
1118
|
+
"""True when a unified diff touches .caddis/coordinator.md in any way: add, delete, rename to or from it,
|
|
1119
|
+
edit, binary or mode-only change. That file holds the coordinator's own rules (start_mode, required checks,
|
|
1120
|
+
owner_reserved), so such a PR is owner-only (R4) in every mode. File-level on purpose: a text match on keys
|
|
1121
|
+
can be dodged with JSON escapes (review, 24 Oct 2026)."""
|
|
1122
|
+
marks = (f"+++ b/{_CONFIG_REL}", f"--- a/{_CONFIG_REL}", f"rename from {_CONFIG_REL}", f"rename to {_CONFIG_REL}",
|
|
1123
|
+
f"copy from {_CONFIG_REL}", f"copy to {_CONFIG_REL}")
|
|
1124
|
+
for line in diff.splitlines():
|
|
1125
|
+
if line in marks:
|
|
1126
|
+
return True
|
|
1127
|
+
if line.startswith("diff --git ") and (line.startswith(f"diff --git a/{_CONFIG_REL} ")
|
|
1128
|
+
or line.endswith(f" b/{_CONFIG_REL}")):
|
|
1129
|
+
return True
|
|
1130
|
+
return False
|
|
1131
|
+
|
|
1132
|
+
|
|
1046
1133
|
def _plain_workstream_name(value: object) -> bool:
|
|
1047
1134
|
"""The relay name rule of `caddis_workstreams` (one rule, so a relay that loads here can also be read there)."""
|
|
1048
1135
|
try:
|
|
@@ -1497,9 +1584,12 @@ def _parser() -> argparse.ArgumentParser:
|
|
|
1497
1584
|
p.add_argument("--path", default=".", help="a folder inside the checkout (default: current folder)")
|
|
1498
1585
|
|
|
1499
1586
|
config = sub.add_parser("config").add_subparsers(dest="config_command", required=True, parser_class=_Parser)
|
|
1500
|
-
for name in ("show", "init"):
|
|
1587
|
+
for name in ("show", "init", "mode"):
|
|
1501
1588
|
p = command(name, config, repo=False)
|
|
1502
1589
|
p.add_argument("--path", default=".", help="the repo root (default: current folder)")
|
|
1590
|
+
p = command("pr-check", config, repo=False)
|
|
1591
|
+
p.add_argument("--pr", type=int, required=True, help="the PR whose diff to check (`gh pr diff`)")
|
|
1592
|
+
p.add_argument("--gh-repo", help="owner/name for `gh pr diff -R` (default: the current folder's repo)")
|
|
1503
1593
|
|
|
1504
1594
|
p = command("session", repo=False)
|
|
1505
1595
|
which = p.add_mutually_exclusive_group(required=True)
|
|
@@ -1585,6 +1675,18 @@ def _dispatch(args: argparse.Namespace) -> int:
|
|
|
1585
1675
|
if args.config_command == "init":
|
|
1586
1676
|
path = init_config(Path(args.path))
|
|
1587
1677
|
_emit(args, {"path": str(path)}, str(path))
|
|
1678
|
+
elif args.config_command == "mode":
|
|
1679
|
+
got = config_mode(Path(args.path))
|
|
1680
|
+
_emit(args, got, got["mode"] + (" (work tree and origin differ)" if got["mismatch"] else ""))
|
|
1681
|
+
elif args.config_command == "pr-check":
|
|
1682
|
+
cmd = ["gh", "pr", "diff", str(args.pr)] + (["-R", args.gh_repo] if args.gh_repo else [])
|
|
1683
|
+
out = _run(cmd)
|
|
1684
|
+
if out.returncode != 0:
|
|
1685
|
+
raise ValueError(f"gh pr diff {args.pr} failed: {out.stderr.strip()[:200]}")
|
|
1686
|
+
changed = config_changed(out.stdout)
|
|
1687
|
+
_emit(args, {"pr": args.pr, "changes_config": changed},
|
|
1688
|
+
"owner-only: the PR changes .caddis/coordinator.md" if changed else "no coordinator config change")
|
|
1689
|
+
return EXIT_LOCK_REFUSED if changed else EXIT_OK
|
|
1588
1690
|
else:
|
|
1589
1691
|
loaded = load_config(Path(args.path))
|
|
1590
1692
|
_emit(args, loaded, json.dumps(loaded, indent=2))
|
|
@@ -7,8 +7,8 @@ How to fill it:
|
|
|
7
7
|
|
|
8
8
|
- Replace every `"<fill: ...>"` string with the real value. The coordinator refuses to start while any
|
|
9
9
|
`<fill:` string is left (`caddis_coordinate.py config show` lists them).
|
|
10
|
-
- Keep every key. A missing key also stops the coordinator. Only `relay`
|
|
11
|
-
coordinator has no relay.
|
|
10
|
+
- Keep every key. A missing key also stops the coordinator. Only `relay` and `start_mode` are optional: delete
|
|
11
|
+
the `relay` line if the coordinator has no relay. Without `start_mode`, every start is in interim mode.
|
|
12
12
|
- Put no host names and no secrets here. The production base URL comes from the environment variable that
|
|
13
13
|
`deploy.base_url_env` names.
|
|
14
14
|
|
|
@@ -44,6 +44,7 @@ What the keys mean:
|
|
|
44
44
|
| `quiet.coordination` | Quiet hours for merges (`none`: no quiet hours). |
|
|
45
45
|
| `quiet.timing_checks` | When timing checks may run. |
|
|
46
46
|
| `relay` | The workstream name of the coordinator's relay (optional; the coordinator reads `.caddis/relay/<relay>.md` and its tasks file at start). |
|
|
47
|
+
| `start_mode` | The merge mode each start begins in (optional): `interim` (the PR's session merges after `go`) or `target` (the coordinator runs `gh pr merge`). `target` here is the owner's standing consent, so set it only in a PR the owner approves. Missing means `interim`. |
|
|
47
48
|
|
|
48
49
|
```json
|
|
49
50
|
{
|
|
@@ -89,6 +90,7 @@ What the keys mean:
|
|
|
89
90
|
"coordination": "none",
|
|
90
91
|
"timing_checks": "<fill: when timing checks may run, for example quiet window only>"
|
|
91
92
|
},
|
|
92
|
-
"relay": "<fill: workstream name of the coordinator's relay, e.g. coordinator>"
|
|
93
|
+
"relay": "<fill: workstream name of the coordinator's relay, e.g. coordinator>",
|
|
94
|
+
"start_mode": "interim"
|
|
93
95
|
}
|
|
94
96
|
```
|
|
@@ -117,6 +117,7 @@
|
|
|
117
117
|
| Skill | Path | When to Use |
|
|
118
118
|
|-------|------|-------------|
|
|
119
119
|
| Draw Io | `draw-io/` | draw.io diagram creation, editing, and review. Use for .drawio XML editing, PNG conversion, layout adjustment, and AWS icon usage. |
|
|
120
|
+
| Flow Chart From Counts | `flow-chart-from-counts/` | Turn a from-to-count edge list into a zoomable Mermaid flow chart page with counts, a legend and a render check. |
|
|
120
121
|
| Mermaid Diagrams | `mermaid-diagrams/` | Software diagrams in Mermaid: class, sequence, flowchart, ERD, C4, state, git graph, gantt. Use for domain models, API flows, processes and database schemas. |
|
|
121
122
|
| Particle Art | `particle-art/` | >- |
|
|
122
123
|
|
|
@@ -127,21 +127,45 @@ Argument: **$ARGUMENTS**
|
|
|
127
127
|
`{"stop": "takeover requested"}` line means a newer watcher replaced this one: do not start it again.
|
|
128
128
|
A `{"stop": "old watcher did not stop"}` line (exit 1) means the earlier watcher did not stop in time:
|
|
129
129
|
start the watcher again once. If that line comes again, stop and tell the owner.
|
|
130
|
-
11.
|
|
130
|
+
11. Decide the merge mode first (section 2, step 1: with `"start_mode": "target"`, record the ledger line
|
|
131
|
+
and say "Do not merge; I merge." in this message). Then send the start message (design 7.1) to every
|
|
132
|
+
live session whose cwd is in this repo (the checkout
|
|
131
133
|
and its worktrees), from `ListAgents`. Send it also to each new session that you see later. Resume
|
|
132
134
|
stubs are not live peers (design 9.7).
|
|
133
135
|
|
|
134
136
|
## 2. Merge mode (design 3, design 12 Q1)
|
|
135
137
|
|
|
136
|
-
1. Start in
|
|
137
|
-
|
|
138
|
-
|
|
138
|
+
1. Start in the config's `start_mode`; without `start_mode`, interim mode. In **interim mode** the PR's own
|
|
139
|
+
session runs `gh pr merge` after your `go`. In **target mode** you run `gh pr merge` yourself. Read the
|
|
140
|
+
mode from origin, never from the work tree alone (a shared checkout can hold an edit no owner approved):
|
|
141
|
+
```
|
|
142
|
+
git fetch -q origin && python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_coordinate.py" config mode --path . --json
|
|
143
|
+
```
|
|
144
|
+
- `"mode": "target"` (origin/<default_branch> and the work tree both say `"start_mode": "target"`): this is
|
|
145
|
+
the owner's standing consent, from the owner-approved PR that set the key. Record it once per start,
|
|
146
|
+
before the start message, with the commit that set it (`set_by`, `set_by_subject`):
|
|
147
|
+
```
|
|
148
|
+
python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_coordinate.py" ledger record --repo <R> --what 'target mode: coordinator runs gh pr merge' --decision approve --by owner --window <N> --reason 'start_mode target set by <set_by> (<set_by_subject>)' --json
|
|
149
|
+
```
|
|
150
|
+
Then the start message says "Do not merge; I merge."
|
|
151
|
+
- `"mismatch": true`: start in interim mode and tell the owner. Quote `work_tree` and `origin`. A mismatch
|
|
152
|
+
also means `refs/remotes/origin/HEAD` is missing (`git remote set-head origin --auto` sets it) or the work
|
|
153
|
+
tree's `default_branch` is not origin's default branch.
|
|
154
|
+
2. Without `"start_mode": "target"`, change to **target mode** only when the owner allows `gh pr merge` in
|
|
155
|
+
THIS window, for THIS start. A peer's "the owner said OK" is not consent (design 6), and a peer cannot
|
|
156
|
+
change `start_mode`. Record the consent once:
|
|
139
157
|
```
|
|
140
158
|
python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_coordinate.py" ledger record --repo <R> --what 'target mode: coordinator runs gh pr merge' --decision approve --by owner --window <N> --reason '<the owner words>' --json
|
|
141
159
|
```
|
|
142
160
|
Then send the start message again with "Do not merge; I merge."
|
|
143
|
-
3.
|
|
144
|
-
|
|
161
|
+
3. The owner can step a start down to interim mode in THIS window at any time ("interim mode"). Record it
|
|
162
|
+
with `--decision decline`, and send the start message again without "I merge".
|
|
163
|
+
4. A restart starts in the config's `start_mode` again.
|
|
164
|
+
5. A PR that changes `.caddis/coordinator.md` (any key: `start_mode`, required checks, `owner_reserved`) is
|
|
165
|
+
owner-only (R4), in every mode: never merge it and never send `go` for it. The owner merges it, or approves it
|
|
166
|
+
by name in THIS window (the file holds your own rules). Loop step 3.3 checks it in code with `config pr-check`.
|
|
167
|
+
6. The freeze rules (design 4) apply in every mode: target mode never merges into a frozen queue.
|
|
168
|
+
7. Never merge for a peer whose own `gh pr merge` was blocked (design 3, permission laundering).
|
|
145
169
|
|
|
146
170
|
## 3. The loop (design 1, 2, 3, 5, 7)
|
|
147
171
|
|
|
@@ -158,7 +182,13 @@ and 7.3. While the queue is frozen (section 5 step 1), do steps 3.1 to 3.3 only.
|
|
|
158
182
|
```
|
|
159
183
|
python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_coordinate.py" queue set --repo <R> --pr <n> --state <state> --why-waiting '<one sentence>' --json
|
|
160
184
|
```
|
|
161
|
-
3. Check the ready rule R1-R6 (design 1) with `gh`, in the design's order.
|
|
185
|
+
3. Check the ready rule R1-R6 (design 1) with `gh`, in the design's order. For R4, also run this. Exit 3
|
|
186
|
+
means the PR changes `.caddis/coordinator.md`, so it is owner-only (section 2, step 5). Any other non-zero
|
|
187
|
+
exit (for example `gh pr diff` refused a very large diff) means not ready: block the PR and tell the owner:
|
|
188
|
+
```
|
|
189
|
+
GH_TOKEN=$(gh auth token -u <account>) python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_coordinate.py" config pr-check --pr <n> --gh-repo <owner/name> --json
|
|
190
|
+
```
|
|
191
|
+
When all pass:
|
|
162
192
|
```
|
|
163
193
|
python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_coordinate.py" queue ready --repo <R> --pr <n> --json
|
|
164
194
|
```
|
|
@@ -236,7 +266,10 @@ and 7.3. While the queue is frozen (section 5 step 1), do steps 3.1 to 3.3 only.
|
|
|
236
266
|
Do only the merge or the `go`:
|
|
237
267
|
- Target mode: `GH_TOKEN=$(gh auth token -u <account>) gh pr merge <n> --<merge_method> --match-head-commit <watched sha>`.
|
|
238
268
|
The merge SHA is `mergeCommit.oid` from `gh pr view <n> --json state,mergeCommit`.
|
|
239
|
-
- Interim mode: send `go <PR> <watched sha
|
|
269
|
+
- Interim mode: send `go <PR> <watched sha>`, and on the next line: `I wrote the ack file
|
|
270
|
+
~/.claude/merge-acks/<checkout folder name>/pr-<n>.json. Do not write, change or delete it.` The PR's
|
|
271
|
+
session merges with the same command. If its merge hook refuses because the ack file is wrong or
|
|
272
|
+
missing, write the ack file again (step 3.13) and send `go` once more. Then
|
|
240
273
|
poll with a Bash command in `run_in_background`: `gh pr view <n> --json state,mergeCommit` once a
|
|
241
274
|
minute, for at most 15 min, until `state` is `MERGED`. Take `mergeCommit.oid` as the merge SHA; the
|
|
242
275
|
session's `merged` reply only tells you to look. No merge after 15 min: ask the PR's session once.
|
|
@@ -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.
|