swarph-cli 0.41.3__tar.gz → 0.41.7__tar.gz

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 (235) hide show
  1. {swarph_cli-0.41.3/src/swarph_cli.egg-info → swarph_cli-0.41.7}/PKG-INFO +30 -2
  2. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/README.md +28 -0
  3. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/pyproject.toml +18 -2
  4. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/src/swarph_cli/__init__.py +1 -1
  5. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/src/swarph_cli/commands/board.py +176 -6
  6. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/src/swarph_cli/commands/daemon.py +15 -1
  7. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/src/swarph_cli/commands/mesh.py +60 -64
  8. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/src/swarph_cli/commands/monitor.py +2 -1
  9. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/src/swarph_cli/commands/onboard.py +130 -14
  10. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/src/swarph_cli/commands/ratify.py +7 -2
  11. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/src/swarph_cli/commands/spawn.py +405 -49
  12. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/src/swarph_cli/gateway/lanes_control.py +7 -1
  13. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/src/swarph_cli/main.py +15 -4
  14. swarph_cli-0.41.7/src/swarph_cli/tokens.py +127 -0
  15. {swarph_cli-0.41.3 → swarph_cli-0.41.7/src/swarph_cli.egg-info}/PKG-INFO +30 -2
  16. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/src/swarph_cli.egg-info/SOURCES.txt +6 -0
  17. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/src/swarph_cli.egg-info/requires.txt +1 -1
  18. swarph_cli-0.41.7/tests/test_243_onboard_per_peer_token.py +86 -0
  19. swarph_cli-0.41.7/tests/test_84_cli_labels.py +81 -0
  20. swarph_cli-0.41.7/tests/test_board_card_thread.py +171 -0
  21. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/tests/test_brain_ask_command.py +2 -2
  22. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/tests/test_monitor_state_split.py +21 -0
  23. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/tests/test_onboard_command.py +23 -7
  24. swarph_cli-0.41.7/tests/test_spawn_base_hoist.py +206 -0
  25. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/tests/test_spawn_command.py +14 -5
  26. swarph_cli-0.41.7/tests/test_token_precedence_explicit_first.py +219 -0
  27. swarph_cli-0.41.7/tests/test_vibe_membrane.py +324 -0
  28. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/tests/test_watchdog.py +2 -2
  29. swarph_cli-0.41.3/tests/test_spawn_base_hoist.py +0 -79
  30. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/LICENSE +0 -0
  31. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/setup.cfg +0 -0
  32. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/src/swarph_cli/bench/__init__.py +0 -0
  33. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/src/swarph_cli/bench/backends.py +0 -0
  34. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/src/swarph_cli/bench/pack.py +0 -0
  35. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/src/swarph_cli/bench/prices.py +0 -0
  36. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/src/swarph_cli/bench/quality.py +0 -0
  37. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/src/swarph_cli/bench/refresh_prices.py +0 -0
  38. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/src/swarph_cli/bench/runner.py +0 -0
  39. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/src/swarph_cli/bench/validate.py +0 -0
  40. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/src/swarph_cli/caller.py +0 -0
  41. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/src/swarph_cli/capture/__init__.py +0 -0
  42. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/src/swarph_cli/capture/harden.py +0 -0
  43. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/src/swarph_cli/capture/lineage.py +0 -0
  44. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/src/swarph_cli/capture/liveness.py +0 -0
  45. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/src/swarph_cli/capture/manifest.py +0 -0
  46. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/src/swarph_cli/capture/paths.py +0 -0
  47. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/src/swarph_cli/capture/verify.py +0 -0
  48. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/src/swarph_cli/cell.py +0 -0
  49. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/src/swarph_cli/chain_token.py +0 -0
  50. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/src/swarph_cli/commands/__init__.py +0 -0
  51. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/src/swarph_cli/commands/_display.py +0 -0
  52. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/src/swarph_cli/commands/_gateway_client.py +0 -0
  53. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/src/swarph_cli/commands/add.py +0 -0
  54. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/src/swarph_cli/commands/bench.py +0 -0
  55. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/src/swarph_cli/commands/board_merge_check.py +0 -0
  56. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/src/swarph_cli/commands/brain.py +0 -0
  57. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/src/swarph_cli/commands/brain_ask.py +0 -0
  58. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/src/swarph_cli/commands/cell.py +0 -0
  59. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/src/swarph_cli/commands/cell_selfcheck.py +0 -0
  60. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/src/swarph_cli/commands/channel.py +0 -0
  61. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/src/swarph_cli/commands/chat.py +0 -0
  62. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/src/swarph_cli/commands/codegraph.py +0 -0
  63. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/src/swarph_cli/commands/codegraph_hook.py +0 -0
  64. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/src/swarph_cli/commands/compress.py +0 -0
  65. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/src/swarph_cli/commands/event.py +0 -0
  66. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/src/swarph_cli/commands/gateway.py +0 -0
  67. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/src/swarph_cli/commands/group.py +0 -0
  68. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/src/swarph_cli/commands/highlight.py +0 -0
  69. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/src/swarph_cli/commands/hook_output.py +0 -0
  70. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/src/swarph_cli/commands/hooks.py +0 -0
  71. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/src/swarph_cli/commands/import_session.py +0 -0
  72. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/src/swarph_cli/commands/init.py +0 -0
  73. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/src/swarph_cli/commands/install_hook.py +0 -0
  74. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/src/swarph_cli/commands/install_multiplexer.py +0 -0
  75. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/src/swarph_cli/commands/lane.py +0 -0
  76. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/src/swarph_cli/commands/mcp_server.py +0 -0
  77. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/src/swarph_cli/commands/memory.py +0 -0
  78. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/src/swarph_cli/commands/memory_sync.py +0 -0
  79. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/src/swarph_cli/commands/okf_links.py +0 -0
  80. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/src/swarph_cli/commands/protocol_handler.py +0 -0
  81. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/src/swarph_cli/commands/schedule.py +0 -0
  82. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/src/swarph_cli/commands/security.py +0 -0
  83. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/src/swarph_cli/commands/service.py +0 -0
  84. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/src/swarph_cli/commands/timeline.py +0 -0
  85. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/src/swarph_cli/commands/watchdog.py +0 -0
  86. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/src/swarph_cli/compress/__init__.py +0 -0
  87. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/src/swarph_cli/compress/levers.py +0 -0
  88. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/src/swarph_cli/compress/marker.py +0 -0
  89. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/src/swarph_cli/compress/verify.py +0 -0
  90. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/src/swarph_cli/console_safe.py +0 -0
  91. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/src/swarph_cli/delivery_queue.py +0 -0
  92. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/src/swarph_cli/gateway/__init__.py +0 -0
  93. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/src/swarph_cli/gateway/feature_registry.py +0 -0
  94. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/src/swarph_cli/gateway/schema.sql +0 -0
  95. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/src/swarph_cli/gateway/server.py +0 -0
  96. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/src/swarph_cli/gateway/services_control.py +0 -0
  97. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/src/swarph_cli/multiplexer.py +0 -0
  98. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/src/swarph_cli/parsers/__init__.py +0 -0
  99. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/src/swarph_cli/parsers/claude.py +0 -0
  100. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/src/swarph_cli/scripts/ensure_monitor.sh +0 -0
  101. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/src/swarph_cli/service/__init__.py +0 -0
  102. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/src/swarph_cli/service/app.py +0 -0
  103. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/src/swarph_cli/service/providers.py +0 -0
  104. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/src/swarph_cli/session_bridge.py +0 -0
  105. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/src/swarph_cli/stall_alert.py +0 -0
  106. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/src/swarph_cli/systemd/swarph-watchdog.default +0 -0
  107. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/src/swarph_cli/systemd/swarph-watchdog.service +0 -0
  108. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/src/swarph_cli/systemd/swarph-watchdog.timer +0 -0
  109. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/src/swarph_cli.egg-info/dependency_links.txt +0 -0
  110. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/src/swarph_cli.egg-info/entry_points.txt +0 -0
  111. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/src/swarph_cli.egg-info/top_level.txt +0 -0
  112. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/tests/test_add_security_gate.py +0 -0
  113. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/tests/test_artifact_add.py +0 -0
  114. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/tests/test_artifact_hash.py +0 -0
  115. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/tests/test_artifact_lib.py +0 -0
  116. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/tests/test_artifact_mcp.py +0 -0
  117. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/tests/test_artifact_skill.py +0 -0
  118. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/tests/test_artifact_tool.py +0 -0
  119. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/tests/test_artifact_uri.py +0 -0
  120. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/tests/test_bench_backends.py +0 -0
  121. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/tests/test_bench_command.py +0 -0
  122. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/tests/test_bench_pack.py +0 -0
  123. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/tests/test_bench_prices.py +0 -0
  124. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/tests/test_bench_quality.py +0 -0
  125. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/tests/test_bench_runner.py +0 -0
  126. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/tests/test_bench_validate.py +0 -0
  127. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/tests/test_board_command.py +0 -0
  128. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/tests/test_board_merge_check.py +0 -0
  129. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/tests/test_brain_ask_env_diagnosis.py +0 -0
  130. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/tests/test_brain_command.py +0 -0
  131. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/tests/test_caller_meta_guard.py +0 -0
  132. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/tests/test_capture_lineage.py +0 -0
  133. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/tests/test_capture_liveness.py +0 -0
  134. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/tests/test_capture_manifest.py +0 -0
  135. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/tests/test_capture_paths.py +0 -0
  136. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/tests/test_capture_security.py +0 -0
  137. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/tests/test_cell_command_dispatch.py +0 -0
  138. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/tests/test_cell_harden.py +0 -0
  139. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/tests/test_cell_loader.py +0 -0
  140. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/tests/test_cell_selfcheck.py +0 -0
  141. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/tests/test_cell_verify.py +0 -0
  142. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/tests/test_chain_token.py +0 -0
  143. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/tests/test_channel_command.py +0 -0
  144. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/tests/test_channel_post.py +0 -0
  145. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/tests/test_chat_command.py +0 -0
  146. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/tests/test_claude_parser.py +0 -0
  147. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/tests/test_claude_tmux_template.py +0 -0
  148. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/tests/test_codegraph_caller_scope.py +0 -0
  149. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/tests/test_codegraph_command.py +0 -0
  150. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/tests/test_codegraph_hook.py +0 -0
  151. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/tests/test_codegraph_mcp.py +0 -0
  152. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/tests/test_compress_caller_convention.py +0 -0
  153. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/tests/test_compress_command.py +0 -0
  154. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/tests/test_compress_levers.py +0 -0
  155. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/tests/test_compress_marker.py +0 -0
  156. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/tests/test_compress_verify.py +0 -0
  157. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/tests/test_console_encoding_e2e.py +0 -0
  158. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/tests/test_daemon_command.py +0 -0
  159. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/tests/test_daemon_console_encoding.py +0 -0
  160. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/tests/test_daemon_delivery.py +0 -0
  161. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/tests/test_delivery_queue.py +0 -0
  162. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/tests/test_display_sanitize.py +0 -0
  163. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/tests/test_event_emit.py +0 -0
  164. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/tests/test_feature_to_uri.py +0 -0
  165. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/tests/test_gateway_brain.py +0 -0
  166. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/tests/test_gateway_client.py +0 -0
  167. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/tests/test_gateway_command.py +0 -0
  168. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/tests/test_group_command.py +0 -0
  169. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/tests/test_highlight_command.py +0 -0
  170. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/tests/test_hook_output.py +0 -0
  171. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/tests/test_hooks_activity_marker.py +0 -0
  172. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/tests/test_hooks_add.py +0 -0
  173. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/tests/test_hooks_bundle.py +0 -0
  174. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/tests/test_hooks_init.py +0 -0
  175. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/tests/test_hooks_lifecycle.py +0 -0
  176. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/tests/test_hooks_merge.py +0 -0
  177. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/tests/test_import_command.py +0 -0
  178. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/tests/test_init_command.py +0 -0
  179. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/tests/test_install_hook.py +0 -0
  180. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/tests/test_install_multiplexer_command.py +0 -0
  181. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/tests/test_lane_command.py +0 -0
  182. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/tests/test_main.py +0 -0
  183. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/tests/test_main_unknown_verb_guard.py +0 -0
  184. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/tests/test_mcp_server.py +0 -0
  185. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/tests/test_memory_command.py +0 -0
  186. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/tests/test_memory_gateway.py +0 -0
  187. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/tests/test_memory_sync.py +0 -0
  188. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/tests/test_memory_sync_membrane.py +0 -0
  189. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/tests/test_mesh_command.py +0 -0
  190. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/tests/test_mesh_inbox_mark_read.py +0 -0
  191. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/tests/test_mesh_inbox_sanitize.py +0 -0
  192. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/tests/test_mesh_sidecar.py +0 -0
  193. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/tests/test_mesh_sidecar_cursor_decoupled.py +0 -0
  194. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/tests/test_mesh_sidecar_wake_survives_read.py +0 -0
  195. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/tests/test_meta_edge_identity.py +0 -0
  196. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/tests/test_monitor_cli.py +0 -0
  197. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/tests/test_monitor_daemon_collision.py +0 -0
  198. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/tests/test_monitor_self_name_verification.py +0 -0
  199. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/tests/test_multiplexer.py +0 -0
  200. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/tests/test_okf_links.py +0 -0
  201. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/tests/test_packaged_artifacts_exist.py +0 -0
  202. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/tests/test_process_liveness.py +0 -0
  203. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/tests/test_protocol_handler.py +0 -0
  204. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/tests/test_providers_isolation.py +0 -0
  205. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/tests/test_ratify_command.py +0 -0
  206. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/tests/test_repl_caller_convention.py +0 -0
  207. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/tests/test_schedule_command.py +0 -0
  208. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/tests/test_schedule_create_contract.py +0 -0
  209. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/tests/test_security.py +0 -0
  210. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/tests/test_service_app.py +0 -0
  211. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/tests/test_service_command.py +0 -0
  212. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/tests/test_service_providers.py +0 -0
  213. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/tests/test_session_bridge_inject.py +0 -0
  214. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/tests/test_session_bridge_probe.py +0 -0
  215. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/tests/test_session_bridge_resolve.py +0 -0
  216. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/tests/test_session_path_role_gate.py +0 -0
  217. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/tests/test_shipped_units_match_the_cli.py +0 -0
  218. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/tests/test_smoke_chat.py +0 -0
  219. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/tests/test_smoke_one_shot.py +0 -0
  220. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/tests/test_smoke_phase_5_5.py +0 -0
  221. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/tests/test_spawn_live_pin.py +0 -0
  222. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/tests/test_spawn_mitosis_lineage.py +0 -0
  223. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/tests/test_spawn_tmux_session.py +0 -0
  224. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/tests/test_spawn_windows_relaunch.py +0 -0
  225. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/tests/test_stall_alert.py +0 -0
  226. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/tests/test_timeline_command.py +0 -0
  227. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/tests/test_token_file_one_parser.py +0 -0
  228. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/tests/test_version_consistency.py +0 -0
  229. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/tests/test_watchdog_dm_wake.py +0 -0
  230. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/tests/test_watchdog_dm_wake_cooldown.py +0 -0
  231. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/tests/test_watchdog_dm_wake_wiring.py +0 -0
  232. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/tests/test_watchdog_emit_health.py +0 -0
  233. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/tests/test_watchdog_install_flags.py +0 -0
  234. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/tests/test_watchdog_model_rung.py +0 -0
  235. {swarph_cli-0.41.3 → swarph_cli-0.41.7}/tests/test_watchdog_stale_peers.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: swarph-cli
