@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.
Files changed (20) hide show
  1. package/bundles/antigravity-plugin/plugin.json +1 -1
  2. package/bundles/antigravity-plugin/scripts/caddis_coordinate.py +123 -6
  3. package/bundles/antigravity-plugin/scripts/caddis_fleet.py +46 -1
  4. package/bundles/antigravity-plugin/scripts/caddis_workstreams.py +9 -0
  5. package/bundles/antigravity-plugin/scripts/templates/coordinator.md +5 -2
  6. package/bundles/antigravity-plugin/skills/_registry.md +1 -0
  7. package/bundles/antigravity-plugin/skills/coordinate/SKILL.md +29 -5
  8. package/bundles/antigravity-plugin/skills/flow-chart-from-counts/SKILL.md +193 -0
  9. package/bundles/antigravity-plugin/skills/flow-chart-from-counts/assets/viewer-template.html +190 -0
  10. package/bundles/antigravity-plugin/skills/flow-chart-from-counts/references/edge-list-sql.md +105 -0
  11. package/bundles/antigravity-plugin/skills/flow-chart-from-counts/scripts/check-render.mjs +139 -0
  12. package/bundles/antigravity-plugin/skills/flow-chart-from-counts/scripts/edges_to_mermaid.py +418 -0
  13. package/bundles/antigravity-plugin/skills/handoff/SKILL.md +37 -2
  14. package/bundles/codex/.codex/skills/media/flow-chart-from-counts/SKILL.md +193 -0
  15. package/bundles/codex/.codex/skills/media/flow-chart-from-counts/assets/viewer-template.html +190 -0
  16. package/bundles/codex/.codex/skills/media/flow-chart-from-counts/references/edge-list-sql.md +105 -0
  17. package/bundles/codex/.codex/skills/media/flow-chart-from-counts/scripts/check-render.mjs +139 -0
  18. package/bundles/codex/.codex/skills/media/flow-chart-from-counts/scripts/edges_to_mermaid.py +418 -0
  19. package/bundles/manifest.json +2 -2
  20. package/package.json +1 -1
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "caddis",
3
- "version": "1.3.158",
3
+ "version": "1.3.160",
4
4
  "description": "caddis — agent-agnostic dev harness as an Antigravity (agy) plugin: TDD skills + commands, single-sourced with the Claude Code plugin."
5
5
  }
@@ -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, or a value is still a `<fill: ...>` placeholder."""
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
- def watch(rid: str, pid: int, interval: float, max_cycles: int | None = None) -> int:
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 the coordinator relay, `claude agents --json` (or `ListAgents`) and
83
- `caddis_roadmap.py status` (design 0.1 step 4).
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. A line
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>`; the PR's session merges with the same command. Then
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.