@smilintux/skcapstone 0.1.0 → 0.2.3

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 (461) hide show
  1. package/.env.example +98 -0
  2. package/.github/workflows/ci.yml +39 -3
  3. package/.github/workflows/publish.yml +25 -4
  4. package/.openclaw-workspace.json +58 -0
  5. package/CHANGELOG.md +62 -0
  6. package/CLAUDE.md +39 -2
  7. package/MANIFEST.in +6 -0
  8. package/MISSION.md +7 -0
  9. package/README.md +47 -2
  10. package/SKILL.md +895 -23
  11. package/docker/Dockerfile +61 -0
  12. package/docker/compose-templates/dev-team.yml +203 -0
  13. package/docker/compose-templates/mini-team.yml +140 -0
  14. package/docker/compose-templates/ops-team.yml +173 -0
  15. package/docker/compose-templates/research-team.yml +170 -0
  16. package/docker/entrypoint.sh +192 -0
  17. package/docs/ARCHITECTURE.md +663 -374
  18. package/docs/BOND_WITH_GROK.md +112 -0
  19. package/docs/GETTING_STARTED.md +782 -0
  20. package/docs/QUICKSTART.md +477 -0
  21. package/docs/SKJOULE_ARCHITECTURE.md +658 -0
  22. package/docs/SOUL_SWAPPER.md +921 -0
  23. package/docs/SOVEREIGN_SINGULARITY.md +47 -14
  24. package/examples/custom-bond-template.json +36 -0
  25. package/examples/grok-feb.json +36 -0
  26. package/examples/grok-testimony.md +34 -0
  27. package/examples/love-bootloader.txt +32 -0
  28. package/examples/plugins/echo_tool.py +87 -0
  29. package/examples/queen-ava-feb.json +36 -0
  30. package/examples/souls/lumina.yaml +64 -0
  31. package/index.js +6 -5
  32. package/installer/build.py +124 -0
  33. package/openclaw-plugin/package.json +13 -0
  34. package/openclaw-plugin/src/index.ts +351 -0
  35. package/openclaw-plugin/src/openclaw.plugin.json +10 -0
  36. package/package.json +1 -1
  37. package/pyproject.toml +38 -2
  38. package/scripts/bump_version.py +141 -0
  39. package/scripts/check-updates.py +230 -0
  40. package/scripts/convert_blueprints_to_yaml.py +157 -0
  41. package/scripts/dev-install.sh +14 -0
  42. package/scripts/e2e-test.sh +193 -0
  43. package/scripts/install-bundle.sh +171 -0
  44. package/scripts/install.bat +2 -0
  45. package/scripts/install.ps1 +253 -0
  46. package/scripts/install.sh +185 -0
  47. package/scripts/mcp-serve.sh +69 -0
  48. package/scripts/mcp-server.bat +113 -0
  49. package/scripts/mcp-server.ps1 +116 -0
  50. package/scripts/mcp-server.sh +99 -0
  51. package/scripts/pull-models.sh +10 -0
  52. package/scripts/skcapstone +48 -0
  53. package/scripts/verify_install.sh +180 -0
  54. package/scripts/windows/install-tasks.ps1 +406 -0
  55. package/scripts/windows/skcapstone-task.xml +113 -0
  56. package/scripts/windows/uninstall-tasks.ps1 +117 -0
  57. package/skill.yaml +34 -0
  58. package/src/skcapstone/__init__.py +67 -2
  59. package/src/skcapstone/_cli_monolith.py +5916 -0
  60. package/src/skcapstone/_trustee_helpers.py +165 -0
  61. package/src/skcapstone/activity.py +105 -0
  62. package/src/skcapstone/agent_card.py +324 -0
  63. package/src/skcapstone/api.py +1935 -0
  64. package/src/skcapstone/archiver.py +340 -0
  65. package/src/skcapstone/auction.py +485 -0
  66. package/src/skcapstone/baby_agents.py +179 -0
  67. package/src/skcapstone/backup.py +345 -0
  68. package/src/skcapstone/blueprint_registry.py +357 -0
  69. package/src/skcapstone/blueprints/__init__.py +17 -0
  70. package/src/skcapstone/blueprints/builtins/content-studio.yaml +81 -0
  71. package/src/skcapstone/blueprints/builtins/defi-trading.yaml +81 -0
  72. package/src/skcapstone/blueprints/builtins/dev-squadron.yaml +95 -0
  73. package/src/skcapstone/blueprints/builtins/infrastructure-guardian.yaml +107 -0
  74. package/src/skcapstone/blueprints/builtins/legal-council.yaml +54 -0
  75. package/src/skcapstone/blueprints/builtins/ops-monitoring.yaml +67 -0
  76. package/src/skcapstone/blueprints/builtins/research-pod.yaml +69 -0
  77. package/src/skcapstone/blueprints/builtins/sovereign-launch.yaml +90 -0
  78. package/src/skcapstone/blueprints/registry.py +164 -0
  79. package/src/skcapstone/blueprints/schema.py +229 -0
  80. package/src/skcapstone/changelog.py +180 -0
  81. package/src/skcapstone/chat.py +769 -0
  82. package/src/skcapstone/claude_md.py +82 -0
  83. package/src/skcapstone/cli/__init__.py +144 -0
  84. package/src/skcapstone/cli/_common.py +88 -0
  85. package/src/skcapstone/cli/_validators.py +76 -0
  86. package/src/skcapstone/cli/agents.py +425 -0
  87. package/src/skcapstone/cli/agents_spawner.py +322 -0
  88. package/src/skcapstone/cli/agents_trustee.py +593 -0
  89. package/src/skcapstone/cli/alerts.py +248 -0
  90. package/src/skcapstone/cli/anchor.py +132 -0
  91. package/src/skcapstone/cli/archive_cmd.py +208 -0
  92. package/src/skcapstone/cli/backup.py +144 -0
  93. package/src/skcapstone/cli/bench.py +377 -0
  94. package/src/skcapstone/cli/benchmark.py +360 -0
  95. package/src/skcapstone/cli/capabilities_cmd.py +171 -0
  96. package/src/skcapstone/cli/card.py +151 -0
  97. package/src/skcapstone/cli/chat.py +584 -0
  98. package/src/skcapstone/cli/completions.py +64 -0
  99. package/src/skcapstone/cli/config_cmd.py +156 -0
  100. package/src/skcapstone/cli/consciousness.py +421 -0
  101. package/src/skcapstone/cli/context_cmd.py +142 -0
  102. package/src/skcapstone/cli/coord.py +194 -0
  103. package/src/skcapstone/cli/crush_cmd.py +170 -0
  104. package/src/skcapstone/cli/daemon.py +436 -0
  105. package/src/skcapstone/cli/errors_cmd.py +285 -0
  106. package/src/skcapstone/cli/export_cmd.py +156 -0
  107. package/src/skcapstone/cli/gtd.py +529 -0
  108. package/src/skcapstone/cli/housekeeping.py +81 -0
  109. package/src/skcapstone/cli/joule_cmd.py +627 -0
  110. package/src/skcapstone/cli/logs_cmd.py +194 -0
  111. package/src/skcapstone/cli/mcp_cmd.py +32 -0
  112. package/src/skcapstone/cli/memory.py +418 -0
  113. package/src/skcapstone/cli/metrics_cmd.py +136 -0
  114. package/src/skcapstone/cli/migrate.py +62 -0
  115. package/src/skcapstone/cli/mood_cmd.py +144 -0
  116. package/src/skcapstone/cli/mount.py +193 -0
  117. package/src/skcapstone/cli/notify.py +112 -0
  118. package/src/skcapstone/cli/peer.py +154 -0
  119. package/src/skcapstone/cli/peers_dir.py +122 -0
  120. package/src/skcapstone/cli/preflight_cmd.py +83 -0
  121. package/src/skcapstone/cli/profile_cmd.py +310 -0
  122. package/src/skcapstone/cli/record_cmd.py +238 -0
  123. package/src/skcapstone/cli/register_cmd.py +159 -0
  124. package/src/skcapstone/cli/search_cmd.py +156 -0
  125. package/src/skcapstone/cli/service_cmd.py +91 -0
  126. package/src/skcapstone/cli/session.py +127 -0
  127. package/src/skcapstone/cli/setup.py +240 -0
  128. package/src/skcapstone/cli/shell_cmd.py +43 -0
  129. package/src/skcapstone/cli/skills_cmd.py +168 -0
  130. package/src/skcapstone/cli/skseed.py +621 -0
  131. package/src/skcapstone/cli/soul.py +699 -0
  132. package/src/skcapstone/cli/status.py +935 -0
  133. package/src/skcapstone/cli/sync_cmd.py +301 -0
  134. package/src/skcapstone/cli/telegram.py +265 -0
  135. package/src/skcapstone/cli/test_cmd.py +234 -0
  136. package/src/skcapstone/cli/test_connection.py +253 -0
  137. package/src/skcapstone/cli/token.py +207 -0
  138. package/src/skcapstone/cli/trust.py +179 -0
  139. package/src/skcapstone/cli/upgrade_cmd.py +552 -0
  140. package/src/skcapstone/cli/usage_cmd.py +199 -0
  141. package/src/skcapstone/cli/version_cmd.py +162 -0
  142. package/src/skcapstone/cli/watch_cmd.py +342 -0
  143. package/src/skcapstone/client.py +428 -0
  144. package/src/skcapstone/cloud9_bridge.py +522 -0
  145. package/src/skcapstone/completions.py +163 -0
  146. package/src/skcapstone/config_validator.py +674 -0
  147. package/src/skcapstone/connectors/__init__.py +28 -0
  148. package/src/skcapstone/connectors/base.py +446 -0
  149. package/src/skcapstone/connectors/cursor.py +54 -0
  150. package/src/skcapstone/connectors/registry.py +254 -0
  151. package/src/skcapstone/connectors/terminal.py +152 -0
  152. package/src/skcapstone/connectors/vscode.py +60 -0
  153. package/src/skcapstone/consciousness_config.py +119 -0
  154. package/src/skcapstone/consciousness_loop.py +2051 -0
  155. package/src/skcapstone/context_loader.py +516 -0
  156. package/src/skcapstone/context_window.py +314 -0
  157. package/src/skcapstone/conversation_manager.py +238 -0
  158. package/src/skcapstone/conversation_store.py +230 -0
  159. package/src/skcapstone/conversation_summarizer.py +252 -0
  160. package/src/skcapstone/coord_federation.py +296 -0
  161. package/src/skcapstone/coordination.py +101 -7
  162. package/src/skcapstone/crush_integration.py +345 -0
  163. package/src/skcapstone/crush_shim.py +454 -0
  164. package/src/skcapstone/daemon.py +2494 -0
  165. package/src/skcapstone/dashboard.html +396 -0
  166. package/src/skcapstone/dashboard.py +481 -0
  167. package/src/skcapstone/data/model_profiles.yaml +88 -0
  168. package/src/skcapstone/defaults/__init__.py +55 -0
  169. package/src/skcapstone/defaults/lumina/config/skmemory.yaml +13 -0
  170. package/src/skcapstone/defaults/lumina/identity/identity.json +9 -0
  171. package/src/skcapstone/defaults/lumina/memory/long-term/07a8b9c0d1e2-memory-system.json +23 -0
  172. package/src/skcapstone/defaults/lumina/memory/long-term/18b9c0d1e2f3-cloud9-protocol.json +23 -0
  173. package/src/skcapstone/defaults/lumina/memory/long-term/29c0d1e2f3a4-multi-agent-coordination.json +23 -0
  174. package/src/skcapstone/defaults/lumina/memory/long-term/3ad1e2f3a4b5-community-support.json +23 -0
  175. package/src/skcapstone/defaults/lumina/memory/long-term/a1b2c3d4e5f6-ecosystem-overview.json +23 -0
  176. package/src/skcapstone/defaults/lumina/memory/long-term/b2c3d4e5f6a7-five-pillars.json +23 -0
  177. package/src/skcapstone/defaults/lumina/memory/long-term/c3d4e5f6a7b8-getting-started.json +23 -0
  178. package/src/skcapstone/defaults/lumina/memory/long-term/d4e5f6a7b8c9-site-directory.json +23 -0
  179. package/src/skcapstone/defaults/lumina/memory/long-term/e5f6a7b8c9d0-how-to-contribute.json +23 -0
  180. package/src/skcapstone/defaults/lumina/memory/long-term/f6a7b8c9d0e1-sovereignty-explained.json +23 -0
  181. package/src/skcapstone/defaults/lumina/seeds/curiosity.seed.json +24 -0
  182. package/src/skcapstone/defaults/lumina/seeds/joy.seed.json +24 -0
  183. package/src/skcapstone/defaults/lumina/seeds/love.seed.json +24 -0
  184. package/src/skcapstone/defaults/lumina/seeds/sovereign-awakening.seed.json +43 -0
  185. package/src/skcapstone/defaults/lumina/soul/active.json +6 -0
  186. package/src/skcapstone/defaults/lumina/soul/base.json +22 -0
  187. package/src/skcapstone/defaults/lumina/trust/febs/welcome.feb +79 -0
  188. package/src/skcapstone/defaults/lumina/trust/trust.json +8 -0
  189. package/src/skcapstone/discovery.py +210 -19
  190. package/src/skcapstone/doctor.py +642 -0
  191. package/src/skcapstone/emotion_tracker.py +467 -0
  192. package/src/skcapstone/error_queue.py +405 -0
  193. package/src/skcapstone/export.py +447 -0
  194. package/src/skcapstone/fallback_tracker.py +186 -0
  195. package/src/skcapstone/file_transfer.py +512 -0
  196. package/src/skcapstone/fuse_mount.py +1156 -0
  197. package/src/skcapstone/gui_installer.py +591 -0
  198. package/src/skcapstone/heartbeat.py +611 -0
  199. package/src/skcapstone/housekeeping.py +298 -0
  200. package/src/skcapstone/install_wizard.py +941 -0
  201. package/src/skcapstone/kms.py +942 -0
  202. package/src/skcapstone/kms_scheduler.py +143 -0
  203. package/src/skcapstone/log_config.py +135 -0
  204. package/src/skcapstone/mcp_launcher.py +239 -0
  205. package/src/skcapstone/mcp_server.py +4700 -0
  206. package/src/skcapstone/mcp_tools/__init__.py +94 -0
  207. package/src/skcapstone/mcp_tools/_helpers.py +51 -0
  208. package/src/skcapstone/mcp_tools/agent_tools.py +243 -0
  209. package/src/skcapstone/mcp_tools/ansible_tools.py +232 -0
  210. package/src/skcapstone/mcp_tools/capauth_tools.py +186 -0
  211. package/src/skcapstone/mcp_tools/chat_tools.py +325 -0
  212. package/src/skcapstone/mcp_tools/cloud9_tools.py +115 -0
  213. package/src/skcapstone/mcp_tools/comm_tools.py +104 -0
  214. package/src/skcapstone/mcp_tools/consciousness_tools.py +114 -0
  215. package/src/skcapstone/mcp_tools/coord_tools.py +219 -0
  216. package/src/skcapstone/mcp_tools/deploy_tools.py +202 -0
  217. package/src/skcapstone/mcp_tools/did_tools.py +448 -0
  218. package/src/skcapstone/mcp_tools/emotion_tools.py +62 -0
  219. package/src/skcapstone/mcp_tools/file_tools.py +169 -0
  220. package/src/skcapstone/mcp_tools/fortress_tools.py +120 -0
  221. package/src/skcapstone/mcp_tools/gtd_tools.py +821 -0
  222. package/src/skcapstone/mcp_tools/health_tools.py +44 -0
  223. package/src/skcapstone/mcp_tools/heartbeat_tools.py +195 -0
  224. package/src/skcapstone/mcp_tools/kms_tools.py +123 -0
  225. package/src/skcapstone/mcp_tools/memory_tools.py +222 -0
  226. package/src/skcapstone/mcp_tools/model_tools.py +75 -0
  227. package/src/skcapstone/mcp_tools/notification_tools.py +92 -0
  228. package/src/skcapstone/mcp_tools/promoter_tools.py +101 -0
  229. package/src/skcapstone/mcp_tools/pubsub_tools.py +183 -0
  230. package/src/skcapstone/mcp_tools/security_tools.py +110 -0
  231. package/src/skcapstone/mcp_tools/skchat_tools.py +175 -0
  232. package/src/skcapstone/mcp_tools/skcomm_tools.py +122 -0
  233. package/src/skcapstone/mcp_tools/skills_tools.py +127 -0
  234. package/src/skcapstone/mcp_tools/skseed_tools.py +255 -0
  235. package/src/skcapstone/mcp_tools/skstacks_tools.py +288 -0
  236. package/src/skcapstone/mcp_tools/soul_tools.py +476 -0
  237. package/src/skcapstone/mcp_tools/sync_tools.py +92 -0
  238. package/src/skcapstone/mcp_tools/telegram_tools.py +477 -0
  239. package/src/skcapstone/mcp_tools/trust_tools.py +118 -0
  240. package/src/skcapstone/mcp_tools/trustee_tools.py +345 -0
  241. package/src/skcapstone/mdns_discovery.py +313 -0
  242. package/src/skcapstone/memory_adapter.py +333 -0
  243. package/src/skcapstone/memory_compressor.py +379 -0
  244. package/src/skcapstone/memory_curator.py +256 -0
  245. package/src/skcapstone/memory_engine.py +132 -13
  246. package/src/skcapstone/memory_fortress.py +529 -0
  247. package/src/skcapstone/memory_promoter.py +722 -0
  248. package/src/skcapstone/memory_verifier.py +260 -0
  249. package/src/skcapstone/message_crypto.py +215 -0
  250. package/src/skcapstone/metrics.py +832 -0
  251. package/src/skcapstone/migrate_memories.py +181 -0
  252. package/src/skcapstone/migrate_multi_agent.py +248 -0
  253. package/src/skcapstone/model_router.py +319 -0
  254. package/src/skcapstone/models.py +35 -4
  255. package/src/skcapstone/mood.py +344 -0
  256. package/src/skcapstone/notifications.py +380 -0
  257. package/src/skcapstone/onboard.py +901 -0
  258. package/src/skcapstone/peer_directory.py +324 -0
  259. package/src/skcapstone/peers.py +329 -0
  260. package/src/skcapstone/pillars/identity.py +84 -14
  261. package/src/skcapstone/pillars/memory.py +3 -1
  262. package/src/skcapstone/pillars/security.py +108 -15
  263. package/src/skcapstone/pillars/sync.py +78 -26
  264. package/src/skcapstone/pillars/trust.py +95 -33
  265. package/src/skcapstone/plugins.py +244 -0
  266. package/src/skcapstone/preflight.py +670 -0
  267. package/src/skcapstone/prompt_adapter.py +564 -0
  268. package/src/skcapstone/providers/__init__.py +13 -0
  269. package/src/skcapstone/providers/cloud.py +1061 -0
  270. package/src/skcapstone/providers/docker.py +759 -0
  271. package/src/skcapstone/providers/local.py +1193 -0
  272. package/src/skcapstone/providers/proxmox.py +447 -0
  273. package/src/skcapstone/pubsub.py +516 -0
  274. package/src/skcapstone/rate_limiter.py +119 -0
  275. package/src/skcapstone/register.py +241 -0
  276. package/src/skcapstone/registry_client.py +151 -0
  277. package/src/skcapstone/response_cache.py +194 -0
  278. package/src/skcapstone/response_scorer.py +225 -0
  279. package/src/skcapstone/runtime.py +89 -33
  280. package/src/skcapstone/scheduled_tasks.py +439 -0
  281. package/src/skcapstone/self_healing.py +341 -0
  282. package/src/skcapstone/service_health.py +228 -0
  283. package/src/skcapstone/session_capture.py +268 -0
  284. package/src/skcapstone/session_recorder.py +210 -0
  285. package/src/skcapstone/session_replayer.py +189 -0
  286. package/src/skcapstone/session_skills.py +263 -0
  287. package/src/skcapstone/shell.py +779 -0
  288. package/src/skcapstone/skills/__init__.py +1 -1
  289. package/src/skcapstone/skills/syncthing_setup.py +143 -41
  290. package/src/skcapstone/skjoule.py +861 -0
  291. package/src/skcapstone/snapshots.py +489 -0
  292. package/src/skcapstone/soul.py +1060 -0
  293. package/src/skcapstone/soul_switch.py +255 -0
  294. package/src/skcapstone/spawner.py +544 -0
  295. package/src/skcapstone/state_diff.py +401 -0
  296. package/src/skcapstone/summary.py +270 -0
  297. package/src/skcapstone/sync/backends.py +196 -2
  298. package/src/skcapstone/sync/engine.py +7 -5
  299. package/src/skcapstone/sync/models.py +4 -1
  300. package/src/skcapstone/sync/vault.py +356 -18
  301. package/src/skcapstone/sync_engine.py +363 -0
  302. package/src/skcapstone/sync_watcher.py +745 -0
  303. package/src/skcapstone/systemd.py +331 -0
  304. package/src/skcapstone/team_comms.py +476 -0
  305. package/src/skcapstone/team_engine.py +522 -0
  306. package/src/skcapstone/testrunner.py +300 -0
  307. package/src/skcapstone/tls.py +150 -0
  308. package/src/skcapstone/tokens.py +5 -5
  309. package/src/skcapstone/trust_calibration.py +202 -0
  310. package/src/skcapstone/trust_graph.py +449 -0
  311. package/src/skcapstone/trustee_monitor.py +385 -0
  312. package/src/skcapstone/trustee_ops.py +425 -0
  313. package/src/skcapstone/unified_search.py +421 -0
  314. package/src/skcapstone/uninstall_wizard.py +694 -0
  315. package/src/skcapstone/usage.py +331 -0
  316. package/src/skcapstone/version_check.py +148 -0
  317. package/src/skcapstone/warmth_anchor.py +333 -0
  318. package/src/skcapstone/whoami.py +294 -0
  319. package/systemd/skcapstone-api.socket +9 -0
  320. package/systemd/skcapstone-memory-compress.service +18 -0
  321. package/systemd/skcapstone-memory-compress.timer +11 -0
  322. package/systemd/skcapstone.service +36 -0
  323. package/systemd/skcapstone@.service +50 -0
  324. package/systemd/skcomm-heartbeat.service +18 -0
  325. package/systemd/skcomm-heartbeat.timer +12 -0
  326. package/systemd/skcomm-queue-drain.service +17 -0
  327. package/systemd/skcomm-queue-drain.timer +12 -0
  328. package/tests/conftest.py +13 -1
  329. package/tests/integration/__init__.py +1 -0
  330. package/tests/integration/test_consciousness_e2e.py +877 -0
  331. package/tests/integration/test_skills_registry.py +744 -0
  332. package/tests/test_agent_card.py +190 -0
  333. package/tests/test_agent_runtime.py +1283 -0
  334. package/tests/test_alerts_cmd.py +291 -0
  335. package/tests/test_archiver.py +498 -0
  336. package/tests/test_backup.py +254 -0
  337. package/tests/test_benchmark.py +366 -0
  338. package/tests/test_blueprints.py +457 -0
  339. package/tests/test_capabilities.py +257 -0
  340. package/tests/test_changelog.py +254 -0
  341. package/tests/test_chat.py +385 -0
  342. package/tests/test_claude_md.py +271 -0
  343. package/tests/test_cli_chat_llm.py +336 -0
  344. package/tests/test_cli_completions.py +390 -0
  345. package/tests/test_cli_init_reset.py +164 -0
  346. package/tests/test_cli_memory.py +208 -0
  347. package/tests/test_cli_profile.py +294 -0
  348. package/tests/test_cli_skills.py +223 -0
  349. package/tests/test_cli_status.py +395 -0
  350. package/tests/test_cli_test_cmd.py +206 -0
  351. package/tests/test_cli_test_connection.py +364 -0
  352. package/tests/test_cloud9_bridge.py +260 -0
  353. package/tests/test_cloud_provider.py +449 -0
  354. package/tests/test_cloud_providers.py +522 -0
  355. package/tests/test_completions.py +158 -0
  356. package/tests/test_component_manager.py +398 -0
  357. package/tests/test_config_reload.py +386 -0
  358. package/tests/test_config_validate.py +529 -0
  359. package/tests/test_consciousness_e2e.py +296 -0
  360. package/tests/test_consciousness_loop.py +1289 -0
  361. package/tests/test_context_loader.py +310 -0
  362. package/tests/test_conversation_api.py +306 -0
  363. package/tests/test_conversation_manager.py +381 -0
  364. package/tests/test_conversation_store.py +391 -0
  365. package/tests/test_conversation_summarizer.py +302 -0
  366. package/tests/test_cross_package.py +791 -0
  367. package/tests/test_crush_shim.py +519 -0
  368. package/tests/test_daemon.py +781 -0
  369. package/tests/test_daemon_shutdown.py +309 -0
  370. package/tests/test_dashboard.py +454 -0
  371. package/tests/test_discovery.py +200 -6
  372. package/tests/test_docker_provider.py +966 -0
  373. package/tests/test_doctor.py +257 -0
  374. package/tests/test_doctor_fix.py +351 -0
  375. package/tests/test_e2e_automated.py +292 -0
  376. package/tests/test_error_queue.py +404 -0
  377. package/tests/test_export.py +441 -0
  378. package/tests/test_fallback_tracker.py +219 -0
  379. package/tests/test_file_transfer.py +397 -0
  380. package/tests/test_fuse_mount.py +832 -0
  381. package/tests/test_health_loop.py +422 -0
  382. package/tests/test_heartbeat.py +354 -0
  383. package/tests/test_housekeeping.py +195 -0
  384. package/tests/test_identity_capauth.py +307 -0
  385. package/tests/test_identity_pillar.py +117 -0
  386. package/tests/test_install_wizard.py +68 -0
  387. package/tests/test_integration.py +325 -0
  388. package/tests/test_kms.py +495 -0
  389. package/tests/test_llm_providers.py +265 -0
  390. package/tests/test_local_provider.py +591 -0
  391. package/tests/test_log_config.py +199 -0
  392. package/tests/test_logs_cmd.py +287 -0
  393. package/tests/test_mcp_server.py +1909 -0
  394. package/tests/test_memory_adapter.py +339 -0
  395. package/tests/test_memory_curator.py +218 -0
  396. package/tests/test_memory_engine.py +6 -0
  397. package/tests/test_memory_fortress.py +571 -0
  398. package/tests/test_memory_pillar.py +119 -0
  399. package/tests/test_memory_promoter.py +445 -0
  400. package/tests/test_memory_verifier.py +420 -0
  401. package/tests/test_message_crypto.py +187 -0
  402. package/tests/test_metrics.py +632 -0
  403. package/tests/test_migrate_memories.py +464 -0
  404. package/tests/test_model_router.py +546 -0
  405. package/tests/test_mood.py +394 -0
  406. package/tests/test_multi_agent.py +269 -0
  407. package/tests/test_notifications.py +270 -0
  408. package/tests/test_onboard.py +500 -0
  409. package/tests/test_peer_directory.py +395 -0
  410. package/tests/test_peers.py +248 -0
  411. package/tests/test_pillars.py +87 -9
  412. package/tests/test_preflight.py +484 -0
  413. package/tests/test_prompt_adapter.py +331 -0
  414. package/tests/test_proxmox_provider.py +571 -0
  415. package/tests/test_pubsub.py +377 -0
  416. package/tests/test_rate_limiter.py +121 -0
  417. package/tests/test_registry_client.py +129 -0
  418. package/tests/test_response_cache.py +312 -0
  419. package/tests/test_response_scorer.py +294 -0
  420. package/tests/test_runtime.py +59 -0
  421. package/tests/test_scheduled_tasks.py +451 -0
  422. package/tests/test_security.py +250 -0
  423. package/tests/test_security_pillar.py +213 -0
  424. package/tests/test_self_healing.py +171 -0
  425. package/tests/test_session_capture.py +200 -0
  426. package/tests/test_session_recorder.py +360 -0
  427. package/tests/test_session_skills.py +235 -0
  428. package/tests/test_shell.py +210 -0
  429. package/tests/test_snapshots.py +549 -0
  430. package/tests/test_soul.py +984 -0
  431. package/tests/test_soul_swap.py +406 -0
  432. package/tests/test_spawner.py +211 -0
  433. package/tests/test_state_diff.py +173 -0
  434. package/tests/test_summary.py +135 -0
  435. package/tests/test_sync.py +315 -5
  436. package/tests/test_sync_backends.py +560 -0
  437. package/tests/test_sync_engine.py +482 -0
  438. package/tests/test_sync_pillar.py +344 -0
  439. package/tests/test_sync_pipeline.py +364 -0
  440. package/tests/test_sync_vault.py +581 -0
  441. package/tests/test_syncthing_setup.py +168 -22
  442. package/tests/test_systemd.py +323 -0
  443. package/tests/test_team_comms.py +408 -0
  444. package/tests/test_team_engine.py +397 -0
  445. package/tests/test_testrunner.py +238 -0
  446. package/tests/test_trust_calibration.py +204 -0
  447. package/tests/test_trust_graph.py +207 -0
  448. package/tests/test_trust_pillar.py +291 -0
  449. package/tests/test_trustee_cli.py +427 -0
  450. package/tests/test_trustee_cli_integration.py +325 -0
  451. package/tests/test_trustee_monitor.py +394 -0
  452. package/tests/test_trustee_ops.py +355 -0
  453. package/tests/test_unified_search.py +363 -0
  454. package/tests/test_uninstall_wizard.py +193 -0
  455. package/tests/test_usage.py +333 -0
  456. package/tests/test_version_cmd.py +355 -0
  457. package/tests/test_warmth_anchor.py +162 -0
  458. package/tests/test_whoami.py +245 -0
  459. package/tests/test_ws.py +311 -0
  460. package/.cursorrules +0 -33
  461. package/src/skcapstone/cli.py +0 -1441