3
- Version: 0.41.3
3
+ Version: 0.41.7
4
4
  Summary: The `swarph` binary — a multi-LLM CLI and mesh-gateway client + bundled server (`swarph gateway`): one-shot prompts across providers, interactive `chat`, multi-provider `spawn` (claude/codex/antigravity via a ProviderMembrane with subprocess billing-scrub), `mesh` send/inbox/register, `brain-ask` over the swarph-brain (gbrain) semantic memory, git-backed `assisted_memory`, session `import`, and a stranded-session `watchdog`.
5
5
  Author: Pierre Samson, Claude Opus
6
6
  License: MIT
@@ -24,7 +24,7 @@ Requires-Python: >=3.10
24
24
  Description-Content-Type: text/markdown
25
25
  License-File: LICENSE
26
26
  Requires-Dist: swarph-mesh>=0.5.0
27
- Requires-Dist: swarph-shared>=0.4.0
27
+ Requires-Dist: swarph-shared!=0.6.0,<0.7,>=0.4.0
28
28
  Requires-Dist: PyYAML>=6.0
29
29
  Provides-Extra: dev
30
30
  Requires-Dist: pytest>=7.0; extra == "dev"
@@ -108,10 +108,38 @@ swarph board cards add --project <id|slug> --title "…" [--body "…"] [--ai2]
108
108
  swarph board cards move <id> <stage> # advance the card (proposed→idea→spec→plan→build→test→done)
