@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
|
@@ -23,12 +23,12 @@ from collections.abc import Callable
|
|
|
23
23
|
from pathlib import Path
|
|
24
24
|
|
|
25
25
|
try:
|
|
26
|
-
from caddis_file_lock import atomic_write, locked_path, retry_denied
|
|
26
|
+
from caddis_file_lock import _try_lock, _unlock, atomic_write, locked_path, retry_denied
|
|
27
27
|
except ModuleNotFoundError as exc:
|
|
28
28
|
if exc.name != "caddis_file_lock":
|
|
29
29
|
raise
|
|
30
30
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "claude-harness" / "scripts"))
|
|
31
|
-
from caddis_file_lock import atomic_write, locked_path, retry_denied
|
|
31
|
+
from caddis_file_lock import _try_lock, _unlock, atomic_write, locked_path, retry_denied
|
|
32
32
|
|
|
33
33
|
|
|
34
34
|
_TIMEOUT = 10
|
|
@@ -132,6 +132,14 @@ def _lock_path(rid: str) -> Path:
|
|
|
132
132
|
return _repo_dir(rid) / "coordinator.lock.json"
|
|
133
133
|
|
|
134
134
|
|
|
135
|
+
def _watcher_lock_path(rid: str) -> Path:
|
|
136
|
+
return _repo_dir(rid) / "watcher.lock"
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _watcher_stop_path(rid: str) -> Path:
|
|
140
|
+
return _repo_dir(rid) / "watcher.stop"
|
|
141
|
+
|
|
142
|
+
|
|
135
143
|
def read_state(rid: str) -> dict | None:
|
|
136
144
|
"""Return the last good parse of `coordinator.json`; None when it is missing or was never good.
|
|
137
145
|
|
|
@@ -367,6 +375,17 @@ def acquire_lock(rid: str, pid: int, session_id: str, name: str) -> tuple[bool,
|
|
|
367
375
|
return True, None
|
|
368
376
|
|
|
369
377
|
|
|
378
|
+
def lock_holder(rid: str) -> dict | None:
|
|
379
|
+
"""Return the lock holder with its `alive` flag, or None when no holder. Never takes or changes the lock.
|
|
380
|
+
|
|
381
|
+
Raise OSError when the lock file cannot be read (the caller fails closed)."""
|
|
382
|
+
holder = _read_holder(_lock_path(rid))
|
|
383
|
+
if holder is None:
|
|
384
|
+
return None
|
|
385
|
+
return {"pid": holder["pid"], "sessionId": holder.get("sessionId"), "name": holder.get("name"),
|
|
386
|
+
"started_at": holder.get("started_at"), "alive": _holder_alive(holder)}
|
|
387
|
+
|
|
388
|
+
|
|
370
389
|
def release_lock(rid: str, pid: int) -> None:
|
|
371
390
|
"""Remove the lock if `pid` holds it; do nothing otherwise."""
|
|
372
391
|
with locked_path(_lock_path(rid)) as path:
|
|
@@ -943,6 +962,7 @@ def session_lookup(sessions: list[dict], *, session_id: str | None = None, name:
|
|
|
943
962
|
|
|
944
963
|
CONFIG_KEYS = ("schema", "default_branch", "merge_method", "required_checks", "not_required", "docs_only",
|
|
945
964
|
"main_workflow", "reviews", "peer_ack", "deploy", "owner_reserved", "stall", "quiet")
|
|
965
|
+
OPTIONAL_CONFIG_KEYS = ("relay",) # the coordinator's own relay: `.caddis/relay/<relay>.md`
|
|
946
966
|
CONFIG_SCHEMA = 1
|
|
947
967
|
_PLACEHOLDER = "<fill:"
|
|
948
968
|
_FENCE = re.compile(r"^ {0,3}(`{3,}|~{3,})[ \t]*([^\s`]*)") # a Markdown code fence and its info string
|
|
@@ -998,7 +1018,8 @@ def load_config(repo_root: Path) -> dict:
|
|
|
998
1018
|
"""Read `<repo_root>/.caddis/coordinator.md` and return its json block.
|
|
999
1019
|
|
|
1000
1020
|
Raise FileNotFoundError when the file is missing, ValueError when there is no json block, a key is missing,
|
|
1001
|
-
`schema` is not 1,
|
|
1021
|
+
`schema` is not 1, a value is still a `<fill: ...>` placeholder, or the optional `relay` is not a plain
|
|
1022
|
+
workstream name (`caddis_workstreams.is_plain_name`: lower case `a-z0-9_-`, no Windows device name)."""
|
|
1002
1023
|
path = config_path(repo_root)
|
|
1003
1024
|
try:
|
|
1004
1025
|
text = path.read_text(encoding="utf-8")
|
|
@@ -1016,9 +1037,24 @@ def load_config(repo_root: Path) -> dict:
|
|
|
1016
1037
|
unfilled = _placeholders(config)
|
|
1017
1038
|
if unfilled:
|
|
1018
1039
|
raise ValueError(f"the config still has {_PLACEHOLDER} placeholders at: {', '.join(unfilled)}")
|
|
1040
|
+
if "relay" in config and not _plain_workstream_name(config["relay"]):
|
|
1041
|
+
raise ValueError(f"the config relay must be a plain workstream name such as coordinator, "
|
|
1042
|
+
f"not {config['relay']!r}")
|
|
1019
1043
|
return config
|
|
1020
1044
|
|
|
1021
1045
|
|
|
1046
|
+
def _plain_workstream_name(value: object) -> bool:
|
|
1047
|
+
"""The relay name rule of `caddis_workstreams` (one rule, so a relay that loads here can also be read there)."""
|
|
1048
|
+
try:
|
|
1049
|
+
import caddis_workstreams
|
|
1050
|
+
except ModuleNotFoundError as exc:
|
|
1051
|
+
if exc.name != "caddis_workstreams":
|
|
1052
|
+
raise
|
|
1053
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
1054
|
+
import caddis_workstreams
|
|
1055
|
+
return caddis_workstreams.is_plain_name(value)
|
|
1056
|
+
|
|
1057
|
+
|
|
1022
1058
|
def init_config(repo_root: Path) -> Path:
|
|
1023
1059
|
"""Copy the template to `<repo_root>/.caddis/coordinator.md` when it is absent. Never overwrite."""
|
|
1024
1060
|
path = config_path(repo_root)
|
|
@@ -1078,10 +1114,79 @@ def watch_cycle(rid: str, pid: int, now: datetime.datetime, head_of: Callable[[i
|
|
|
1078
1114
|
return {"stop": None, "results": results, "error": None}
|
|
1079
1115
|
|
|
1080
1116
|
|
|
1081
|
-
|
|
1117
|
+
_TAKEOVER_MAX_S = 150.0 # the longest a `--takeover` waits for the old watcher
|
|
1118
|
+
_TAKEOVER_POLL_S = 0.2
|
|
1119
|
+
|
|
1120
|
+
|
|
1121
|
+
def _print_line(value: dict) -> None:
|
|
1122
|
+
print(json.dumps(value, separators=(",", ":")), flush=True)
|
|
1123
|
+
|
|
1124
|
+
|
|
1125
|
+
def _takeover_wait(interval: float) -> float:
|
|
1126
|
+
"""The old watcher checks the stop file right after each sleep, and one cycle can run long (gh calls): wait one
|
|
1127
|
+
interval plus 30 s, at most `_TAKEOVER_MAX_S`."""
|
|
1128
|
+
return min(interval + 30, _TAKEOVER_MAX_S)
|
|
1129
|
+
|
|
1130
|
+
|
|
1131
|
+
def _remove_stop_file(rid: str) -> None:
|
|
1132
|
+
retry_denied(lambda: _watcher_stop_path(rid).unlink(missing_ok=True))
|
|
1133
|
+
|
|
1134
|
+
|
|
1135
|
+
def _remove_own_stop_file(rid: str) -> None:
|
|
1136
|
+
"""Remove the stop file only when this process wrote it: a newer takeover's request stays."""
|
|
1137
|
+
try:
|
|
1138
|
+
owner = json.loads(_watcher_stop_path(rid).read_text(encoding="utf-8")).get("pid")
|
|
1139
|
+
except (OSError, ValueError, AttributeError):
|
|
1140
|
+
return
|
|
1141
|
+
if owner == os.getpid():
|
|
1142
|
+
_remove_stop_file(rid)
|
|
1143
|
+
|
|
1144
|
+
|
|
1145
|
+
def watch(rid: str, pid: int, interval: float, max_cycles: int | None = None, takeover: bool = False) -> int:
|
|
1082
1146
|
"""Run `watch_cycle` every `interval` seconds. Print one JSON line (flushed) for each cycle with results or a
|
|
1083
1147
|
stop, and for an error that differs from the previous cycle's error; print nothing for a quiet cycle or a
|
|
1084
|
-
repeated error. Return 0 on a stop or after `max_cycles` cycles.
|
|
1148
|
+
repeated error. Return 0 on a stop or after `max_cycles` cycles.
|
|
1149
|
+
|
|
1150
|
+
One watcher per repo: the loop holds the OS lock on `<hub>/repos/<rid>/watcher.lock` for its whole life (the OS
|
|
1151
|
+
frees it when the process dies). When another watcher holds it:
|
|
1152
|
+
- without `takeover`, print `{"stop": "another watcher is running"}` and return 0 at once;
|
|
1153
|
+
- with `takeover`, write `<hub>/repos/<rid>/watcher.stop` and wait (`_takeover_wait`) for the lock. The old
|
|
1154
|
+
watcher sees the stop file right after its next sleep, prints `{"stop": "takeover requested"}` and ends.
|
|
1155
|
+
A `finally` removes this process's own stop file (on success, timeout or an exception; a newer takeover's
|
|
1156
|
+
file stays). When the lock does not come in time, print `{"stop": "old watcher did not stop"}` and
|
|
1157
|
+
return 1. After a `/clear` the old watcher runs on in the same process, but its output belongs to the old
|
|
1158
|
+
conversation: takeover gives the new conversation a watcher whose output it can read.
|
|
1159
|
+
The watcher that gets the lock removes any stop file before its first cycle. When the repo folder does not
|
|
1160
|
+
exist, no lock or stop file is made: the first cycle then stops (it never recreates the folder)."""
|
|
1161
|
+
path = _watcher_lock_path(rid)
|
|
1162
|
+
if not path.parent.is_dir():
|
|
1163
|
+
return _watch_loop(rid, pid, interval, max_cycles)
|
|
1164
|
+
flags = os.O_RDWR | os.O_CREAT | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_BINARY", 0)
|
|
1165
|
+
with os.fdopen(os.open(path, flags, 0o666), "r+b") as handle:
|
|
1166
|
+
held = _try_lock(handle)
|
|
1167
|
+
if not held and not takeover:
|
|
1168
|
+
_print_line({"stop": "another watcher is running"})
|
|
1169
|
+
return EXIT_OK
|
|
1170
|
+
if not held:
|
|
1171
|
+
try:
|
|
1172
|
+
atomic_write(_watcher_stop_path(rid), json.dumps({"pid": os.getpid(), "at": _now()}) + "\n")
|
|
1173
|
+
deadline = time.monotonic() + _takeover_wait(interval)
|
|
1174
|
+
while not held and time.monotonic() < deadline:
|
|
1175
|
+
time.sleep(_TAKEOVER_POLL_S)
|
|
1176
|
+
held = _try_lock(handle)
|
|
1177
|
+
finally: # on success, on timeout and on an exception: never leave this process's request behind
|
|
1178
|
+
_remove_own_stop_file(rid)
|
|
1179
|
+
if not held:
|
|
1180
|
+
_print_line({"stop": "old watcher did not stop"})
|
|
1181
|
+
return EXIT_ERROR
|
|
1182
|
+
try:
|
|
1183
|
+
_remove_stop_file(rid)
|
|
1184
|
+
return _watch_loop(rid, pid, interval, max_cycles)
|
|
1185
|
+
finally:
|
|
1186
|
+
_unlock(handle)
|
|
1187
|
+
|
|
1188
|
+
|
|
1189
|
+
def _watch_loop(rid: str, pid: int, interval: float, max_cycles: int | None) -> int:
|
|
1085
1190
|
head_of = _gh_head_of(rid)
|
|
1086
1191
|
cycles = 0
|
|
1087
1192
|
last_error: str | None = None
|
|
@@ -1098,6 +1203,9 @@ def watch(rid: str, pid: int, interval: float, max_cycles: int | None = None) ->
|
|
|
1098
1203
|
if max_cycles is not None and cycles >= max_cycles:
|
|
1099
1204
|
return EXIT_OK
|
|
1100
1205
|
time.sleep(interval)
|
|
1206
|
+
if _watcher_stop_path(rid).exists(): # right after the sleep: a newer watcher asked for the lock
|
|
1207
|
+
_print_line({"stop": "takeover requested"})
|
|
1208
|
+
return EXIT_OK
|
|
1101
1209
|
except KeyboardInterrupt:
|
|
1102
1210
|
return EXIT_OK
|
|
1103
1211
|
|
|
@@ -1405,6 +1513,7 @@ def _parser() -> argparse.ArgumentParser:
|
|
|
1405
1513
|
p.add_argument("--name", required=True)
|
|
1406
1514
|
p = command("release", lock)
|
|
1407
1515
|
p.add_argument("--pid", type=int, required=True)
|
|
1516
|
+
command("holder", lock)
|
|
1408
1517
|
|
|
1409
1518
|
command("heartbeat")
|
|
1410
1519
|
command("intake")
|
|
@@ -1412,6 +1521,8 @@ def _parser() -> argparse.ArgumentParser:
|
|
|
1412
1521
|
p.add_argument("--pid", type=int, required=True, help="the coordinator session pid that holds the lock")
|
|
1413
1522
|
p.add_argument("--interval", type=float, default=30.0, help="seconds between cycles (default 30)")
|
|
1414
1523
|
p.add_argument("--max-cycles", type=int, help="stop after this many cycles (for tests)")
|
|
1524
|
+
p.add_argument("--takeover", action="store_true",
|
|
1525
|
+
help="ask a running watcher of this repo to stop, then take its place (after a /clear)")
|
|
1415
1526
|
command("state")
|
|
1416
1527
|
|
|
1417
1528
|
queue = sub.add_parser("queue").add_subparsers(dest="queue_command", required=True, parser_class=_Parser)
|
|
@@ -1491,6 +1602,12 @@ def _dispatch(args: argparse.Namespace) -> int:
|
|
|
1491
1602
|
_claim_state(rid, {"name": args.name, "sessionId": args.session_id, "pid": args.pid})
|
|
1492
1603
|
_emit(args, {"acquired": True, "holder": None}, "acquired")
|
|
1493
1604
|
return EXIT_OK
|
|
1605
|
+
if args.lock_command == "holder":
|
|
1606
|
+
holder = lock_holder(rid)
|
|
1607
|
+
_emit(args, holder or {"holder": None}, "no holder" if holder is None else
|
|
1608
|
+
f"pid {holder['pid']} ({holder['name']}, session {holder['sessionId']}, "
|
|
1609
|
+
f"{'alive' if holder['alive'] else 'not alive'})")
|
|
1610
|
+
return EXIT_OK
|
|
1494
1611
|
release_lock(rid, args.pid)
|
|
1495
1612
|
_emit(args, {"released": True}, "released")
|
|
1496
1613
|
return EXIT_OK
|
|
@@ -1501,7 +1618,7 @@ def _dispatch(args: argparse.Namespace) -> int:
|
|
|
1501
1618
|
_emit(args, {"ok": True}, "ok")
|
|
1502
1619
|
return EXIT_OK
|
|
1503
1620
|
if args.command == "watch":
|
|
1504
|
-
return watch(rid, args.pid, args.interval, args.max_cycles)
|
|
1621
|
+
return watch(rid, args.pid, args.interval, args.max_cycles, takeover=args.takeover)
|
|
1505
1622
|
if args.command == "intake":
|
|
1506
1623
|
results = intake(rid, datetime.datetime.now(datetime.timezone.utc), _gh_head_of(rid))
|
|
1507
1624
|
lines = [f"{r['id']}: {r['result']} {r['reason']}".rstrip() for r in results]
|
|
@@ -482,6 +482,17 @@ def render_resume(session: str, session_id: str, workstream: str, prompt: str, r
|
|
|
482
482
|
f"{_fenced(shown)}\n{start}\n")
|
|
483
483
|
|
|
484
484
|
|
|
485
|
+
def render_coordinator_resume(session: str, session_id: str, workstream: str) -> str:
|
|
486
|
+
"""The resume block for the repo's coordinator: three lines to type, no prompt and no start line.
|
|
487
|
+
|
|
488
|
+
`/caddis:coordinate start` reads the coordinator's relay and tasks itself, so a pasted prompt would only race it."""
|
|
489
|
+
return (f"=== RESUME ===\nSession: {session} ({session_id})\nWorkstream: {workstream}\n"
|
|
490
|
+
"This session is the repo's coordinator. To resume, type these three lines in this window, one at a time:\n"
|
|
491
|
+
"/clear\n/rename coordinator\n/caddis:coordinate start\n"
|
|
492
|
+
f"Do not paste a resume prompt; /caddis:coordinate start reads .caddis/relay/{workstream}.md "
|
|
493
|
+
"and its tasks itself.\n")
|
|
494
|
+
|
|
495
|
+
|
|
485
496
|
# ---- CLI --------------------------------------------------------------------------------------
|
|
486
497
|
|
|
487
498
|
def _load_acks(fleet_dir: Path) -> list[Ack]:
|
|
@@ -491,6 +502,28 @@ def _load_acks(fleet_dir: Path) -> list[Ack]:
|
|
|
491
502
|
return [parse_ack(p.read_text(encoding="utf-8")) for p in sorted(acks_dir.glob("*.md"))]
|
|
492
503
|
|
|
493
504
|
|
|
505
|
+
def _coordinator_relay_problem(root: Path, name: str) -> str | None:
|
|
506
|
+
"""None when `<root>/.caddis/coordinator.md` loads and its `relay` is `name`, else one line that says why not.
|
|
507
|
+
|
|
508
|
+
The coordinator block says `/caddis:coordinate start` reads `.caddis/relay/<name>.md` itself; start reads the
|
|
509
|
+
config's `relay`, so the block is true only when the two are the same name."""
|
|
510
|
+
try:
|
|
511
|
+
import caddis_coordinate
|
|
512
|
+
except ModuleNotFoundError as exc:
|
|
513
|
+
if exc.name != "caddis_coordinate":
|
|
514
|
+
raise
|
|
515
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
516
|
+
import caddis_coordinate
|
|
517
|
+
try:
|
|
518
|
+
relay = caddis_coordinate.load_config(root).get("relay")
|
|
519
|
+
except (OSError, ValueError) as exc: # FileNotFoundError is an OSError
|
|
520
|
+
return f"--coordinator needs a valid .caddis/coordinator.md with relay {name!r}: {' '.join(str(exc).split())}"
|
|
521
|
+
if relay != name:
|
|
522
|
+
return (f"--coordinator: --name {name!r} is not the coordinator config relay {relay!r}"
|
|
523
|
+
if relay else f"--coordinator: .caddis/coordinator.md has no relay (needs relay {name!r})")
|
|
524
|
+
return None
|
|
525
|
+
|
|
526
|
+
|
|
494
527
|
def _resume_block_cli(args: argparse.Namespace) -> int:
|
|
495
528
|
try:
|
|
496
529
|
import caddis_workstreams
|
|
@@ -500,15 +533,23 @@ def _resume_block_cli(args: argparse.Namespace) -> int:
|
|
|
500
533
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
501
534
|
import caddis_workstreams
|
|
502
535
|
root = args.root.resolve()
|
|
536
|
+
if args.coordinator:
|
|
537
|
+
problem = _coordinator_relay_problem(root, args.name)
|
|
538
|
+
if problem:
|
|
539
|
+
sys.stderr.write(f"resume-block failed: {problem}\n")
|
|
540
|
+
return caddis_exit.BLOCKED
|
|
503
541
|
try:
|
|
504
542
|
text, digest = caddis_workstreams.read_relay(root, args.name)
|
|
505
543
|
if digest is None:
|
|
506
544
|
raise ResumeError(f"relay {args.name!r} not found")
|
|
507
|
-
prompt = resume_prompt(text)
|
|
545
|
+
prompt = "" if args.coordinator else resume_prompt(text)
|
|
508
546
|
except (ValueError, OSError) as exc: # ResumeError is a ValueError; a bad relay name is one too
|
|
509
547
|
sys.stderr.write(f"resume-block failed: {exc}\n")
|
|
510
548
|
return caddis_exit.BLOCKED
|
|
511
549
|
session, known = session_name(args.session_id, root)
|
|
550
|
+
if args.coordinator:
|
|
551
|
+
print(render_coordinator_resume(session, args.session_id, args.name), end="")
|
|
552
|
+
return caddis_exit.CLEAN
|
|
512
553
|
role = args.role or (role_of(session) if known else args.name)
|
|
513
554
|
if not _SAFE_NAME.match(role): # the role goes unquoted into the start line
|
|
514
555
|
sys.stderr.write(f"resume-block failed: bad role {role!r}\n")
|
|
@@ -553,7 +594,11 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
553
594
|
p.add_argument("--root", default=".", type=Path)
|
|
554
595
|
p.add_argument("--role", help="role for the new session's name (default: the session name's role, else the relay name)")
|
|
555
596
|
p.add_argument("--fleet", action="store_true", help="fleet close: print no start line (the restart sheet has it)")
|
|
597
|
+
p.add_argument("--coordinator", action="store_true",
|
|
598
|
+
help="this session holds the repo's coordinator lock: print the three restart lines, no prompt")
|
|
556
599
|
args = parser.parse_args(argv)
|
|
600
|
+
if args.cmd == "resume-block" and args.coordinator and args.role:
|
|
601
|
+
parser.error("resume-block: --role cannot go with --coordinator (the coordinator keeps its name)")
|
|
557
602
|
|
|
558
603
|
if args.cmd == "role-of":
|
|
559
604
|
print(role_of(args.name))
|
|
@@ -46,6 +46,15 @@ def _name(value: str) -> str:
|
|
|
46
46
|
return value
|
|
47
47
|
|
|
48
48
|
|
|
49
|
+
def is_plain_name(value: object) -> bool:
|
|
50
|
+
"""True for a plain workstream name as typed (no lower-casing, no `branch.` namespace), e.g. `coordinator`."""
|
|
51
|
+
try:
|
|
52
|
+
_name(value) # type: ignore[arg-type]
|
|
53
|
+
except ValueError:
|
|
54
|
+
return False
|
|
55
|
+
return bool(_PLAIN_NAME.fullmatch(value)) # type: ignore[arg-type]
|
|
56
|
+
|
|
57
|
+
|
|
49
58
|
def _plain_name(value: str) -> str:
|
|
50
59
|
if not isinstance(value, str):
|
|
51
60
|
raise TypeError("plan or suggestion must be text")
|
|
@@ -7,7 +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.
|
|
10
|
+
- Keep every key. A missing key also stops the coordinator. Only `relay` is optional: delete its line if the
|
|
11
|
+
coordinator has no relay.
|
|
11
12
|
- Put no host names and no secrets here. The production base URL comes from the environment variable that
|
|
12
13
|
`deploy.base_url_env` names.
|
|
13
14
|
|
|
@@ -42,6 +43,7 @@ What the keys mean:
|
|
|
42
43
|
| `stall.ready_no_ready_msg_min` | Minutes a green PR may wait for its session's ready message. |
|
|
43
44
|
| `quiet.coordination` | Quiet hours for merges (`none`: no quiet hours). |
|
|
44
45
|
| `quiet.timing_checks` | When timing checks may run. |
|
|
46
|
+
| `relay` | The workstream name of the coordinator's relay (optional; the coordinator reads `.caddis/relay/<relay>.md` and its tasks file at start). |
|
|
45
47
|
|
|
46
48
|
```json
|
|
47
49
|
{
|
|
@@ -86,6 +88,7 @@ What the keys mean:
|
|
|
86
88
|
"quiet": {
|
|
87
89
|
"coordination": "none",
|
|
88
90
|
"timing_checks": "<fill: when timing checks may run, for example quiet window only>"
|
|
89
|
-
}
|
|
91
|
+
},
|
|
92
|
+
"relay": "<fill: workstream name of the coordinator's relay, e.g. coordinator>"
|
|
90
93
|
}
|
|
91
94
|
```
|
|
@@ -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
|
|
|
@@ -19,6 +19,13 @@ Argument: **$ARGUMENTS**
|
|
|
19
19
|
`queue next` result. Change nothing.
|
|
20
20
|
- `stop`: do **6. Stop**.
|
|
21
21
|
|
|
22
|
+
## Restart and resume
|
|
23
|
+
|
|
24
|
+
- Planned restart: run `/caddis:handoff`, `/clear`, `/rename coordinator`, `/caddis:coordinate start`, in
|
|
25
|
+
that order, in this window.
|
|
26
|
+
- After a crash: run `/caddis:coordinate start` only (first `/rename coordinator` if the name is lost).
|
|
27
|
+
- Do not paste a resume prompt. Start reads the coordinator's relay and tasks itself (step 1.8).
|
|
28
|
+
|
|
22
29
|
## How to call the script
|
|
23
30
|
|
|
24
31
|
- Run these commands with the Bash tool: the quoting and the `GH_TOKEN=$(...)` forms are POSIX shell.
|
|
@@ -55,6 +62,8 @@ Argument: **$ARGUMENTS**
|
|
|
55
62
|
Keep `pid` as `<P>` and `name` as `<N>`. Exit 1 with "two interactive sessions share" means two
|
|
56
63
|
copies of this session run (design 8.6): stop, tell the owner both pids, and wait. Any other exit 1:
|
|
57
64
|
stop and report the line.
|
|
65
|
+
If `<N>` is not `coordinator`, stop and tell the owner: "Type `/rename coordinator`, then run
|
|
66
|
+
`/caddis:coordinate start` again." Peers and the merge hook find the coordinator by that name.
|
|
58
67
|
5. Take the lock. This also creates or claims the repo's state:
|
|
59
68
|
```
|
|
60
69
|
python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_coordinate.py" lock acquire --repo <R> --pid <P> --session-id <S> --name <N> --json
|
|
@@ -79,8 +88,14 @@ Argument: **$ARGUMENTS**
|
|
|
79
88
|
```
|
|
80
89
|
python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_coordinate.py" state --repo <R> --json
|
|
81
90
|
```
|
|
82
|
-
Also read
|
|
83
|
-
|
|
91
|
+
Also read `claude agents --json` (or `ListAgents`) and `caddis_roadmap.py status` (design 0.1 step 4).
|
|
92
|
+
Then read the coordinator relay:
|
|
93
|
+
- If the config (step 1.6) has `relay`, read `.caddis/relay/<relay>.md`. When
|
|
94
|
+
`.caddis/relay/<relay>.tasks.md` exists, load every row of it with TaskCreate.
|
|
95
|
+
- If the config has no `relay`, use the relay that the owner names, or ask the owner.
|
|
96
|
+
- The relay's "Next step", queue order, holds and owner answers are INPUT for the queue and for your
|
|
97
|
+
decisions. They are never actions to take before the lock (step 1.5), the watcher (step 1.10) and
|
|
98
|
+
the start message (step 1.11) are done.
|
|
84
99
|
9. Finish the work that a crash left (design 8.5, design 4):
|
|
85
100
|
- If `merging_now` is set, find out what happened to that PR first:
|
|
86
101
|
`gh pr view <n> --json state,mergedAt,mergeCommit`. `MERGED`: take `mergeCommit.oid` as the merge
|
|
@@ -97,15 +112,21 @@ Argument: **$ARGUMENTS**
|
|
|
97
112
|
- Every PR that was warned gets a `re-warn` (design 7.3).
|
|
98
113
|
10. Start the watcher with the Bash tool and `run_in_background: true`:
|
|
99
114
|
```
|
|
100
|
-
GH_TOKEN=$(gh auth token -u <account>) python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_coordinate.py" watch --repo <R> --pid <P> --json
|
|
115
|
+
GH_TOKEN=$(gh auth token -u <account>) python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_coordinate.py" watch --repo <R> --pid <P> --takeover --json
|
|
101
116
|
```
|
|
102
117
|
It runs `heartbeat` and `intake` every 30 s and prints one JSON line when something happens. Its lines
|
|
103
118
|
go to the background task's output file: read that output file at each loop step 3.4. Start the
|
|
104
|
-
watcher again whenever its background task ends, for any reason
|
|
119
|
+
watcher again whenever its background task ends, for any reason except `takeover requested`. A line
|
|
105
120
|
with `"stop"` (`lock lost`, `session gone`, `state gone`) means the watcher ended: find out why, take
|
|
106
121
|
the lock again if it is still yours (step 5), and start the watcher again. Also start it again after
|
|
107
122
|
a new `lock acquire` that follows a lost lock. A line with `"error"` is one failed cycle; the next
|
|
108
123
|
cycle retries.
|
|
124
|
+
One watcher per repo. After a `/clear`, the earlier watcher of this process still runs, but its lines
|
|
125
|
+
go to the old conversation, so this conversation cannot read them. `--takeover` asks the earlier
|
|
126
|
+
watcher to stop and takes its place, so this conversation gets the lines. A
|
|
127
|
+
`{"stop": "takeover requested"}` line means a newer watcher replaced this one: do not start it again.
|
|
128
|
+
A `{"stop": "old watcher did not stop"}` line (exit 1) means the earlier watcher did not stop in time:
|
|
129
|
+
start the watcher again once. If that line comes again, stop and tell the owner.
|
|
109
130
|
11. Send the start message (design 7.1) to every live session whose cwd is in this repo (the checkout
|
|
110
131
|
and its worktrees), from `ListAgents`. Send it also to each new session that you see later. Resume
|
|
111
132
|
stubs are not live peers (design 9.7).
|
|
@@ -215,7 +236,10 @@ and 7.3. While the queue is frozen (section 5 step 1), do steps 3.1 to 3.3 only.
|
|
|
215
236
|
Do only the merge or the `go`:
|
|
216
237
|
- Target mode: `GH_TOKEN=$(gh auth token -u <account>) gh pr merge <n> --<merge_method> --match-head-commit <watched sha>`.
|
|
217
238
|
The merge SHA is `mergeCommit.oid` from `gh pr view <n> --json state,mergeCommit`.
|
|
218
|
-
- Interim mode: send `go <PR> <watched sha
|
|
239
|
+
- Interim mode: send `go <PR> <watched sha>`, and on the next line: `I wrote the ack file
|
|
240
|
+
~/.claude/merge-acks/<checkout folder name>/pr-<n>.json. Do not write, change or delete it.` The PR's
|
|
241
|
+
session merges with the same command. If its merge hook refuses because the ack file is wrong or
|
|
242
|
+
missing, write the ack file again (step 3.13) and send `go` once more. Then
|
|
219
243
|
poll with a Bash command in `run_in_background`: `gh pr view <n> --json state,mergeCommit` once a
|
|
220
244
|
minute, for at most 15 min, until `state` is `MERGED`. Take `mergeCommit.oid` as the merge SHA; the
|
|
221
245
|
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.
|