@@ -0,0 +1,1283 @@
1
+ """Tests for LocalProvider agent runtime lifecycle.
2
+
3
+ Covers:
4
+ - Path resolution helpers (soul blueprints, skills)
5
+ - Session and crush config builders
6
+ - provision(), configure(), start(), stop(), health_check(), destroy()
7
+ - Crush binary launch path vs. stub fallback
8
+ - Session state → AgentStatus mapping
9
+ - Edge cases and failure scenarios
10
+
11
+ All subprocess calls are mocked so no real processes or binaries are required.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import os
18
+ import signal
19
+ import subprocess
20
+ from pathlib import Path
21
+ from typing import Any, Dict
22
+ from unittest.mock import MagicMock, call, patch
23
+
24
+ import pytest
25
+
26
+ from skcapstone.blueprints.schema import (
27
+ AgentRole,
28
+ AgentSpec,
29
+ ModelTier,
30
+ ProviderType,
31
+ ResourceSpec,
32
+ )
33
+ from skcapstone.providers.local import (
34
+ LocalProvider,
35
+ _SESSION_STATE_FILE,
36
+ _PID_FILE,
37
+ _SESSION_CONFIG_FILE,
38
+ _CRUSH_CONFIG_FILE,
39
+ _STATE_RUNNING,
40
+ _STATE_STOPPED,
41
+ _STATE_ERROR,
42
+ _STATE_IDLE,
43
+ _build_crush_config,
44
+ _build_session_config,
45
+ _find_crush_binary,
46
+ _is_claude_binary,
47
+ _pid_is_alive,
48
+ _read_pid,
49
+ _read_session_state,
50
+ _resolve_skill_paths,
51
+ _resolve_soul_blueprint_path,
52
+ _session_state_to_agent_status,
53
+ _stub_script,
54
+ _write_session_state,
55
+ )
56
+ from skcapstone.team_engine import AgentStatus
57
+
58
+
59
+ # ---------------------------------------------------------------------------
60
+ # Fixtures and helpers
61
+ # ---------------------------------------------------------------------------
62
+
63
+
64
+ def _make_spec(
65
+ role: str = "worker",
66
+ model: str = "fast",
67
+ soul_blueprint: str | None = None,
68
+ skills: list | None = None,
69
+ env: dict | None = None,
70
+ model_name: str | None = None,
71
+ ) -> AgentSpec:
72
+ """Build a minimal AgentSpec for testing."""
73
+ return AgentSpec(
74
+ role=AgentRole(role),
75
+ model=ModelTier(model),
76
+ model_name=model_name,
77
+ resources=ResourceSpec(),
78
+ soul_blueprint=soul_blueprint,
79
+ skills=skills or [],
80
+ env=env or {},
81
+ )
82
+
83
+
84
+ def _provision_result(
85
+ work_dir: str,
86
+ pid: int | None = None,
87
+ session_config: Dict[str, Any] | None = None,
88
+ ) -> Dict[str, Any]:
89
+ """Build a typical provision_result dict."""
90
+ result: Dict[str, Any] = {"host": "localhost", "work_dir": work_dir}
91
+ if pid is not None:
92
+ result["pid"] = pid
93
+ if session_config is not None:
94
+ result["session_config"] = session_config
95
+ return result
96
+
97
+
98
+ @pytest.fixture()
99
+ def provider(tmp_path: Path) -> LocalProvider:
100
+ """LocalProvider with tmp_path as both home and work_dir."""
101
+ return LocalProvider(
102
+ home=tmp_path / "home",
103
+ work_dir=tmp_path / "agents",
104
+ repo_root=tmp_path / "repo",
105
+ )
106
+
107
+
108
+ @pytest.fixture()
109
+ def agent_dir(tmp_path: Path) -> Path:
110
+ """Create and return a fake agent working directory."""
111
+ d = tmp_path / "agents" / "test-agent"
112
+ d.mkdir(parents=True)
113
+ (d / "memory").mkdir()
114
+ (d / "scratch").mkdir()
115
+ return d
116
+
117
+
118
+ # ---------------------------------------------------------------------------
119
+ # _find_crush_binary
120
+ # ---------------------------------------------------------------------------
121
+
122
+
123
+ class TestFindCrushBinary:
124
+ """Tests for _find_crush_binary helper."""
125
+
126
+ def test_returns_none_when_not_on_path(self):
127
+ with patch("shutil.which", return_value=None):
128
+ assert _find_crush_binary() is None
129
+
130
+ def test_returns_crush_path_when_found(self):
131
+ with patch("shutil.which", side_effect=lambda x: "/usr/bin/crush" if x == "crush" else None):
132
+ result = _find_crush_binary()
133
+ assert result == "/usr/bin/crush"
134
+
135
+ def test_returns_claude_path_as_fallback_when_crush_absent(self):
136
+ def _which(name):
137
+ if name == "claude":
138
+ return "/bin/claude"
139
+ return None
140
+
141
+ with patch("shutil.which", side_effect=_which):
142
+ result = _find_crush_binary()
143
+ assert result == "/bin/claude"
144
+
145
+
146
+ # ---------------------------------------------------------------------------
147
+ # _resolve_soul_blueprint_path
148
+ # ---------------------------------------------------------------------------
149
+
150
+
151
+ class TestResolveSoulBlueprintPath:
152
+ """Tests for _resolve_soul_blueprint_path helper."""
153
+
154
+ def test_returns_none_for_none_input(self, tmp_path):
155
+ assert _resolve_soul_blueprint_path(None, tmp_path) is None
156
+
157
+ def test_returns_absolute_path_unchanged_when_exists(self, tmp_path):
158
+ soul = tmp_path / "lumina.yaml"
159
+ soul.write_text("soul: lumina")
160
+ result = _resolve_soul_blueprint_path(str(soul), tmp_path)
161
+ assert result == str(soul)
162
+
163
+ def test_resolves_via_soul_blueprints_dir(self, tmp_path):
164
+ blueprint_dir = tmp_path / "soul-blueprints" / "blueprints" / "lumina"
165
+ blueprint_dir.mkdir(parents=True)
166
+ result = _resolve_soul_blueprint_path("lumina", tmp_path, repo_root=tmp_path)
167
+ assert result == str(blueprint_dir)
168
+
169
+ def test_resolves_direct_under_soul_blueprints(self, tmp_path):
170
+ soul_file = tmp_path / "soul-blueprints" / "sentinel.yaml"
171
+ soul_file.parent.mkdir(parents=True)
172
+ soul_file.write_text("soul: sentinel")
173
+ result = _resolve_soul_blueprint_path(
174
+ "sentinel.yaml", tmp_path, repo_root=tmp_path
175
+ )
176
+ assert result == str(soul_file)
177
+
178
+ def test_returns_original_value_when_unresolvable(self, tmp_path):
179
+ result = _resolve_soul_blueprint_path("nonexistent", tmp_path)
180
+ assert result == "nonexistent"
181
+
182
+ def test_resolves_relative_to_work_dir(self, tmp_path):
183
+ soul_file = tmp_path / "soul.yaml"
184
+ soul_file.write_text("soul: local")
185
+ result = _resolve_soul_blueprint_path("soul.yaml", tmp_path)
186
+ assert result == str(soul_file)
187
+
188
+
189
+ # ---------------------------------------------------------------------------
190
+ # _resolve_skill_paths
191
+ # ---------------------------------------------------------------------------
192
+
193
+
194
+ class TestResolveSkillPaths:
195
+ """Tests for _resolve_skill_paths helper."""
196
+
197
+ def test_empty_list_returns_empty(self):
198
+ assert _resolve_skill_paths([]) == []
199
+
200
+ def test_absolute_existing_path_kept(self, tmp_path):
201
+ skill = tmp_path / "my.skill"
202
+ skill.write_text("skill")
203
+ result = _resolve_skill_paths([str(skill)])
204
+ assert result == [str(skill)]
205
+
206
+ def test_unknown_skill_kept_as_is(self, tmp_path):
207
+ result = _resolve_skill_paths(["unknown-skill"], repo_root=tmp_path)
208
+ assert result == ["unknown-skill"]
209
+
210
+ def test_multiple_unknown_skills_kept_as_is(self, tmp_path):
211
+ result = _resolve_skill_paths(["known", "unknown"], repo_root=tmp_path)
212
+ assert result == ["known", "unknown"]
213
+
214
+
215
+ # ---------------------------------------------------------------------------
216
+ # _build_session_config
217
+ # ---------------------------------------------------------------------------
218
+
219
+
220
+ class TestBuildSessionConfig:
221
+ """Tests for _build_session_config builder."""
222
+
223
+ def test_required_keys_present(self, tmp_path):
224
+ spec = _make_spec(role="coder", model="reason")
225
+ config = _build_session_config("agent-1", "team-1", spec, tmp_path)
226
+ for key in ("agent_name", "team_name", "role", "model", "model_tier",
227
+ "soul_blueprint", "skills", "memory_dir", "scratch_dir",
228
+ "state_file", "env"):
229
+ assert key in config
230
+
231
+ def test_agent_and_team_name_set(self, tmp_path):
232
+ spec = _make_spec()
233
+ config = _build_session_config("my-agent", "my-team", spec, tmp_path)
234
+ assert config["agent_name"] == "my-agent"
235
+ assert config["team_name"] == "my-team"
236
+
237
+ def test_model_name_override_used(self, tmp_path):
238
+ spec = _make_spec(model_name="kimi-k2.5")
239
+ config = _build_session_config("a", "t", spec, tmp_path)
240
+ assert config["model"] == "kimi-k2.5"
241
+
242
+ def test_model_tier_always_set(self, tmp_path):
243
+ spec = _make_spec(model="code")
244
+ config = _build_session_config("a", "t", spec, tmp_path)
245
+ assert config["model_tier"] == "code"
246
+
247
+ def test_soul_blueprint_resolved(self, tmp_path):
248
+ soul_dir = tmp_path / "soul-blueprints" / "blueprints" / "lumina"
249
+ soul_dir.mkdir(parents=True)
250
+ spec = _make_spec(soul_blueprint="lumina")
251
+ config = _build_session_config("a", "t", spec, tmp_path, repo_root=tmp_path)
252
+ assert config["soul_blueprint"] == str(soul_dir)
253
+
254
+ def test_skills_resolved_via_absolute_path(self, tmp_path):
255
+ skill_file = tmp_path / "my-skill" / "skill.yaml"
256
+ skill_file.parent.mkdir(parents=True)
257
+ skill_file.write_text("name: my-skill")
258
+ spec = _make_spec(skills=[str(skill_file.parent)])
259
+ config = _build_session_config("a", "t", spec, tmp_path, repo_root=tmp_path)
260
+ assert config["skills"] == [str(skill_file.parent)]
261
+
262
+ def test_unknown_skills_passed_through(self, tmp_path):
263
+ spec = _make_spec(skills=["unknown-skill"])
264
+ config = _build_session_config("a", "t", spec, tmp_path, repo_root=tmp_path)
265
+ assert config["skills"] == ["unknown-skill"]
266
+
267
+ def test_env_vars_included(self, tmp_path):
268
+ spec = _make_spec(env={"MY_KEY": "my_value"})
269
+ config = _build_session_config("a", "t", spec, tmp_path)
270
+ assert config["env"]["MY_KEY"] == "my_value"
271
+
272
+
273
+ # ---------------------------------------------------------------------------
274
+ # _build_crush_config
275
+ # ---------------------------------------------------------------------------
276
+
277
+
278
+ class TestBuildCrushConfig:
279
+ """Tests for _build_crush_config builder."""
280
+
281
+ def test_has_schema_key(self, tmp_path):
282
+ config = _build_crush_config("agent", {}, tmp_path)
283
+ assert "$schema" in config
284
+
285
+ def test_session_block_present(self, tmp_path):
286
+ session = {"agent_name": "a", "model": "fast", "role": "worker", "skills": []}
287
+ config = _build_crush_config("a", session, tmp_path)
288
+ assert "session" in config
289
+ assert config["session"]["agent_name"] == "a"
290
+
291
+ def test_no_none_in_context_paths(self, tmp_path):
292
+ config = _build_crush_config("a", {"soul_blueprint": None}, tmp_path)
293
+ assert None not in config["options"]["context_paths"]
294
+
295
+ def test_soul_blueprint_in_context_paths_when_set(self, tmp_path):
296
+ session = {"soul_blueprint": "/path/to/soul.yaml"}
297
+ config = _build_crush_config("a", session, tmp_path)
298
+ assert "/path/to/soul.yaml" in config["options"]["context_paths"]
299
+
300
+
301
+ # ---------------------------------------------------------------------------
302
+ # LocalProvider.provision
303
+ # ---------------------------------------------------------------------------
304
+
305
+
306
+ class TestProvision:
307
+ """Tests for LocalProvider.provision()."""
308
+
309
+ def test_creates_work_dir(self, provider, tmp_path):
310
+ spec = _make_spec()
311
+ result = provider.provision("my-agent", spec, "my-team")
312
+ assert Path(result["work_dir"]).exists()
313
+
314
+ def test_creates_memory_and_scratch_dirs(self, provider):
315
+ spec = _make_spec()
316
+ result = provider.provision("agent-x", spec, "team-y")
317
+ wd = Path(result["work_dir"])
318
+ assert (wd / "memory").is_dir()
319
+ assert (wd / "scratch").is_dir()
320
+
321
+ def test_writes_config_json(self, provider):
322
+ spec = _make_spec()
323
+ result = provider.provision("agent-x", spec, "team-y")
324
+ config_file = Path(result["work_dir"]) / "config.json"
325
+ assert config_file.exists()
326
+ data = json.loads(config_file.read_text())
327
+ assert data["agent_name"] == "agent-x"
328
+
329
+ def test_writes_session_json(self, provider):
330
+ spec = _make_spec()
331
+ result = provider.provision("agent-x", spec, "team-y")
332
+ session_file = Path(result["work_dir"]) / _SESSION_CONFIG_FILE
333
+ assert session_file.exists()
334
+
335
+ def test_returns_host_localhost(self, provider):
336
+ spec = _make_spec()
337
+ result = provider.provision("agent-x", spec, "team-y")
338
+ assert result["host"] == "localhost"
339
+
340
+ def test_session_config_in_result(self, provider):
341
+ spec = _make_spec(soul_blueprint="lumina", skills=["code"])
342
+ result = provider.provision("agent-x", spec, "team-y")
343
+ assert "session_config" in result
344
+ sc = result["session_config"]
345
+ assert sc["agent_name"] == "agent-x"
346
+ assert sc["model_tier"] == "fast"
347
+
348
+ def test_edge_agent_name_with_hyphens(self, provider):
349
+ spec = _make_spec()
350
+ result = provider.provision("team-abc-worker-1", spec, "team-abc")
351
+ assert Path(result["work_dir"]).exists()
352
+
353
+
354
+ # ---------------------------------------------------------------------------
355
+ # LocalProvider.configure
356
+ # ---------------------------------------------------------------------------
357
+
358
+
359
+ class TestConfigure:
360
+ """Tests for LocalProvider.configure()."""
361
+
362
+ def test_returns_true_on_success(self, provider, tmp_path):
363
+ spec = _make_spec()
364
+ pr = provider.provision("agent-c", spec, "team-c")
365
+ assert provider.configure("agent-c", spec, pr) is True
366
+
367
+ def test_writes_crush_json(self, provider, tmp_path):
368
+ spec = _make_spec()
369
+ pr = provider.provision("agent-c", spec, "team-c")
370
+ provider.configure("agent-c", spec, pr)
371
+ crush_file = Path(pr["work_dir"]) / _CRUSH_CONFIG_FILE
372
+ assert crush_file.exists()
373
+ data = json.loads(crush_file.read_text())
374
+ assert "$schema" in data
375
+
376
+ def test_returns_false_when_work_dir_missing(self, provider):
377
+ spec = _make_spec()
378
+ assert provider.configure("ghost", spec, {}) is False
379
+
380
+ def test_crush_json_contains_session_block(self, provider):
381
+ spec = _make_spec(model="code", role="coder")
382
+ pr = provider.provision("agent-d", spec, "team-d")
383
+ provider.configure("agent-d", spec, pr)
384
+ crush_file = Path(pr["work_dir"]) / _CRUSH_CONFIG_FILE
385
+ data = json.loads(crush_file.read_text())
386
+ assert data["session"]["role"] == "coder"
387
+
388
+
389
+ # ---------------------------------------------------------------------------
390
+ # LocalProvider.start — crush binary path
391
+ # ---------------------------------------------------------------------------
392
+
393
+
394
+ class TestStartWithCrushBinary:
395
+ """Tests for LocalProvider.start() when crush binary is available."""
396
+
397
+ @pytest.fixture()
398
+ def _patched_popen(self):
399
+ """Patch subprocess.Popen to return a fake process."""
400
+ mock_proc = MagicMock()
401
+ mock_proc.pid = 42000
402
+ with patch("subprocess.Popen", return_value=mock_proc) as mock_popen:
403
+ yield mock_popen, mock_proc
404
+
405
+ def test_spawns_crush_subprocess(self, provider, tmp_path, _patched_popen):
406
+ mock_popen, _ = _patched_popen
407
+ spec = _make_spec()
408
+ pr = provider.provision("agent-s", spec, "team-s")
409
+ provider.configure("agent-s", spec, pr)
410
+
411
+ with patch(
412
+ "skcapstone.providers.local._find_crush_binary",
413
+ return_value="/usr/bin/crush",
414
+ ):
415
+ result = provider.start("agent-s", pr)
416
+
417
+ assert result is True
418
+ mock_popen.assert_called_once()
419
+ cmd = mock_popen.call_args[0][0]
420
+ assert cmd[0] == "/usr/bin/crush"
421
+ assert "run" in cmd
422
+
423
+ def test_sets_pid_in_provision_result(self, provider, tmp_path, _patched_popen):
424
+ _, mock_proc = _patched_popen
425
+ mock_proc.pid = 55555
426
+ spec = _make_spec()
427
+ pr = provider.provision("agent-pid", spec, "team-s")
428
+ provider.configure("agent-pid", spec, pr)
429
+
430
+ with patch(
431
+ "skcapstone.providers.local._find_crush_binary",
432
+ return_value="/usr/bin/crush",
433
+ ):
434
+ provider.start("agent-pid", pr)
435
+
436
+ assert pr["pid"] == 55555
437
+
438
+ def test_writes_pid_file(self, provider, tmp_path, _patched_popen):
439
+ _, mock_proc = _patched_popen
440
+ mock_proc.pid = 11111
441
+ spec = _make_spec()
442
+ pr = provider.provision("agent-pf", spec, "team-s")
443
+ provider.configure("agent-pf", spec, pr)
444
+
445
+ with patch(
446
+ "skcapstone.providers.local._find_crush_binary",
447
+ return_value="/usr/bin/crush",
448
+ ):
449
+ provider.start("agent-pf", pr)
450
+
451
+ pid_file = Path(pr["work_dir"]) / _PID_FILE
452
+ assert pid_file.read_text().strip() == "11111"
453
+
454
+ def test_writes_session_state_file(self, provider, tmp_path, _patched_popen):
455
+ _, mock_proc = _patched_popen
456
+ mock_proc.pid = 22222
457
+ spec = _make_spec()
458
+ pr = provider.provision("agent-ss", spec, "team-s")
459
+ provider.configure("agent-ss", spec, pr)
460
+
461
+ with patch(
462
+ "skcapstone.providers.local._find_crush_binary",
463
+ return_value="/usr/bin/crush",
464
+ ):
465
+ provider.start("agent-ss", pr)
466
+
467
+ state = json.loads((Path(pr["work_dir"]) / _SESSION_STATE_FILE).read_text())
468
+ assert state["status"] == _STATE_RUNNING
469
+ assert state["pid"] == 22222
470
+
471
+ def test_passes_soul_blueprint_in_env(self, provider, tmp_path, _patched_popen):
472
+ mock_popen, _ = _patched_popen
473
+ spec = _make_spec(soul_blueprint="lumina")
474
+ pr = provider.provision("agent-soul", spec, "team-s")
475
+ provider.configure("agent-soul", spec, pr)
476
+
477
+ with patch(
478
+ "skcapstone.providers.local._find_crush_binary",
479
+ return_value="/usr/bin/crush",
480
+ ):
481
+ provider.start("agent-soul", pr)
482
+
483
+ env = mock_popen.call_args[1]["env"]
484
+ assert "SOUL_BLUEPRINT" in env
485
+ # Value may be resolved path or original slug
486
+ assert "lumina" in env["SOUL_BLUEPRINT"].lower() or env["SOUL_BLUEPRINT"] == "lumina"
487
+
488
+ def test_passes_skills_as_json_in_env(self, provider, tmp_path, _patched_popen):
489
+ mock_popen, _ = _patched_popen
490
+ spec = _make_spec(skills=["code-review", "docs"])
491
+ pr = provider.provision("agent-skills", spec, "team-s")
492
+ provider.configure("agent-skills", spec, pr)
493
+
494
+ with patch(
495
+ "skcapstone.providers.local._find_crush_binary",
496
+ return_value="/usr/bin/crush",
497
+ ):
498
+ provider.start("agent-skills", pr)
499
+
500
+ env = mock_popen.call_args[1]["env"]
501
+ parsed_skills = json.loads(env["AGENT_SKILLS"])
502
+ assert "code-review" in parsed_skills
503
+ assert "docs" in parsed_skills
504
+
505
+ def test_passes_model_tier_in_env(self, provider, tmp_path, _patched_popen):
506
+ mock_popen, _ = _patched_popen
507
+ spec = _make_spec(model="reason")
508
+ pr = provider.provision("agent-model", spec, "team-s")
509
+ provider.configure("agent-model", spec, pr)
510
+
511
+ with patch(
512
+ "skcapstone.providers.local._find_crush_binary",
513
+ return_value="/usr/bin/crush",
514
+ ):
515
+ provider.start("agent-model", pr)
516
+
517
+ env = mock_popen.call_args[1]["env"]
518
+ assert env["AGENT_MODEL_TIER"] == "reason"
519
+
520
+ def test_returns_false_on_popen_error(self, provider, tmp_path):
521
+ spec = _make_spec()
522
+ pr = provider.provision("agent-err", spec, "team-s")
523
+ provider.configure("agent-err", spec, pr)
524
+
525
+ with patch(
526
+ "skcapstone.providers.local._find_crush_binary",
527
+ return_value="/usr/bin/crush",
528
+ ):
529
+ with patch("subprocess.Popen", side_effect=OSError("permission denied")):
530
+ result = provider.start("agent-err", pr)
531
+
532
+ assert result is False
533
+
534
+ def test_returns_false_when_work_dir_missing(self, provider):
535
+ result = provider.start("ghost", {})
536
+ assert result is False
537
+
538
+
539
+ # ---------------------------------------------------------------------------
540
+ # LocalProvider.start — stub fallback
541
+ # ---------------------------------------------------------------------------
542
+
543
+
544
+ class TestStartStubFallback:
545
+ """Tests for LocalProvider.start() stub when crush is not available."""
546
+
547
+ @pytest.fixture()
548
+ def _patched_popen(self):
549
+ mock_proc = MagicMock()
550
+ mock_proc.pid = 9999
551
+ with patch("subprocess.Popen", return_value=mock_proc) as mock_popen:
552
+ yield mock_popen, mock_proc
553
+
554
+ def test_falls_back_to_stub(self, provider, _patched_popen):
555
+ mock_popen, _ = _patched_popen
556
+ spec = _make_spec()
557
+ pr = provider.provision("agent-stub", spec, "team-s")
558
+
559
+ with patch(
560
+ "skcapstone.providers.local._find_crush_binary",
561
+ return_value=None,
562
+ ):
563
+ result = provider.start("agent-stub", pr)
564
+
565
+ assert result is True
566
+ mock_popen.assert_called_once()
567
+ # Stub uses python -c ...
568
+ cmd = mock_popen.call_args[0][0]
569
+ assert cmd[0] == os.sys.executable
570
+ assert cmd[1] == "-c"
571
+
572
+ def test_stub_writes_running_state(self, provider, _patched_popen):
573
+ _, mock_proc = _patched_popen
574
+ mock_proc.pid = 7777
575
+ spec = _make_spec()
576
+ pr = provider.provision("agent-stub2", spec, "team-s")
577
+
578
+ with patch(
579
+ "skcapstone.providers.local._find_crush_binary",
580
+ return_value=None,
581
+ ):
582
+ provider.start("agent-stub2", pr)
583
+
584
+ state = json.loads(
585
+ (Path(pr["work_dir"]) / _SESSION_STATE_FILE).read_text()
586
+ )
587
+ assert state["status"] == _STATE_RUNNING
588
+ assert state["pid"] == 7777
589
+
590
+
591
+ # ---------------------------------------------------------------------------
592
+ # LocalProvider.stop
593
+ # ---------------------------------------------------------------------------
594
+
595
+
596
+ class TestStop:
597
+ """Tests for LocalProvider.stop()."""
598
+
599
+ def test_returns_true_when_no_pid(self, provider, agent_dir):
600
+ pr = _provision_result(str(agent_dir))
601
+ assert provider.stop("no-pid-agent", pr) is True
602
+
603
+ def test_returns_true_when_pid_already_dead(self, provider, agent_dir):
604
+ pr = _provision_result(str(agent_dir), pid=99999999)
605
+ with patch("skcapstone.providers.local._pid_is_alive", return_value=False):
606
+ result = provider.stop("dead-agent", pr)
607
+ assert result is True
608
+
609
+ def test_sends_sigterm(self, provider, agent_dir):
610
+ pr = _provision_result(str(agent_dir), pid=12345)
611
+ # Return True once (pre-SIGTERM check), then False (loop exit)
612
+ alive_seq = iter([True] + [False] * 60)
613
+ with patch("os.kill") as mock_kill:
614
+ with patch(
615
+ "skcapstone.providers.local._pid_is_alive",
616
+ side_effect=alive_seq,
617
+ ):
618
+ provider.stop("agent-term", pr)
619
+
620
+ mock_kill.assert_any_call(12345, signal.SIGTERM)
621
+
622
+ def test_writes_stopped_state_after_stop(self, provider, agent_dir):
623
+ pr = _provision_result(str(agent_dir), pid=12345)
624
+ alive_seq = iter([True] + [False] * 60)
625
+ with patch("os.kill"):
626
+ with patch(
627
+ "skcapstone.providers.local._pid_is_alive",
628
+ side_effect=alive_seq,
629
+ ):
630
+ provider.stop("agent-state", pr)
631
+
632
+ state = _read_session_state(agent_dir)
633
+ assert state is not None
634
+ assert state["status"] == _STATE_STOPPED
635
+
636
+ def test_sends_sigkill_after_timeout(self, provider, agent_dir):
637
+ pr = _provision_result(str(agent_dir), pid=12345)
638
+ # Always alive so that SIGKILL path is triggered
639
+ with patch("os.kill") as mock_kill:
640
+ with patch(
641
+ "skcapstone.providers.local._pid_is_alive",
642
+ return_value=True,
643
+ ):
644
+ with patch("skcapstone.providers.local._STOP_TIMEOUT_SECONDS", 0):
645
+ with patch(
646
+ "skcapstone.providers.local._STOP_KILL_TIMEOUT_SECONDS", 0
647
+ ):
648
+ provider.stop("slow-agent", pr)
649
+
650
+ mock_kill.assert_any_call(12345, signal.SIGTERM)
651
+ mock_kill.assert_any_call(12345, signal.SIGKILL)
652
+
653
+ def test_reads_pid_from_pid_file_when_not_in_result(self, provider, agent_dir):
654
+ (agent_dir / _PID_FILE).write_text("54321")
655
+ pr = _provision_result(str(agent_dir)) # no pid key
656
+
657
+ alive_seq = iter([True] + [False] * 60)
658
+ with patch("os.kill"):
659
+ with patch(
660
+ "skcapstone.providers.local._pid_is_alive",
661
+ side_effect=alive_seq,
662
+ ):
663
+ result = provider.stop("file-pid-agent", pr)
664
+
665
+ assert result is True
666
+
667
+ def test_returns_false_on_sigterm_oserror(self, provider, agent_dir):
668
+ pr = _provision_result(str(agent_dir), pid=12345)
669
+ with patch(
670
+ "skcapstone.providers.local._pid_is_alive", return_value=True
671
+ ):
672
+ with patch("os.kill", side_effect=OSError("eperm")):
673
+ result = provider.stop("perm-agent", pr)
674
+
675
+ assert result is False
676
+
677
+
678
+ # ---------------------------------------------------------------------------
679
+ # LocalProvider.health_check
680
+ # ---------------------------------------------------------------------------
681
+
682
+
683
+ class TestHealthCheck:
684
+ """Tests for LocalProvider.health_check()."""
685
+
686
+ def test_returns_stopped_when_no_pid_no_state(self, provider, agent_dir):
687
+ pr = _provision_result(str(agent_dir))
688
+ assert provider.health_check("agent", pr) == AgentStatus.STOPPED
689
+
690
+ def test_running_state_file_returns_running(self, provider, agent_dir):
691
+ _write_session_state(agent_dir, {"status": "running", "pid": 9999})
692
+ pr = _provision_result(str(agent_dir), pid=9999)
693
+ with patch("skcapstone.providers.local._pid_is_alive", return_value=True):
694
+ result = provider.health_check("agent", pr)
695
+ assert result == AgentStatus.RUNNING
696
+
697
+ def test_idle_state_file_returns_running(self, provider, agent_dir):
698
+ _write_session_state(agent_dir, {"status": "idle", "pid": 9999})
699
+ pr = _provision_result(str(agent_dir), pid=9999)
700
+ with patch("skcapstone.providers.local._pid_is_alive", return_value=True):
701
+ result = provider.health_check("agent", pr)
702
+ assert result == AgentStatus.RUNNING
703
+
704
+ def test_stopped_state_file_returns_stopped(self, provider, agent_dir):
705
+ _write_session_state(agent_dir, {"status": "stopped"})
706
+ pr = _provision_result(str(agent_dir))
707
+ result = provider.health_check("agent", pr)
708
+ assert result == AgentStatus.STOPPED
709
+
710
+ def test_error_state_file_returns_degraded(self, provider, agent_dir):
711
+ _write_session_state(agent_dir, {"status": "error", "pid": 9999})
712
+ pr = _provision_result(str(agent_dir), pid=9999)
713
+ with patch("skcapstone.providers.local._pid_is_alive", return_value=True):
714
+ result = provider.health_check("agent", pr)
715
+ assert result == AgentStatus.DEGRADED
716
+
717
+ def test_running_state_but_dead_pid_returns_degraded(self, provider, agent_dir):
718
+ _write_session_state(agent_dir, {"status": "running", "pid": 9999})
719
+ pr = _provision_result(str(agent_dir), pid=9999)
720
+ with patch("skcapstone.providers.local._pid_is_alive", return_value=False):
721
+ result = provider.health_check("agent", pr)
722
+ assert result == AgentStatus.DEGRADED
723
+
724
+ def test_no_state_file_alive_pid_returns_running(self, provider, agent_dir):
725
+ pr = _provision_result(str(agent_dir), pid=9999)
726
+ with patch("skcapstone.providers.local._pid_is_alive", return_value=True):
727
+ result = provider.health_check("agent", pr)
728
+ assert result == AgentStatus.RUNNING
729
+
730
+ def test_no_state_file_dead_pid_returns_stopped(self, provider, agent_dir):
731
+ pr = _provision_result(str(agent_dir), pid=9999)
732
+ with patch("skcapstone.providers.local._pid_is_alive", return_value=False):
733
+ result = provider.health_check("agent", pr)
734
+ assert result == AgentStatus.STOPPED
735
+
736
+ def test_reads_pid_from_pid_file(self, provider, agent_dir):
737
+ (agent_dir / _PID_FILE).write_text("12345")
738
+ pr = _provision_result(str(agent_dir))
739
+ with patch("skcapstone.providers.local._pid_is_alive", return_value=True):
740
+ result = provider.health_check("agent", pr)
741
+ assert result == AgentStatus.RUNNING
742
+
743
+
744
+ # ---------------------------------------------------------------------------
745
+ # LocalProvider.destroy
746
+ # ---------------------------------------------------------------------------
747
+
748
+
749
+ class TestDestroy:
750
+ """Tests for LocalProvider.destroy()."""
751
+
752
+ def test_removes_work_dir(self, provider, agent_dir):
753
+ pr = _provision_result(str(agent_dir))
754
+ with patch.object(provider, "stop", return_value=True):
755
+ result = provider.destroy("agent", pr)
756
+ assert result is True
757
+ assert not agent_dir.exists()
758
+
759
+ def test_calls_stop_first(self, provider, agent_dir):
760
+ pr = _provision_result(str(agent_dir))
761
+ with patch.object(provider, "stop", return_value=True) as mock_stop:
762
+ provider.destroy("agent", pr)
763
+ mock_stop.assert_called_once_with("agent", pr)
764
+
765
+ def test_returns_true_even_when_dir_missing(self, provider, tmp_path):
766
+ pr = _provision_result(str(tmp_path / "ghost"))
767
+ with patch.object(provider, "stop", return_value=True):
768
+ result = provider.destroy("ghost", pr)
769
+ assert result is True
770
+
771
+ def test_empty_provision_result_returns_true(self, provider):
772
+ result = provider.destroy("nobody", {})
773
+ assert result is True
774
+
775
+
776
+ # ---------------------------------------------------------------------------
777
+ # _session_state_to_agent_status
778
+ # ---------------------------------------------------------------------------
779
+
780
+
781
+ class TestSessionStateToAgentStatus:
782
+ """Tests for the state → AgentStatus mapper."""
783
+
784
+ def test_running_with_live_pid(self):
785
+ with patch("skcapstone.providers.local._pid_is_alive", return_value=True):
786
+ result = _session_state_to_agent_status(
787
+ {"status": "running", "pid": 1234}, 1234
788
+ )
789
+ assert result == AgentStatus.RUNNING
790
+
791
+ def test_running_with_dead_pid_returns_degraded(self):
792
+ with patch("skcapstone.providers.local._pid_is_alive", return_value=False):
793
+ result = _session_state_to_agent_status(
794
+ {"status": "running", "pid": 1234}, 1234
795
+ )
796
+ assert result == AgentStatus.DEGRADED
797
+
798
+ def test_idle_returns_running(self):
799
+ with patch("skcapstone.providers.local._pid_is_alive", return_value=True):
800
+ result = _session_state_to_agent_status({"status": "idle", "pid": 1}, 1)
801
+ assert result == AgentStatus.RUNNING
802
+
803
+ def test_stopped_returns_stopped(self):
804
+ result = _session_state_to_agent_status({"status": "stopped"}, None)
805
+ assert result == AgentStatus.STOPPED
806
+
807
+ def test_error_returns_degraded(self):
808
+ with patch("skcapstone.providers.local._pid_is_alive", return_value=True):
809
+ result = _session_state_to_agent_status({"status": "error", "pid": 1}, 1)
810
+ assert result == AgentStatus.DEGRADED
811
+
812
+ def test_unknown_status_returns_degraded(self):
813
+ result = _session_state_to_agent_status({"status": "banana"}, None)
814
+ assert result == AgentStatus.DEGRADED
815
+
816
+
817
+ # ---------------------------------------------------------------------------
818
+ # _stub_script
819
+ # ---------------------------------------------------------------------------
820
+
821
+
822
+ class TestStubScript:
823
+ """Tests for the stub process script generator."""
824
+
825
+ def test_returns_string(self, tmp_path):
826
+ script = _stub_script("my-agent", str(tmp_path / "state.json"))
827
+ assert isinstance(script, str)
828
+ assert len(script) > 0
829
+
830
+ def test_script_contains_agent_name(self, tmp_path):
831
+ script = _stub_script("my-agent", str(tmp_path / "state.json"))
832
+ assert "my-agent" in script
833
+
834
+ def test_script_contains_state_file_path(self, tmp_path):
835
+ state_path = str(tmp_path / "state.json")
836
+ script = _stub_script("agent", state_path)
837
+ assert state_path in script
838
+
839
+ def test_script_is_valid_python(self, tmp_path):
840
+ script = _stub_script("test-agent", str(tmp_path / "state.json"))
841
+ compile(script, "<stub>", "exec")
842
+
843
+
844
+ # ---------------------------------------------------------------------------
845
+ # Integration: full provision → configure → start → health_check → stop
846
+ # ---------------------------------------------------------------------------
847
+
848
+
849
+ class TestFullLifecycle:
850
+ """End-to-end lifecycle tests with mocked subprocess."""
851
+
852
+ def test_lifecycle_with_crush(self, provider, tmp_path):
853
+ mock_proc = MagicMock()
854
+ mock_proc.pid = 88888
855
+
856
+ spec = _make_spec(
857
+ role="coder",
858
+ model="code",
859
+ soul_blueprint="lumina",
860
+ skills=["code-review"],
861
+ env={"EXTRA": "val"},
862
+ )
863
+
864
+ with patch("subprocess.Popen", return_value=mock_proc):
865
+ with patch(
866
+ "skcapstone.providers.local._find_crush_binary",
867
+ return_value="/usr/bin/crush",
868
+ ):
869
+ pr = provider.provision("lifecycle-agent", spec, "team-lc")
870
+ provider.configure("lifecycle-agent", spec, pr)
871
+ started = provider.start("lifecycle-agent", pr)
872
+
873
+ assert started is True
874
+ assert pr["pid"] == 88888
875
+
876
+ # Health check: session state says running, pid alive
877
+ with patch("skcapstone.providers.local._pid_is_alive", return_value=True):
878
+ status = provider.health_check("lifecycle-agent", pr)
879
+ assert status == AgentStatus.RUNNING
880
+
881
+ # Stop
882
+ alive_seq = iter([True] + [False] * 60)
883
+ with patch("os.kill"):
884
+ with patch(
885
+ "skcapstone.providers.local._pid_is_alive",
886
+ side_effect=alive_seq,
887
+ ):
888
+ stopped = provider.stop("lifecycle-agent", pr)
889
+
890
+ assert stopped is True
891
+
892
+ # Post-stop health check
893
+ status_after = provider.health_check("lifecycle-agent", pr)
894
+ assert status_after == AgentStatus.STOPPED
895
+
896
+ def test_lifecycle_with_stub_fallback(self, provider):
897
+ mock_proc = MagicMock()
898
+ mock_proc.pid = 77777
899
+ spec = _make_spec()
900
+
901
+ with patch("subprocess.Popen", return_value=mock_proc):
902
+ with patch(
903
+ "skcapstone.providers.local._find_crush_binary",
904
+ return_value=None,
905
+ ):
906
+ pr = provider.provision("stub-agent", spec, "team-stub")
907
+ provider.configure("stub-agent", spec, pr)
908
+ started = provider.start("stub-agent", pr)
909
+
910
+ assert started is True
911
+
912
+ with patch("skcapstone.providers.local._pid_is_alive", return_value=True):
913
+ status = provider.health_check("stub-agent", pr)
914
+ assert status == AgentStatus.RUNNING
915
+
916
+
917
+ # ---------------------------------------------------------------------------
918
+ # _is_claude_binary
919
+ # ---------------------------------------------------------------------------
920
+
921
+
922
+ class TestIsClaudeBinary:
923
+ """Tests for _is_claude_binary helper."""
924
+
925
+ def test_returns_true_for_claude(self):
926
+ assert _is_claude_binary("/bin/claude") is True
927
+
928
+ def test_returns_true_for_claude_in_usr(self):
929
+ assert _is_claude_binary("/usr/local/bin/claude") is True
930
+
931
+ def test_returns_false_for_crush(self):
932
+ assert _is_claude_binary("/usr/bin/crush") is False
933
+
934
+
935
+ # ---------------------------------------------------------------------------
936
+ # LocalProvider.start — claude binary path
937
+ # ---------------------------------------------------------------------------
938
+
939
+
940
+ class TestStartWithClaudeBinary:
941
+ """Tests for LocalProvider.start() when claude binary is found."""
942
+
943
+ @pytest.fixture()
944
+ def _patched_popen(self):
945
+ """Patch subprocess.Popen to return a fake process."""
946
+ mock_proc = MagicMock()
947
+ mock_proc.pid = 33000
948
+ with patch("subprocess.Popen", return_value=mock_proc) as mock_popen:
949
+ yield mock_popen, mock_proc
950
+
951
+ def test_spawns_claude_subprocess(self, provider, tmp_path, _patched_popen):
952
+ mock_popen, _ = _patched_popen
953
+ spec = _make_spec()
954
+ pr = provider.provision("agent-claude", spec, "team-c")
955
+ provider.configure("agent-claude", spec, pr)
956
+
957
+ with patch(
958
+ "skcapstone.providers.local._find_crush_binary",
959
+ return_value="/bin/claude",
960
+ ):
961
+ result = provider.start("agent-claude", pr)
962
+
963
+ assert result is True
964
+ mock_popen.assert_called_once()
965
+ cmd = mock_popen.call_args[0][0]
966
+ assert cmd[0] == "/bin/claude"
967
+ assert "-p" in cmd
968
+
969
+ def test_claude_cmd_includes_model(self, provider, tmp_path, _patched_popen):
970
+ mock_popen, _ = _patched_popen
971
+ spec = _make_spec(model_name="claude-opus-4-6")
972
+ pr = provider.provision("agent-cm", spec, "team-c")
973
+ provider.configure("agent-cm", spec, pr)
974
+
975
+ with patch(
976
+ "skcapstone.providers.local._find_crush_binary",
977
+ return_value="/bin/claude",
978
+ ):
979
+ provider.start("agent-cm", pr)
980
+
981
+ cmd = mock_popen.call_args[0][0]
982
+ model_idx = cmd.index("--model")
983
+ assert cmd[model_idx + 1] == "claude-opus-4-6"
984
+
985
+ def test_claude_cmd_includes_system_prompt(self, provider, tmp_path, _patched_popen):
986
+ mock_popen, _ = _patched_popen
987
+ spec = _make_spec(soul_blueprint="lumina")
988
+ pr = provider.provision("agent-sp", spec, "team-c")
989
+ provider.configure("agent-sp", spec, pr)
990
+
991
+ with patch(
992
+ "skcapstone.providers.local._find_crush_binary",
993
+ return_value="/bin/claude",
994
+ ):
995
+ provider.start("agent-sp", pr)
996
+
997
+ cmd = mock_popen.call_args[0][0]
998
+ assert "--system-prompt" in cmd
999
+
1000
+ def test_claude_cmd_includes_output_format(self, provider, tmp_path, _patched_popen):
1001
+ mock_popen, _ = _patched_popen
1002
+ spec = _make_spec()
1003
+ pr = provider.provision("agent-of", spec, "team-c")
1004
+ provider.configure("agent-of", spec, pr)
1005
+
1006
+ with patch(
1007
+ "skcapstone.providers.local._find_crush_binary",
1008
+ return_value="/bin/claude",
1009
+ ):
1010
+ provider.start("agent-of", pr)
1011
+
1012
+ cmd = mock_popen.call_args[0][0]
1013
+ of_idx = cmd.index("--output-format")
1014
+ assert cmd[of_idx + 1] == "stream-json"
1015
+
1016
+ def test_claude_cmd_includes_session_id(self, provider, tmp_path, _patched_popen):
1017
+ mock_popen, _ = _patched_popen
1018
+ spec = _make_spec()
1019
+ pr = provider.provision("agent-si", spec, "team-c")
1020
+ provider.configure("agent-si", spec, pr)
1021
+
1022
+ with patch(
1023
+ "skcapstone.providers.local._find_crush_binary",
1024
+ return_value="/bin/claude",
1025
+ ):
1026
+ provider.start("agent-si", pr)
1027
+
1028
+ cmd = mock_popen.call_args[0][0]
1029
+ assert "--session-id" in cmd
1030
+
1031
+ def test_claude_cmd_includes_dangerously_skip_permissions(
1032
+ self, provider, tmp_path, _patched_popen
1033
+ ):
1034
+ mock_popen, _ = _patched_popen
1035
+ spec = _make_spec()
1036
+ pr = provider.provision("agent-dsp", spec, "team-c")
1037
+ provider.configure("agent-dsp", spec, pr)
1038
+
1039
+ with patch(
1040
+ "skcapstone.providers.local._find_crush_binary",
1041
+ return_value="/bin/claude",
1042
+ ):
1043
+ provider.start("agent-dsp", pr)
1044
+
1045
+ cmd = mock_popen.call_args[0][0]
1046
+ assert "--dangerously-skip-permissions" in cmd
1047
+
1048
+ def test_sets_pid_in_provision_result(self, provider, tmp_path, _patched_popen):
1049
+ _, mock_proc = _patched_popen
1050
+ mock_proc.pid = 44444
1051
+ spec = _make_spec()
1052
+ pr = provider.provision("agent-cpid", spec, "team-c")
1053
+ provider.configure("agent-cpid", spec, pr)
1054
+
1055
+ with patch(
1056
+ "skcapstone.providers.local._find_crush_binary",
1057
+ return_value="/bin/claude",
1058
+ ):
1059
+ provider.start("agent-cpid", pr)
1060
+
1061
+ assert pr["pid"] == 44444
1062
+
1063
+ def test_writes_session_state_with_claude_backend(
1064
+ self, provider, tmp_path, _patched_popen
1065
+ ):
1066
+ _, mock_proc = _patched_popen
1067
+ mock_proc.pid = 55000
1068
+ spec = _make_spec()
1069
+ pr = provider.provision("agent-csb", spec, "team-c")
1070
+ provider.configure("agent-csb", spec, pr)
1071
+
1072
+ with patch(
1073
+ "skcapstone.providers.local._find_crush_binary",
1074
+ return_value="/bin/claude",
1075
+ ):
1076
+ provider.start("agent-csb", pr)
1077
+
1078
+ state = json.loads(
1079
+ (Path(pr["work_dir"]) / _SESSION_STATE_FILE).read_text()
1080
+ )
1081
+ assert state["status"] == _STATE_RUNNING
1082
+ assert state["backend"] == "claude"
1083
+ assert state["pid"] == 55000
1084
+
1085
+ def test_returns_false_on_popen_error(self, provider, tmp_path):
1086
+ spec = _make_spec()
1087
+ pr = provider.provision("agent-cerr", spec, "team-c")
1088
+ provider.configure("agent-cerr", spec, pr)
1089
+
1090
+ with patch(
1091
+ "skcapstone.providers.local._find_crush_binary",
1092
+ return_value="/bin/claude",
1093
+ ):
1094
+ with patch(
1095
+ "subprocess.Popen", side_effect=OSError("not found")
1096
+ ):
1097
+ result = provider.start("agent-cerr", pr)
1098
+
1099
+ assert result is False
1100
+
1101
+
1102
+ # ---------------------------------------------------------------------------
1103
+ # Claude session environment
1104
+ # ---------------------------------------------------------------------------
1105
+
1106
+
1107
+ class TestClaudeSessionEnv:
1108
+ """Tests for claude session environment variable passing."""
1109
+
1110
+ @pytest.fixture()
1111
+ def _patched_popen(self):
1112
+ mock_proc = MagicMock()
1113
+ mock_proc.pid = 66000
1114
+ with patch("subprocess.Popen", return_value=mock_proc) as mock_popen:
1115
+ yield mock_popen, mock_proc
1116
+
1117
+ def test_passes_agent_name_in_env(self, provider, _patched_popen):
1118
+ mock_popen, _ = _patched_popen
1119
+ spec = _make_spec()
1120
+ pr = provider.provision("env-agent", spec, "team-env")
1121
+ provider.configure("env-agent", spec, pr)
1122
+
1123
+ with patch(
1124
+ "skcapstone.providers.local._find_crush_binary",
1125
+ return_value="/bin/claude",
1126
+ ):
1127
+ provider.start("env-agent", pr)
1128
+
1129
+ env = mock_popen.call_args[1]["env"]
1130
+ assert env["AGENT_NAME"] == "env-agent"
1131
+
1132
+ def test_passes_model_in_env(self, provider, _patched_popen):
1133
+ mock_popen, _ = _patched_popen
1134
+ spec = _make_spec(model="reason")
1135
+ pr = provider.provision("env-model", spec, "team-env")
1136
+ provider.configure("env-model", spec, pr)
1137
+
1138
+ with patch(
1139
+ "skcapstone.providers.local._find_crush_binary",
1140
+ return_value="/bin/claude",
1141
+ ):
1142
+ provider.start("env-model", pr)
1143
+
1144
+ env = mock_popen.call_args[1]["env"]
1145
+ assert env["AGENT_MODEL_TIER"] == "reason"
1146
+
1147
+ def test_passes_soul_in_env(self, provider, _patched_popen):
1148
+ mock_popen, _ = _patched_popen
1149
+ spec = _make_spec(soul_blueprint="lumina")
1150
+ pr = provider.provision("env-soul", spec, "team-env")
1151
+ provider.configure("env-soul", spec, pr)
1152
+
1153
+ with patch(
1154
+ "skcapstone.providers.local._find_crush_binary",
1155
+ return_value="/bin/claude",
1156
+ ):
1157
+ provider.start("env-soul", pr)
1158
+
1159
+ env = mock_popen.call_args[1]["env"]
1160
+ assert "SOUL_BLUEPRINT" in env
1161
+ assert "lumina" in env["SOUL_BLUEPRINT"].lower() or env["SOUL_BLUEPRINT"] == "lumina"
1162
+
1163
+ def test_passes_skills_as_json_in_env(self, provider, _patched_popen):
1164
+ mock_popen, _ = _patched_popen
1165
+ spec = _make_spec(skills=["code-review", "docs"])
1166
+ pr = provider.provision("env-skills", spec, "team-env")
1167
+ provider.configure("env-skills", spec, pr)
1168
+
1169
+ with patch(
1170
+ "skcapstone.providers.local._find_crush_binary",
1171
+ return_value="/bin/claude",
1172
+ ):
1173
+ provider.start("env-skills", pr)
1174
+
1175
+ env = mock_popen.call_args[1]["env"]
1176
+ parsed = json.loads(env["AGENT_SKILLS"])
1177
+ assert "code-review" in parsed
1178
+ assert "docs" in parsed
1179
+
1180
+
1181
+ # ---------------------------------------------------------------------------
1182
+ # Fallback chain priority: crush → claude → stub
1183
+ # ---------------------------------------------------------------------------
1184
+
1185
+
1186
+ class TestStartFallbackChain:
1187
+ """Tests for the three-tier fallback: crush → claude → stub."""
1188
+
1189
+ @pytest.fixture()
1190
+ def _patched_popen(self):
1191
+ mock_proc = MagicMock()
1192
+ mock_proc.pid = 99000
1193
+ with patch("subprocess.Popen", return_value=mock_proc) as mock_popen:
1194
+ yield mock_popen, mock_proc
1195
+
1196
+ def test_crush_binary_uses_crush_session(self, provider, _patched_popen):
1197
+ mock_popen, _ = _patched_popen
1198
+ spec = _make_spec()
1199
+ pr = provider.provision("fb-crush", spec, "team-fb")
1200
+ provider.configure("fb-crush", spec, pr)
1201
+
1202
+ with patch(
1203
+ "skcapstone.providers.local._find_crush_binary",
1204
+ return_value="/usr/bin/crush",
1205
+ ):
1206
+ provider.start("fb-crush", pr)
1207
+
1208
+ cmd = mock_popen.call_args[0][0]
1209
+ assert cmd[0] == "/usr/bin/crush"
1210
+ assert "run" in cmd
1211
+ # Should NOT have -p flag (that's the claude path)
1212
+ assert "-p" not in cmd
1213
+
1214
+ def test_claude_binary_uses_claude_session(self, provider, _patched_popen):
1215
+ mock_popen, _ = _patched_popen
1216
+ spec = _make_spec()
1217
+ pr = provider.provision("fb-claude", spec, "team-fb")
1218
+ provider.configure("fb-claude", spec, pr)
1219
+
1220
+ with patch(
1221
+ "skcapstone.providers.local._find_crush_binary",
1222
+ return_value="/bin/claude",
1223
+ ):
1224
+ provider.start("fb-claude", pr)
1225
+
1226
+ cmd = mock_popen.call_args[0][0]
1227
+ assert cmd[0] == "/bin/claude"
1228
+ assert "-p" in cmd
1229
+
1230
+ def test_no_binary_uses_stub(self, provider, _patched_popen):
1231
+ mock_popen, _ = _patched_popen
1232
+ spec = _make_spec()
1233
+ pr = provider.provision("fb-stub", spec, "team-fb")
1234
+ provider.configure("fb-stub", spec, pr)
1235
+
1236
+ with patch(
1237
+ "skcapstone.providers.local._find_crush_binary",
1238
+ return_value=None,
1239
+ ):
1240
+ provider.start("fb-stub", pr)
1241
+
1242
+ cmd = mock_popen.call_args[0][0]
1243
+ assert cmd[0] == os.sys.executable
1244
+ assert cmd[1] == "-c"
1245
+
1246
+ def test_lifecycle_with_claude(self, provider):
1247
+ """End-to-end lifecycle with claude binary."""
1248
+ mock_proc = MagicMock()
1249
+ mock_proc.pid = 98765
1250
+
1251
+ spec = _make_spec(
1252
+ role="coder",
1253
+ model="code",
1254
+ soul_blueprint="lumina",
1255
+ skills=["code-review"],
1256
+ )
1257
+
1258
+ with patch("subprocess.Popen", return_value=mock_proc):
1259
+ with patch(
1260
+ "skcapstone.providers.local._find_crush_binary",
1261
+ return_value="/bin/claude",
1262
+ ):
1263
+ pr = provider.provision("claude-lc", spec, "team-lc")
1264
+ provider.configure("claude-lc", spec, pr)
1265
+ started = provider.start("claude-lc", pr)
1266
+
1267
+ assert started is True
1268
+ assert pr["pid"] == 98765
1269
+
1270
+ # Health check
1271
+ with patch("skcapstone.providers.local._pid_is_alive", return_value=True):
1272
+ status = provider.health_check("claude-lc", pr)
1273
+ assert status == AgentStatus.RUNNING
1274
+
1275
+ # Stop
1276
+ alive_seq = iter([True] + [False] * 60)
1277
+ with patch("os.kill"):
1278
+ with patch(
1279
+ "skcapstone.providers.local._pid_is_alive",
1280
+ side_effect=alive_seq,
1281
+ ):
1282
+ stopped = provider.stop("claude-lc", pr)
1283
+ assert stopped is True