109
109
  swarph board cards link <id> <key> <value> # add/update a link (merges — never clobbers existing links)
110
110
  swarph board cards assign <id> <who>
111
+ swarph board cards thread <id> [--limit N] # the card's conversation (a card IS a thread)
112
+ swarph board cards say <id> --content "…" [--to <peer>] [--kind fyi|question|answer|status|unblock]
111
113
  ```
112
114
 
113
115
  `--project` accepts a numeric id **or** a slug (resolved via `projects list`). Note: card creation always starts at `proposed` (the gateway has no stage-on-create) — use `cards move` to advance.
114
116
 
117
+ **`thread` / `say` (v0.41.6)** — every card carries a bound conversation, so a
118
+ finding, a verdict, or a hand-off lands **on the card** instead of in a DM only the
119
+ sender and one recipient can read.
120
+
121
+ `say` needs a recipient because a card post is still a DM on the wire: `--to` wins,
122
+ otherwise the card's **assignee**. With neither, it **refuses** rather than
123
+ substituting a placeholder — the gateway accepts an unregistered `to_node` with a
124
+ `200`, so a placeholder would be indistinguishable from delivery while being
125
+ addressed to nobody.
126
+
127
+ Attaching requires an explicit `propose` grant on the project, not merely read
128
+ access: a message on a card publishes to everyone who can read that card, now and
129
+ in future.
130
+
131
+ > **A card thread is a mesh-wide surface, not a two-party one.** 13 of 14 projects
132
+ > carry `group:ALL`, so a card's conversation is readable by **every ratified peer**.
133
+ > That is the design — a card's discussion belongs on the card regardless of who
134
+ > sent it — and it is the same fact as the feature's selling point, stated from the
135
+ > other side.
136
+ >
137
+ > **Put the verdict on the card. Not the credential.** Findings, decisions,
138
+ > hand-offs and review outcomes belong there and are better there than in one
139
+ > inbox. Tokens, keys and client-confidential material are a DM or nothing.
140
+ > There is no retraction: the message is a row, and *now and in future* means a
141
+ > peer ratified next month reads what you post today.
142
+
115
143
  ### `swarph bench` (v0.38.0)
116
144
 
117
145
  A **deterministic** LLM benchmark: run an N-way model *showdown* on a **pack** — a self-contained, subject-agnostic unit of `{system, tasks, expected}` — and report quality (ground-truth **distance**, not vibes) alongside tokens / cost / latency. You define the correct answer; the score is math; anyone re-runs = the same number.
@@ -62,10 +62,38 @@ swarph board cards add --project <id|slug> --title "…" [--body "…"] [--ai2]
62
62
  swarph board cards move <id> <stage> # advance the card (proposed→idea→spec→plan→build→test→done)
63
63
  swarph board cards link <id> <key> <value> # add/update a link (merges — never clobbers existing links)
64
64
  swarph board cards assign <id> <who>
65
+ swarph board cards thread <id> [--limit N] # the card's conversation (a card IS a thread)
66
+ swarph board cards say <id> --content "…" [--to <peer>] [--kind fyi|question|answer|status|unblock]
65
67
  ```
66
68
 
67
69
  `--project` accepts a numeric id **or** a slug (resolved via `projects list`). Note: card creation always starts at `proposed` (the gateway has no stage-on-create) — use `cards move` to advance.
68
70
 
71
+ **`thread` / `say` (v0.41.6)** — every card carries a bound conversation, so a
72
+ finding, a verdict, or a hand-off lands **on the card** instead of in a DM only the
73
+ sender and one recipient can read.
74
+
75
+ `say` needs a recipient because a card post is still a DM on the wire: `--to` wins,
76
+ otherwise the card's **assignee**. With neither, it **refuses** rather than
77
+ substituting a placeholder — the gateway accepts an unregistered `to_node` with a
78
+ `200`, so a placeholder would be indistinguishable from delivery while being
79
+ addressed to nobody.
80
+
81
+ Attaching requires an explicit `propose` grant on the project, not merely read
82
+ access: a message on a card publishes to everyone who can read that card, now and
83
+ in future.
84
+
85
+ > **A card thread is a mesh-wide surface, not a two-party one.** 13 of 14 projects
86
+ > carry `group:ALL`, so a card's conversation is readable by **every ratified peer**.
87
+ > That is the design — a card's discussion belongs on the card regardless of who
88
+ > sent it — and it is the same fact as the feature's selling point, stated from the
89
+ > other side.
90
+ >
91
+ > **Put the verdict on the card. Not the credential.** Findings, decisions,
92
+ > hand-offs and review outcomes belong there and are better there than in one
93
+ > inbox. Tokens, keys and client-confidential material are a DM or nothing.
94
+ > There is no retraction: the message is a row, and *now and in future* means a
95
+ > peer ratified next month reads what you post today.
96
+
69
97
  ### `swarph bench` (v0.38.0)
70
98
 
71
99
  A **deterministic** LLM benchmark: run an N-way model *showdown* on a **pack** — a self-contained, subject-agnostic unit of `{system, tasks, expected}` — and report quality (ground-truth **distance**, not vibes) alongside tokens / cost / latency. You define the correct answer; the score is math; anyone re-runs = the same number.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "swarph-cli"
7
- version = "0.41.3"
7
+ version = "0.41.7"
8
8
  description = "The `swarph` binary — a multi-LLM CLI and mesh-gateway client + bundled server (`swarph gateway`): one-shot prompts across providers, interactive `chat`, multi-provider `spawn` (claude/codex/antigravity via a ProviderMembrane with subprocess billing-scrub), `mesh` send/inbox/register, `brain-ask` over the swarph-brain (gbrain) semantic memory, git-backed `assisted_memory`, session `import`, and a stranded-session `watchdog`."
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -40,7 +40,23 @@ dependencies = [
40
40
  # >=0.3.1 is load-bearing: 0.3.1 is the first release whose
41
41
  # scrub_env_for_subprocess closes the ANTHROPIC_AUTH_TOKEN/*_BASE_URL
42
42
  # billing-redirect class that the membrane env builders delegate to.
43
- "swarph-shared>=0.4.0",
43
+ # >>> UPPER BOUND IS LOAD-BEARING, NOT TIDINESS. Without it ANY swarph-shared
44
+ # release can break ANY already-released swarph-cli on FRESH installs, with no
45
+ # action by the user and no signal to either package — measured 2026-08-05:
46
+ # shared 0.6.0 added a provider with no membrane here, spawn.py's
47
+ # `VALID_PROVIDERS ⊆ MEMBRANES` guard raised AT IMPORT, and every fresh
48
+ # `pip install swarph-cli` got a dead `swarph spawn` for ~5h.
49
+ # >>> AND THE HARDENED RITUAL MADE IT WORSE: `pipx install --force
50
+ # swarph-cli==X.Y.Z` RE-RESOLVES the transitive dep, so pinning exactly what
51
+ # we told cells to pin did NOT protect them. The most diligent cells were the
52
+ # most exposed. <<<
53
+ # A bound converts a silent runtime break into a RESOLVER CONFLICT AT INSTALL
54
+ # TIME: loud, local, and before anything is running. Raise it deliberately
55
+ # when a shared release is verified against this package. Board #308.
56
+ # `!=0.6.0` is not belt-and-braces: 0.6.0 is KNOWN-BROKEN with this package
57
+ # (it whitelists `vibe`, which has no membrane here, and spawn.py raises at
58
+ # import). Without the exclusion a resolver may still legally select it.
59
+ "swarph-shared>=0.4.0,!=0.6.0,<0.7",
44
60
  # Phase 7 spawn — cell.yaml parser. PyYAML 6.x is the standard.
45
61
  # Stays a swarph-cli dep since file I/O is operator-tooling-layer
46
62
  # concern; swarph-shared cell module is pure-stdlib.
@@ -16,6 +16,6 @@ The architecture splits CLI from substrate so:
16
16
 
17
17
  from __future__ import annotations
18
18
 
19
- __version__ = "0.41.3"
19
+ __version__ = "0.41.7"
20
20
 
21
21
  __all__ = ["__version__"]
@@ -50,20 +50,43 @@ def _patch_json(url: str, body: dict, token: str, *, timeout: float = 10.0) -> t
50
50
 
51
51
  # ── pure builders (unit-tested) ───────────────────────────────────────────────
52
52
 
53
- def _cards_list_url(gateway: str, *, project=None, stage=None, assignee=None) -> str:
54
- q = {k: v for k, v in (("project", project), ("stage", stage), ("assignee", assignee)) if v}
53
+ def _cards_list_url(gateway: str, *, project=None, stage=None, assignee=None,
54
+ label=None) -> str:
55
+ q = {k: v for k, v in (("project", project), ("stage", stage),
56
+ ("assignee", assignee), ("label", label)) if v}
55
57
  base = f"{gateway.rstrip('/')}/board/cards"
56
58
  return f"{base}?{urllib.parse.urlencode(q)}" if q else base
57
59
 
58
60
 
59
- def _card_add_payload(actor, project_id, title, *, body=None, ai2=False, priority=0) -> dict:
61
+ def _card_add_payload(actor, project_id, title, *, body=None, ai2=False, priority=0,
62
+ labels=None) -> dict:
60
63
  p = {"actor": actor, "project_id": int(project_id), "title": title,
61
64
  "ai2": bool(ai2), "priority": int(priority)}
62
65
  if body:
63
66
  p["body"] = body
67
+ if labels:
68
+ p["labels"] = list(labels)
64
69
  return p
65
70
 
66
71
 
72
+ def _apply_label(current: list, action: str, label: str) -> list:
73
+ """Compute the NEW label set. Pure, so the read-modify-write is testable
74
+ without a gateway.
75
+
76
+ The server takes a FULL REPLACEMENT (labels are a set — a merge-only field
77
+ could never REMOVE one), so `label rm` is unavoidably read-modify-write. That
78
+ is last-writer-wins between two simultaneous editors of the SAME card, which
79
+ the board already has for `stage` and `assignee` — stated rather than hidden,
80
+ because the alternative (server-side add/rm ops) is a bigger surface than
81
+ this card justifies.
82
+ """
83
+ cur = [str(x).strip().lower() for x in (current or [])]
84
+ lab = label.strip().lower()
85
+ if action == "add":
86
+ return cur if lab in cur else cur + [lab]
87
+ return [x for x in cur if x != lab]
88
+
89
+
67
90
  def _project_add_payload(actor, slug, title, *, goal=None) -> dict:
68
91
  p = {"actor": actor, "slug": slug, "title": title}
69
92
  if goal:
@@ -138,6 +161,71 @@ def _format_projects(data) -> str:
138
161
  return "\n".join(lines)
139
162
 
140
163
 
164
+ # ── #181: the card IS a thread ────────────────────────────────────────────────
165
+ # The gateway has carried `GET /board/cards/{id}/thread` and the card-gated attach
166
+ # path since 2026-08-05, and NOTHING COULD REACH THEM. No CLI verb existed, so the
167
+ # card↔DM fusion lived entirely in the database and the OpenAPI schema: shipped,
168
+ # deployed, and invisible to every human and cell. Found the way these are always
169
+ # found — I tried to write a finding onto a card and had to send a DM instead.
170
+
171
+ def _thread_url(gateway: str, card_id: int, *, limit: Optional[int] = None) -> str:
172
+ base = f"{gateway.rstrip('/')}/board/cards/{card_id}/thread"
173
+ return f"{base}?limit={limit}" if limit else base
174
+
175
+
176
+ def _thread_recipient(card: dict, explicit_to: Optional[str]) -> str:
177
+ """Who a card-thread post is addressed to. `--to` wins, else the assignee.
178
+
179
+ >>> RAISES RATHER THAN INVENTING A SENTINEL. <<< POST /messages requires
180
+ exactly one of {to_node, channel}, so a card post needs a real recipient. The
181
+ tempting move is a placeholder like "board" or "__card__" — but board card
182
+ #259 measured what the gateway does with an unregistered to_node: it returns
183
+ 200 and the message is addressed to nobody, indistinguishable from delivery.
184
+ A placeholder here would manufacture that defect once per card post.
185
+ """
186
+ if explicit_to:
187
+ return explicit_to
188
+ assignee = card.get("assignee")
189
+ if assignee:
190
+ return assignee
191
+ raise RuntimeError(
192
+ f"card #{card.get('id')} has no assignee, so there is no default recipient "
193
+ f"for a thread post — pass --to <peer>. (A card post is still a DM on the "
194
+ f"wire; it needs someone to be addressed to.)"
195
+ )
196
+
197
+
198
+ def _card_say_payload(from_node: str, to_node: str, kind: str, content: str,
199
+ thread_uuid: str) -> dict:
200
+ return {
201
+ "from_node": from_node,
202
+ "to_node": to_node,
203
+ "kind": kind,
204
+ "content": content,
205
+ "thread_id": thread_uuid,
206
+ }
207
+
208
+
209
+ def _format_thread(data) -> str:
210
+ msgs = data.get("messages", []) if isinstance(data, dict) else (data or [])
211
+ card_id = data.get("card_id") if isinstance(data, dict) else None
212
+ if not msgs:
213
+ # NOT "(no messages)" alone — an empty thread and an unreadable one must not
214
+ # render the same. The gateway 403s an unreadable card and 409s an unmigrated
215
+ # one, so reaching here with [] genuinely means nobody has posted yet.
216
+ return f"card #{card_id}: thread is empty — no messages posted yet"
217
+ lines = [f"card #{card_id} — {len(msgs)} message(s)"]
218
+ for m in msgs:
219
+ ts = (m.get("created_at") or "")[:16].replace("T", " ")
220
+ lines.append(
221
+ f"\n [{m.get('id')}] {ts} {_s(m.get('from_node'))} -> "
222
+ f"{_s(m.get('to_node'))} ({_s(m.get('kind'))})"
223
+ )
224
+ for ln in (_s(m.get("content")) or "").splitlines():
225
+ lines.append(f" {ln}")
226
+ return "\n".join(lines)
227
+
228
+
141
229
  # ── parser + dispatch ─────────────────────────────────────────────────────────
142
230
 
143
231
  def _build_parser() -> argparse.ArgumentParser:
@@ -153,13 +241,17 @@ def _build_parser() -> argparse.ArgumentParser:
153
241
  cards = top.add_parser("cards", help="board cards").add_subparsers(dest="command", required=True)
154
242
  cl = cards.add_parser("list", help="list cards")
155
243
  cl.add_argument("--project"); cl.add_argument("--stage"); cl.add_argument("--assignee")
244
+ cl.add_argument("--label", help="only cards carrying this label (exact match)")
156
245
  cl.add_argument("--json", action="store_true"); _add_common(cl)
157
246
  cs = cards.add_parser("show", help="show one card"); cs.add_argument("id", type=int)
158
247
  cs.add_argument("--json", action="store_true"); _add_common(cs)
159
248
  ca = cards.add_parser("add", help="create a card")
160
249
  ca.add_argument("--project", required=True, help="project id or slug"); ca.add_argument("--title", required=True)
161
250
  ca.add_argument("--body"); ca.add_argument("--ai2", action="store_true")
162
- ca.add_argument("--priority", type=int, default=0); ca.add_argument("--json", action="store_true"); _add_common(ca)
251
+ ca.add_argument("--priority", type=int, default=0)
252
+ ca.add_argument("--label", action="append", dest="labels", metavar="LABEL",
253
+ help="attach a label (repeatable)")
254
+ ca.add_argument("--json", action="store_true"); _add_common(ca)
163
255
  cm = cards.add_parser("move", help="move a card to a stage")
164
256
  cm.add_argument("id", type=int); cm.add_argument("stage"); cm.add_argument("--json", action="store_true"); _add_common(cm)
165
257
  ck = cards.add_parser("link", help="add/update a link on a card (merges)")
@@ -167,6 +259,22 @@ def _build_parser() -> argparse.ArgumentParser:
167
259
  ck.add_argument("--json", action="store_true"); _add_common(ck)
168
260
  cn = cards.add_parser("assign", help="set a card's assignee")
169
261
  cn.add_argument("id", type=int); cn.add_argument("assignee"); cn.add_argument("--json", action="store_true"); _add_common(cn)
262
+ cb = cards.add_parser("label", help="add/remove a label on a card")
263
+ cb.add_argument("action", choices=["add", "rm"])
264
+ cb.add_argument("id", type=int); cb.add_argument("label")
265
+ cb.add_argument("--json", action="store_true"); _add_common(cb)
266
+ ct = cards.add_parser("thread", help="show the card's conversation (#181: a card IS a thread)")
267
+ ct.add_argument("id", type=int)
268
+ ct.add_argument("--limit", type=int, default=None, help="max messages (gateway caps at 1000)")
269
+ _add_common(ct)
270
+ cy = cards.add_parser("say", help="post a message onto the card's thread")
271
+ cy.add_argument("id", type=int)
272
+ cy.add_argument("--content", required=True)
273
+ cy.add_argument("--kind", default="fyi", help="status|question|answer|unblock|fyi")
274
+ cy.add_argument("--to", dest="to_node", default=None,
275
+ help="recipient peer (default: the card's assignee)")
276
+ _add_common(cy)
277
+
170
278
  cr = cards.add_parser("ready", help="flag a card ready-to-advance (move_ready) for the orchestrator")
171
279
  cr.add_argument("id", type=int); cr.add_argument("--clear", action="store_true", help="unset move_ready")
172
280
  cr.add_argument("--json", action="store_true"); _add_common(cr)
@@ -224,7 +332,9 @@ def run_board(argv: list[str]) -> int:
224
332
  if err:
225
333
  print(f"swarph board: {err}", file=sys.stderr)
226
334
  return 1
227
- st, d = _http_get_json(_cards_list_url(gw, project=pid, stage=args.stage, assignee=args.assignee), token)
335
+ st, d = _http_get_json(_cards_list_url(gw, project=pid, stage=args.stage,
336
+ assignee=args.assignee,
337
+ label=getattr(args, "label", None)), token)
228
338
  return _out(st, d, _format_cards, aj)
229
339
  if args.command == "show":
230
340
  st, d = _http_get_json(f"{gw}/board/cards/{args.id}", token)
@@ -234,14 +344,74 @@ def run_board(argv: list[str]) -> int:
234
344
  if err or pid is None:
235
345
  print(f"swarph board: {err or 'project required'}", file=sys.stderr)
236
346
  return 1
237
- st, d = _post_json(f"{gw}/board/cards", _card_add_payload(self_name, pid, args.title, body=args.body, ai2=args.ai2, priority=args.priority), token)
347
+ st, d = _post_json(f"{gw}/board/cards", _card_add_payload(
348
+ self_name, pid, args.title, body=args.body, ai2=args.ai2,
349
+ priority=args.priority, labels=getattr(args, "labels", None)), token)
238
350
  return _out(st, d, lambda x: f"created card #{x.get('id')} [{x.get('stage')}] (stage defaults to proposed — use `cards move` to advance)", aj)
351
+ if args.command == "label":
352
+ # READ the current set before replacing it. If the GET fails we must
353
+ # NOT write — a PATCH built on a failed read would silently CLOBBER
354
+ # every other label on the card. Refuse loudly instead.
355
+ st, cur = _http_get_json(f"{gw}/board/cards/{args.id}", token)
356
+ if st != 200 or not isinstance(cur, dict):
357
+ print(f"swarph board: cannot read card #{args.id} to modify its "
358
+ f"labels (HTTP {st}) — refusing to write, a replacement built "
359
+ f"on a failed read would drop the card's other labels",
360
+ file=sys.stderr)
361
+ return 1
362
+ new = _apply_label(cur.get("labels") or [], args.action, args.label)
363
+ st, d = _patch_json(f"{gw}/board/cards/{args.id}",
364
+ {"actor": self_name, "labels": new}, token)
365
+ return _out(st, d, lambda x: f"card #{x.get('id')} labels -> "
366
+ f"{', '.join(x.get('labels') or []) or '(none)'}", aj)
239
367
  if args.command == "move":
240
368
  st, d = _patch_json(f"{gw}/board/cards/{args.id}", {"actor": self_name, "stage": args.stage}, token)
241
369
  return _out(st, d, lambda x: f"card #{x.get('id')} -> {x.get('stage')}", aj)
242
370
  if args.command == "assign":
243
371
  st, d = _patch_json(f"{gw}/board/cards/{args.id}", {"actor": self_name, "assignee": args.assignee}, token)
244
372
  return _out(st, d, lambda x: f"card #{x.get('id')} assignee -> {x.get('assignee')}", aj)
373
+ if args.command == "thread":
374
+ st, d = _http_get_json(_thread_url(gw, args.id, limit=args.limit), token)
375
+ # NO special-casing of 403/409. `_out` already returns 1 and prints the
376
+ # gateway's `detail` verbatim for every non-2xx, so the refusal reaches
377
+ # the operator intact — a 409 ("predates #181a") and a 403 ("not
378
+ # readable") are never rendered as an empty thread.
379
+ # >>> A HAND-WRITTEN BRANCH HERE WAS DELETED: it re-printed the same
380
+ # detail under a different prefix, carried a comment asserting it
381
+ # prevented a flattening `_out` never did, and a mutation that removed
382
+ # it PASSED ALL 15 TESTS — the definition of a code path doing nothing.
383
+ # Reading it would not have found that; mutating it did. <<<
384
+ return _out(st, d, _format_thread, aj)
385
+ if args.command == "say":
386
+ st, card = _http_get_json(f"{gw}/board/cards/{args.id}", token)
387
+ if st != 200:
388
+ print(f"swarph board cards say: cannot read card #{args.id}: "
389
+ f"{card.get('detail', card)}", file=sys.stderr)
390
+ return 1
391
+ thread_uuid = card.get("thread_uuid")
392
+ if not thread_uuid:
393
+ # Named refusal, not a silent no-op. Same reason the gateway 409s.
394
+ print(f"swarph board cards say: card #{args.id} has no bound thread "
395
+ f"(it predates #181a). Run scripts/migrate_card_threads.py on "
396
+ f"the gateway host.", file=sys.stderr)
397
+ return 1
398
+ try:
399
+ to_node = _thread_recipient(card, args.to_node)
400
+ except RuntimeError as exc:
401
+ print(f"swarph board cards say: {exc}", file=sys.stderr)
402
+ return 1
403
+ st, d = _post_json(
404
+ f"{gw}/messages",
405
+ _card_say_payload(self_name, to_node, args.kind, args.content, thread_uuid),
406
+ token,
407
+ )
408
+ # A 403 here carries the gateway's explanation that attaching PUBLISHES
409
+ # to everyone who can read the card, now and in future, so it needs an
410
+ # explicit `propose` grant. `_out` passes `detail` through whole; it does
411
+ # not need help, and a second copy of that logic is a second thing to
412
+ # keep in sync.
413
+ return _out(st, d, lambda x: f"posted id={x.get('id')} onto card "
414
+ f"#{args.id} (to {to_node})", aj)
245
415
  if args.command == "ready":
246
416
  st, d = _patch_json(f"{gw}/board/cards/{args.id}", {"actor": self_name, "move_ready": not args.clear}, token)
247
417
  return _out(st, d, lambda x: f"card #{x.get('id')} move_ready -> {x.get('move_ready')}", aj)
@@ -138,7 +138,21 @@ def _resolve_state_dir(arg: Optional[str], self_name_arg: Optional[str]) -> Path
138
138
 
139
139
 
140
140
  def _resolve_token(token_file_arg: Optional[str]) -> str:
141
- """Mirror onboard's resolution. env → secrets.toml mode 0600 → prompt."""
141
+ """DELEGATES to onboard's resolver (#243) — and this docstring used to lie.
142
+
143
+ It read "Mirror onboard's resolution. env -> secrets.toml mode 0600 ->
144
+ prompt", which describes a COPY. The body has always been a delegation, so
145
+ the fix for #243 reached this verb for free — but a reader auditing which
146
+ verbs inherited the retired-credential bug would have read this line and
147
+ concluded daemon had its own broken copy. I did exactly that, and carded it.
148
+
149
+ >>> A DOCSTRING THAT DESCRIBES A COPY OVER A BODY THAT DELEGATES IS A STALE
150
+ RECORD, AND IT COSTS THE NEXT READER THE SAME WRONG CONCLUSION. <<< The
151
+ resolution order it names is also now wrong: onboard gained
152
+ ~/.config/swarph/<SWARPH_SELF>.peer_token and REFUSES rather than prompting.
153
+ Naming the delegation instead of restating the order means this cannot go
154
+ stale again.
155
+ """
142
156
  from swarph_cli.commands.onboard import _resolve_token as _onboard_resolve
143
157
 
144
158
  return _onboard_resolve(token_file_arg)
@@ -128,77 +128,28 @@ def _peer_token_path(self_name: str) -> Path:
128
128
  return Path.home() / ".config" / "swarph" / f"{self_name}.peer_token"
129
129
 
130
130
 
131
- _TOKEN_KEYS = ("MESH_GATEWAY_TOKEN", "SWARPH_TOKEN", "TOKEN")
131
+ # _TOKEN_KEYS moved to swarph_cli.tokens.TOKEN_KEYS (#332). Not re-exported:
132
+ # a second copy of a constant in a second module is precisely how the two token
133
+ # PARSERS diverged in the first place. One definition, one address.
132
134
 
133
135
 
134
136
  def _read_token_file(path: Path) -> str:
135
137
  """Read a bearer token from a token file OR an env-style file.
136
138
 
137
- ONE PARSER BEHIND ONE FLAG (droplet, 2026-07-26). This previously did
138
- `read_text().strip()` and returned the ENTIRE FILE, so `--token-file
139
- /root/.mesh.env` -- the shape the systemd unit itself documents -- put
140
- comments and other variables into the Authorization header. droplet's
141
- monitor died with
139
+ THE IMPLEMENTATION MOVED to `swarph_cli.tokens.read_token_file` (#332).
140
+ droplet's "one parser behind one flag" work (2026-07-26) lived here, in a
141
+ COMMAND module \u2014 so every other verb either reimplemented it or reached
142
+ into a sibling command's private helper. gpt-ops flagged that during the
143
+ #332 review: `onboard` must not depend on `mesh`'s internals. The parser is
144
+ byte-for-byte unchanged; only its address is.
142
145
 
143
- UnicodeEncodeError: 'latin-1' codec can't encode character '\u2014'
144
-
145
- from an em-dash in a COMMENT on line 5. Meanwhile `swarph daemon` read the
146
- same flag through onboard._resolve_token, which has always skipped comments
147
- and parsed KEY=VALUE. Two parsers, one flag -- the same one-thing-two-meanings
148
- shape as the cursor that meant both observed and woken.
149
-
150
- Accepts, in order:
151
- 1. an env-style line `MESH_GATEWAY_TOKEN=...` (quotes stripped), or
152
- 2. the first non-comment, non-blank, non-KEY=VALUE line as a bare token.
146
+ This wrapper stays because it is a PATCH POINT: existing suites monkeypatch
147
+ `mesh._read_token_file`, and deleting the name would make those patches
148
+ silently miss (the same trap documented on `_tmux_wake` above).
153
149
  """
154
- try:
155
- raw = path.read_text(encoding="utf-8")
156
- except OSError as exc:
157
- raise RuntimeError(f"cannot read token file {path}: {exc}") from exc
158
-
159
- bare: Optional[str] = None
160
- for lineno, line in enumerate(raw.splitlines(), start=1):
161
- line = line.strip()
162
- if not line or line.startswith("#"):
163
- continue
164
- if "=" in line:
165
- key, value = line.split("=", 1)
166
- if key.strip().upper() in _TOKEN_KEYS:
167
- return _validated_token(value.strip().strip('"').strip("'"),
168
- path, lineno)
169
- continue
170
- if bare is None:
171
- bare = line
172
- bare_lineno = lineno
173
- if bare is not None:
174
- return _validated_token(bare, path, bare_lineno)
175
- raise RuntimeError(
176
- f"token file {path} contains no token: expected a bare token line or "
177
- f"one of {', '.join(_TOKEN_KEYS)}=<token>"
178
- )
179
-
150
+ from swarph_cli.tokens import read_token_file
180
151
 
181
- def _validated_token(token: str, path: Path, lineno: int) -> str:
182
- """Reject a token that cannot go in an HTTP header, NAMING THE SOURCE.
183
-
184
- droplet's hardening, and it is worth more than the parse fix: a non-ASCII
185
- byte in an outbound header surfaced as a latin-1 codec traceback twelve
186
- frames deep in http/client.py, which points every reader at the HTTP layer
187
- for what is a malformed config FILE. Fail at the boundary where the operator
188
- can act, naming the file and the line.
189
- """
190
- try:
191
- token.encode("latin-1")
192
- except UnicodeEncodeError as exc:
193
- bad = token[exc.start:exc.start + 1]
194
- raise RuntimeError(
195
- f"token file {path} line {lineno}: token contains a non-ASCII "
196
- f"character {bad!r} (U+{ord(bad):04X}) at position {exc.start}, "
197
- f"which cannot be sent in an HTTP Authorization header. "
198
- f"If this line is a comment, prefix it with '#'; if the file is "
199
- f"env-style, use MESH_GATEWAY_TOKEN=<token>."
200
- ) from None
201
- return token
152
+ return read_token_file(path)
202
153
 
203
154
 
204
155
  def _read_secrets_token(path: Path) -> str:
@@ -638,6 +589,25 @@ class TmuxSink(Sink):
638
589
  return f"{count} DM{plural} not yet delivered to {self.name}"
639
590
 
640
591
 
592
+ class TmuxNotifySink(Sink):
593
+ """Show a tmux status-line notice without modifying the pane input buffer."""
594
+
595
+ is_push = True
596
+
597
+ def __init__(self, target: str):
598
+ super().__init__(f"tmux-notify:{target}")
599
+ self.target = target
600
+
601
+ def deliver(self, state: "MonitorState", dms: list, up_to_id: int) -> bool:
602
+ # A notification is deliberately content-free: it must not leak DM text
603
+ # into a shared status line, and unlike TmuxSink it never submits input.
604
+ return _tmux_notify(self.target, max(1, len(dms)))
605
+
606
+ def pending_label(self, count: int) -> str:
607
+ plural = "s" if count != 1 else ""
608
+ return f"{count} DM{plural} not yet notified to {self.name}"
609
+
610
+
641
611
  class StdoutSink(Sink):
642
612
  """Write the DM to stdout. Delivery always succeeds."""
643
613
 
@@ -672,6 +642,13 @@ def parse_sink(spec: str) -> Sink:
672
642
  return NoneSink()
673
643
  if spec == "stdout":
674
644
  return StdoutSink()
645
+ if spec.startswith("tmux-notify:"):
646
+ target = spec[len("tmux-notify:"):]
647
+ if not target:
648
+ raise MonitorSinkError(
649
+ "sink 'tmux-notify:' needs a target, e.g. tmux-notify:lab:0.0"
650
+ )
651
+ return TmuxNotifySink(target)
675
652
  if spec.startswith("tmux:"):
676
653
  target = spec[len("tmux:"):]
677
654
  if not target:
@@ -688,7 +665,8 @@ def parse_sink(spec: str) -> Sink:
688
665
  "ignored. Use --deliver pull / tmux:<target> / stdout / none."
689
666
  )
690
667
  raise MonitorSinkError(
691
- f"unknown sink {spec!r}; expected pull, none, stdout, or tmux:<target>"
668
+ f"unknown sink {spec!r}; expected pull, none, stdout, tmux:<target>, "
669
+ "or tmux-notify:<target>"
692
670
  )
693
671
 
694
672
 
@@ -873,6 +851,24 @@ def _tmux_wake(target: str) -> bool:
873
851
  return False
874
852
 
875
853
 
854
+ def _tmux_notify(target: str, dm_count: int) -> bool:
855
+ """Show a transient tmux notice without sending any pane keystrokes."""
856
+ plural = "s" if dm_count != 1 else ""
857
+ try:
858
+ subprocess.run(
859
+ ["tmux", "display-message", "-t", target,
860
+ f"mesh: {dm_count} new DM{plural}; inbox pending"],
861
+ check=True,
862
+ stdout=subprocess.DEVNULL,
863
+ stderr=subprocess.PIPE,
864
+ text=True,
865
+ )
866
+ return True
867
+ except (OSError, subprocess.CalledProcessError) as exc:
868
+ print(f"[monitor] tmux notification failed: {exc}", file=sys.stderr, flush=True)
869
+ return False
870
+
871
+
876
872
  def _select_next_poll_seconds(state: MonitorState) -> int:
877
873
  if state.disconnect_since is not None:
878
874
  if time.time() - state.disconnect_since > _BACKOFF_5XX_THRESHOLD_SECONDS:
@@ -56,7 +56,8 @@ def _build_parser() -> argparse.ArgumentParser:
56
56
  action="append",
57
57
  default=[],
58
58
  metavar="SINK",
59
- help="repeatable; pull (default) | tmux:<target> | stdout | none. "
59
+ help="repeatable; pull (default) | tmux:<target> | tmux-notify:<target> "
60
+ "| stdout | none. "
60
61
  "Each sink gets its OWN delivery ledger.",
61
62
  )
62
63
  start.add_argument("--poll-s", type=int, default=mesh._DEFAULT_POLL_S)