evo-cli 0.28.0__tar.gz → 0.29.0__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 (154) hide show
  1. {evo_cli-0.28.0 → evo_cli-0.29.0}/PKG-INFO +24 -2
  2. {evo_cli-0.28.0 → evo_cli-0.29.0}/README.md +23 -1
  3. evo_cli-0.29.0/evo_cli/VERSION +1 -0
  4. {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/commands/harness/__init__.py +5 -1
  5. evo_cli-0.29.0/evo_cli/commands/harness/_hub.py +337 -0
  6. evo_cli-0.29.0/evo_cli/commands/harness/_mirror.py +119 -0
  7. {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/commands/harness/_model.py +29 -0
  8. evo_cli-0.29.0/evo_cli/commands/harness/_mutate.py +181 -0
  9. {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/commands/harness/_server.py +1 -1
  10. {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/commands/harness/check.py +22 -1
  11. {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/commands/harness/edit.py +32 -16
  12. {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/commands/openvpn.py +3 -1
  13. {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/openvpn.py +53 -5
  14. {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli.egg-info/PKG-INFO +24 -2
  15. {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli.egg-info/SOURCES.txt +7 -1
  16. evo_cli-0.29.0/tests/fake_evo_agents.py +252 -0
  17. evo_cli-0.29.0/tests/fixtures/hub/contract-0.2.0.json +287 -0
  18. evo_cli-0.29.0/tests/fixtures/hub/mirror-demo.yaml +53 -0
  19. evo_cli-0.29.0/tests/test_harness_hub.py +727 -0
  20. {evo_cli-0.28.0 → evo_cli-0.29.0}/tests/test_openvpn.py +44 -3
  21. evo_cli-0.28.0/evo_cli/VERSION +0 -1
  22. evo_cli-0.28.0/evo_cli/commands/harness/_mutate.py +0 -118
  23. {evo_cli-0.28.0 → evo_cli-0.29.0}/Containerfile +0 -0
  24. {evo_cli-0.28.0 → evo_cli-0.29.0}/HISTORY.md +0 -0
  25. {evo_cli-0.28.0 → evo_cli-0.29.0}/LICENSE +0 -0
  26. {evo_cli-0.28.0 → evo_cli-0.29.0}/MANIFEST.in +0 -0
  27. {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/__init__.py +0 -0
  28. {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/__main__.py +0 -0
  29. {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/base.py +0 -0
  30. {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/cli.py +0 -0
  31. {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/commands/__init__.py +0 -0
  32. {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/commands/agent_toy.py +0 -0
  33. {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/commands/claude_code.py +0 -0
  34. {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/commands/cloudflare.py +0 -0
  35. {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/commands/cred.py +0 -0
  36. {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/commands/download.py +0 -0
  37. {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/commands/fix_claude.py +0 -0
  38. {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/commands/gdrive.py +0 -0
  39. {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/commands/gh.py +0 -0
  40. {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/commands/harness/_dag.py +0 -0
  41. {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/commands/harness/_git.py +0 -0
  42. {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/commands/harness/_paths.py +0 -0
  43. {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/commands/harness/_render.py +0 -0
  44. {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/commands/harness/clone.py +0 -0
  45. {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/commands/harness/pull.py +0 -0
  46. {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/commands/harness/serve.py +0 -0
  47. {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/commands/harness/show.py +0 -0
  48. {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/commands/harness/views.py +0 -0
  49. {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/commands/harness/web/assets/index-Cs7hXRPJ.css +0 -0
  50. {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/commands/harness/web/assets/index-CuNOlaH7.js +0 -0
  51. {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/commands/harness/web/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
  52. {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/commands/harness/web/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
  53. {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/commands/harness/web/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
  54. {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/commands/harness/web/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
  55. {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/commands/harness/web/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
  56. {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/commands/harness/web/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
  57. {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/commands/harness/web/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
  58. {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/commands/harness/web/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
  59. {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/commands/harness/web/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
  60. {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/commands/harness/web/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
  61. {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/commands/harness/web/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
  62. {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/commands/harness/web/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
  63. {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/commands/harness/web/index.html +0 -0
  64. {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/commands/hwid.py +0 -0
  65. {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/commands/hwid_reset.py +0 -0
  66. {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/commands/image.py +0 -0
  67. {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/commands/localproxy.py +0 -0
  68. {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/commands/mcp.py +0 -0
  69. {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/commands/miniconda.py +0 -0
  70. {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/commands/netcheck.py +0 -0
  71. {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/commands/ngrok.py +0 -0
  72. {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/commands/opencode.py +0 -0
  73. {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/commands/plantuml.py +0 -0
  74. {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/commands/route/__init__.py +0 -0
  75. {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/commands/route/_dns.py +0 -0
  76. {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/commands/route/_dpi.py +0 -0
  77. {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/commands/route/_hosts.py +0 -0
  78. {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/commands/route/_probe.py +0 -0
  79. {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/commands/route/check.py +0 -0
  80. {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/commands/route/fix.py +0 -0
  81. {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/commands/route/probe.py +0 -0
  82. {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/commands/route/status.py +0 -0
  83. {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/commands/serp.py +0 -0
  84. {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/commands/site2s.py +0 -0
  85. {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/commands/ssh.py +0 -0
  86. {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/commands/storage.py +0 -0
  87. {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/commands/sysmon.py +0 -0
  88. {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/commands/tts.py +0 -0
  89. {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/commands/update.py +0 -0
  90. {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/commands/wifi.py +0 -0
  91. {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/console.py +0 -0
  92. {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/credentials/__init__.py +0 -0
  93. {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/credentials/doctor.py +0 -0
  94. {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/credentials/google_oauth.py +0 -0
  95. {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/credentials/migrate.py +0 -0
  96. {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/credentials/oauth_flow.py +0 -0
  97. {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/credentials/registry.py +0 -0
  98. {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/credentials/store.py +0 -0
  99. {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/credentials/sync.py +0 -0
  100. {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/imaging/__init__.py +0 -0
  101. {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/imaging/align.py +0 -0
  102. {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/imaging/core.py +0 -0
  103. {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/imaging/creds.py +0 -0
  104. {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/imaging/errors.py +0 -0
  105. {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/imaging/gemini.py +0 -0
  106. {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/imaging/ncnn.py +0 -0
  107. {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/mcp_registry.py +0 -0
  108. {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/serp/__init__.py +0 -0
  109. {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/serp/client.py +0 -0
  110. {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/serp/creds.py +0 -0
  111. {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/serp/errors.py +0 -0
  112. {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/serp/install.py +0 -0
  113. {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/serp/render.py +0 -0
  114. {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/tts/__init__.py +0 -0
  115. {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/tts/chunking.py +0 -0
  116. {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/tts/core.py +0 -0
  117. {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/tts/creds.py +0 -0
  118. {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/tts/errors.py +0 -0
  119. {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/tts/gemini.py +0 -0
  120. {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/tts/http.py +0 -0
  121. {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/tts/openai.py +0 -0
  122. {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/tts/player.py +0 -0
  123. {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/tts/vbee.py +0 -0
  124. {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli.egg-info/dependency_links.txt +0 -0
  125. {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli.egg-info/entry_points.txt +0 -0
  126. {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli.egg-info/requires.txt +0 -0
  127. {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli.egg-info/top_level.txt +0 -0
  128. {evo_cli-0.28.0 → evo_cli-0.29.0}/pyproject.toml +0 -0
  129. {evo_cli-0.28.0 → evo_cli-0.29.0}/setup.cfg +0 -0
  130. {evo_cli-0.28.0 → evo_cli-0.29.0}/tests/__init__.py +0 -0
  131. {evo_cli-0.28.0 → evo_cli-0.29.0}/tests/test_agent_toy.py +0 -0
  132. {evo_cli-0.28.0 → evo_cli-0.29.0}/tests/test_claude_code.py +0 -0
  133. {evo_cli-0.28.0 → evo_cli-0.29.0}/tests/test_cli.py +0 -0
  134. {evo_cli-0.28.0 → evo_cli-0.29.0}/tests/test_console.py +0 -0
  135. {evo_cli-0.28.0 → evo_cli-0.29.0}/tests/test_cred.py +0 -0
  136. {evo_cli-0.28.0 → evo_cli-0.29.0}/tests/test_download.py +0 -0
  137. {evo_cli-0.28.0 → evo_cli-0.29.0}/tests/test_fix_claude.py +0 -0
  138. {evo_cli-0.28.0 → evo_cli-0.29.0}/tests/test_gdrive.py +0 -0
  139. {evo_cli-0.28.0 → evo_cli-0.29.0}/tests/test_gh.py +0 -0
  140. {evo_cli-0.28.0 → evo_cli-0.29.0}/tests/test_harness.py +0 -0
  141. {evo_cli-0.28.0 → evo_cli-0.29.0}/tests/test_harness_clone.py +0 -0
  142. {evo_cli-0.28.0 → evo_cli-0.29.0}/tests/test_harness_dag.py +0 -0
  143. {evo_cli-0.28.0 → evo_cli-0.29.0}/tests/test_image.py +0 -0
  144. {evo_cli-0.28.0 → evo_cli-0.29.0}/tests/test_mcp.py +0 -0
  145. {evo_cli-0.28.0 → evo_cli-0.29.0}/tests/test_ngrok.py +0 -0
  146. {evo_cli-0.28.0 → evo_cli-0.29.0}/tests/test_opencode.py +0 -0
  147. {evo_cli-0.28.0 → evo_cli-0.29.0}/tests/test_plantuml.py +0 -0
  148. {evo_cli-0.28.0 → evo_cli-0.29.0}/tests/test_route.py +0 -0
  149. {evo_cli-0.28.0 → evo_cli-0.29.0}/tests/test_serp.py +0 -0
  150. {evo_cli-0.28.0 → evo_cli-0.29.0}/tests/test_storage.py +0 -0
  151. {evo_cli-0.28.0 → evo_cli-0.29.0}/tests/test_sysmon.py +0 -0
  152. {evo_cli-0.28.0 → evo_cli-0.29.0}/tests/test_tts.py +0 -0
  153. {evo_cli-0.28.0 → evo_cli-0.29.0}/tests/test_update.py +0 -0
  154. {evo_cli-0.28.0 → evo_cli-0.29.0}/tests/test_wifi.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: evo_cli
3
- Version: 0.28.0
3
+ Version: 0.29.0
4
4
  Summary: Evolution CLI - a developer toolbox for setting up dev machines
5
5
  Author: maycuatroi
6
6
  Project-URL: Homepage, https://github.com/maycuatroi/evo-cli
@@ -438,12 +438,34 @@ it with a YAML dumper would destroy the formatting. Writes go through the CLI, w
438
438
  line it needs and refuses to save if the reparsed file is not exactly the intended change:
439
439
 
440
440
  ```bash
441
- evo harness step <plan> 3 done --note "..." # keyed by the step's `id` (or `order`)
441
+ evo harness step <plan> 3 done --evidence "repo@sha: tests pass" --note "..." # keyed by the step's `id` (or `order`)
442
442
  evo harness debt <plan> 0 fixed
443
443
  evo harness question <plan> 1 answered
444
444
  evo harness repo <plan> 2 merged
445
445
  ```
446
446
 
447
+ `--note` and `--evidence` append their text on a line of its own; an existing note or evidence is
448
+ kept, and the same text is not added twice.
449
+
450
+ ### Plans on the evo-agents hub
451
+
452
+ When `harness.yaml` has `hub: {project: P}`, the plans live on the evo-agents hub and every
453
+ `plans/<area>/<plan>.yaml` is a read-only copy the hub wrote. `step`, `debt`, `question`, `repo` and
454
+ the dashboard's complete button then never edit those files: they run `evo-agents hub plan patch`
455
+ (or `complete`) with `--if-revision`, and `evo-agents hub plan export` writes the copies back. When
456
+ someone else changed the plan in between, the write is retried on the new revision: appended text
457
+ lands after theirs, and a status they changed to something else stops the command instead of being
458
+ overwritten. A copy edited by hand stops every write before the hub hears of it, and
459
+ `evo harness check` reports it (digest mismatch), with the same fix `evo-agents harness validate`
460
+ prints. These commands need evo-agents 0.2.0 or later, signed in:
461
+
462
+ ```bash
463
+ uv tool install 'evo-ak[graphify]>=0.2.0'
464
+ evo-agents hub login --url https://agents.omelet.tech
465
+ ```
466
+
467
+ Without it, or when the hub cannot be reached, the command fails with that advice and no file changes.
468
+
447
469
  The server is stdlib `http.server`, so `serve` needs no dependency beyond what `pip install evo_cli`
448
470
  already brings.
449
471
 
@@ -405,12 +405,34 @@ it with a YAML dumper would destroy the formatting. Writes go through the CLI, w
405
405
  line it needs and refuses to save if the reparsed file is not exactly the intended change:
406
406
 
407
407
  ```bash
408
- evo harness step <plan> 3 done --note "..." # keyed by the step's `id` (or `order`)
408
+ evo harness step <plan> 3 done --evidence "repo@sha: tests pass" --note "..." # keyed by the step's `id` (or `order`)
409
409
  evo harness debt <plan> 0 fixed
410
410
  evo harness question <plan> 1 answered
411
411
  evo harness repo <plan> 2 merged
412
412
  ```
413
413
 
414
+ `--note` and `--evidence` append their text on a line of its own; an existing note or evidence is
415
+ kept, and the same text is not added twice.
416
+
417
+ ### Plans on the evo-agents hub
418
+
419
+ When `harness.yaml` has `hub: {project: P}`, the plans live on the evo-agents hub and every
420
+ `plans/<area>/<plan>.yaml` is a read-only copy the hub wrote. `step`, `debt`, `question`, `repo` and
421
+ the dashboard's complete button then never edit those files: they run `evo-agents hub plan patch`
422
+ (or `complete`) with `--if-revision`, and `evo-agents hub plan export` writes the copies back. When
423
+ someone else changed the plan in between, the write is retried on the new revision: appended text
424
+ lands after theirs, and a status they changed to something else stops the command instead of being
425
+ overwritten. A copy edited by hand stops every write before the hub hears of it, and
426
+ `evo harness check` reports it (digest mismatch), with the same fix `evo-agents harness validate`
427
+ prints. These commands need evo-agents 0.2.0 or later, signed in:
428
+
429
+ ```bash
430
+ uv tool install 'evo-ak[graphify]>=0.2.0'
431
+ evo-agents hub login --url https://agents.omelet.tech
432
+ ```
433
+
434
+ Without it, or when the hub cannot be reached, the command fails with that advice and no file changes.
435
+
414
436
  The server is stdlib `http.server`, so `serve` needs no dependency beyond what `pip install evo_cli`
415
437
  already brings.
416
438
 
@@ -0,0 +1 @@
1
+ 0.29.0
@@ -21,9 +21,13 @@ def harness_group():
21
21
  evo harness show <plan> read one plan
22
22
  evo harness graph <plan> print a DAG as an adjacency list
23
23
  evo harness check <plan> check what the plan claims against real git
24
- evo harness step <plan> 3 done mark a step
24
+ evo harness step <plan> 3 done mark a step (--evidence and --note append)
25
25
  evo harness clone clone every declared repo into its declared path
26
26
  evo harness pull fast-forward every repo
27
+
28
+ When harness.yaml has hub.project, plans live on the evo-agents hub: step, debt, question,
29
+ repo and the dashboard write through `evo-agents hub plan`, and check reports copies whose
30
+ digest no longer matches.
27
31
  """
28
32
 
29
33
 
@@ -0,0 +1,337 @@
1
+ """Plan writes in a harness whose plans live on the evo-agents hub (harness.yaml `hub.project`).
2
+
3
+ Such a harness keeps read-only copies of its plans in git (`_mirror`). `evo harness step`, `debt`,
4
+ `question`, `repo` and the dashboard's complete button never edit those files: they run
5
+ `evo-agents hub plan patch` or `evo-agents hub plan complete` (seam hub-cli-v1, whose contract is
6
+ `evo-agents hub contract print`), then `evo-agents hub plan export` to write the copies back.
7
+
8
+ Every write names the revision it was computed from (`--if-revision`). When someone else wrote the
9
+ plan in between, the hub refuses it with a 409 and changes nothing; this module reads the plan again,
10
+ recomputes the one item on the new revision and sends it again, at most ATTEMPTS times, after a random pause
11
+ that grows with each retry so that writers racing for one plan spread out instead of colliding again:
12
+
13
+ - text that `--note` and `--evidence` append is appended to the other writer's text, never in place of it;
14
+ - a key set outright (status and its date) that the other writer changed to a different value stops the
15
+ command with an error instead of overwriting it, the way `evo-agents hub plan patch` treats it;
16
+ - an item other than a step must still be the same item at its index, or nothing is written.
17
+
18
+ Before asking the hub anything, a write checks the copies in the harness: the export after it rewrites
19
+ them, so a copy someone edited by hand stops the command first. When evo-agents is missing, not signed
20
+ in, or the hub cannot be reached, the command fails with what to do and the YAML stays as it was.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import json
26
+ import random
27
+ import shutil
28
+ import subprocess
29
+ import time
30
+ from dataclasses import dataclass
31
+ from pathlib import Path
32
+ from typing import Any
33
+
34
+ import rich_click as click
35
+
36
+ from evo_cli.commands.harness._dag import step_key
37
+ from evo_cli.commands.harness._mirror import EDITED, FOREIGN, INTACT, classify, copies, normalized
38
+ from evo_cli.commands.harness._model import AREAS, Plan, harness_root, join_text
39
+
40
+ EXECUTABLE = "evo-agents"
41
+ INSTALL = "uv tool install 'evo-ak[graphify]>=0.2.0'"
42
+ LOGIN = "evo-agents hub login --url https://agents.omelet.tech"
43
+ ATTEMPTS = 12 # writes sent for one item before a run of revision conflicts is reported
44
+ PAUSE = 0.25 # seconds; the pause before a retry is random, up to PAUSE doubled per retry and at most MAX_PAUSE
45
+ MAX_PAUSE = 3.0
46
+ TIMEOUT = 180 # seconds for one evo-agents command; its own HTTP calls time out sooner
47
+ UPDATABLE = ("status", "done_at", "fixed_at", "answered_at", "merged_at", "note", "evidence")
48
+
49
+ # The keys of each `--json` answer read below. tests/test_harness_hub.py checks them, and every
50
+ # command line sent, against `evo-agents hub contract print`.
51
+ READS = {
52
+ "hub plan show": ("area", "revision", "body"),
53
+ "hub plan patch": ("revision", "changed"),
54
+ "hub plan complete": ("revision", "changed"),
55
+ "hub plan export": ("notes",),
56
+ }
57
+
58
+
59
+ class HubError(click.ClickException):
60
+ """An `evo-agents hub ...` command failed. `unavailable` when no command can work right now
61
+ (evo-agents missing, too old, or not signed in), so reading the plan again is pointless."""
62
+
63
+ def __init__(self, message: str, *, unavailable: bool = False):
64
+ super().__init__(message)
65
+ self.unavailable = unavailable
66
+
67
+ def adding(self, text: str) -> HubError:
68
+ return HubError(f"{self.message} {text}", unavailable=self.unavailable)
69
+
70
+
71
+ def _error_text(stderr: str, returncode: int) -> str:
72
+ lines = [line.strip() for line in stderr.splitlines() if line.strip()]
73
+ errors = [line[len("error:") :].strip() for line in lines if line.startswith("error:")]
74
+ text = " ".join(errors) or " ".join(lines[-5:]) or f"exit status {returncode}"
75
+ return text if text.endswith((".", "!", "?")) else text + "."
76
+
77
+
78
+ def call(args: list[str], cwd: Path) -> Any:
79
+ """`evo-agents hub ARGS --json`, decoded; HubError saying what went wrong otherwise."""
80
+ executable = shutil.which(EXECUTABLE)
81
+ if executable is None:
82
+ raise HubError(
83
+ "This harness keeps its plans on the evo-agents hub (hub.project in harness.yaml), and `evo-agents` "
84
+ f"is not on PATH. Install it with `{INSTALL}`, then sign in with `{LOGIN}`.",
85
+ unavailable=True,
86
+ )
87
+ shown = "evo-agents hub " + " ".join(args[:2])
88
+ try:
89
+ done = subprocess.run(
90
+ [executable, "hub", *args, "--json"],
91
+ cwd=cwd,
92
+ capture_output=True,
93
+ text=True,
94
+ encoding="utf-8",
95
+ errors="replace",
96
+ stdin=subprocess.DEVNULL,
97
+ timeout=TIMEOUT,
98
+ check=False,
99
+ )
100
+ except subprocess.TimeoutExpired:
101
+ raise HubError(f"`{shown}` gave no answer within {TIMEOUT}s.") from None
102
+ except OSError as exc:
103
+ raise HubError(f"Cannot run {executable}: {exc}.", unavailable=True) from None
104
+
105
+ if done.returncode != 0:
106
+ text = _error_text(done.stderr, done.returncode)
107
+ lowered = done.stderr.lower()
108
+ if "not signed in" in lowered or "hub login" in lowered:
109
+ raise HubError(f"`{shown}` failed: {text} Sign in with `{LOGIN}`.", unavailable=True)
110
+ if done.returncode == 2 and ("invalid choice" in lowered or "unrecognized arguments" in lowered):
111
+ raise HubError(
112
+ f"This evo-agents does not understand `{shown}`: {text} Install a version that does with `{INSTALL}`.",
113
+ unavailable=True,
114
+ )
115
+ raise HubError(f"`{shown}` failed: {text}")
116
+ try:
117
+ return json.loads(done.stdout)
118
+ except ValueError:
119
+ raise HubError(f"`{shown}` did not print the JSON it promises with --json.") from None
120
+
121
+
122
+ @dataclass(frozen=True)
123
+ class Target:
124
+ manifest_path: Path
125
+ root: Path
126
+ project: str
127
+ plan: Plan
128
+
129
+ @property
130
+ def copy(self) -> str:
131
+ try:
132
+ return self.plan.path.resolve().relative_to(self.root).as_posix()
133
+ except ValueError:
134
+ return str(self.plan.path)
135
+
136
+ @property
137
+ def untouched(self) -> str:
138
+ return f"{self.copy} was not changed."
139
+
140
+
141
+ def _preflight(target: Target) -> None:
142
+ """Refuse before the hub hears anything when the export after the write would overwrite work."""
143
+ problems = []
144
+ for found in copies(target.manifest_path, target.project):
145
+ mine = found.path.resolve() == target.plan.path.resolve()
146
+ if found.state in (EDITED, FOREIGN) or (mine and found.state != INTACT):
147
+ problems.append(f" {found.message}")
148
+ if problems:
149
+ raise click.ClickException(
150
+ "The plans of this harness live on the evo-agents hub, and `evo-agents hub plan export` rewrites "
151
+ "their copies after every change, so it would overwrite this:\n"
152
+ + "\n".join(problems)
153
+ + "\nNothing was sent to the hub, and no file was changed."
154
+ )
155
+
156
+
157
+ def _show(target: Target) -> dict:
158
+ try:
159
+ plan = call(["plan", "show", target.plan.id, "--project", target.project], target.root)
160
+ except HubError as exc:
161
+ raise exc.adding(target.untouched) from None
162
+ if (
163
+ not isinstance(plan, dict)
164
+ or type(plan.get("revision")) is not int
165
+ or not isinstance(plan.get("body"), dict)
166
+ or plan.get("area") not in AREAS
167
+ ):
168
+ raise HubError(f"`evo-agents hub plan show {target.plan.id}` answered without a revision, area and body.")
169
+ return plan
170
+
171
+
172
+ def _reread(target: Target, failure: HubError, sent_on: dict, retry: int) -> dict:
173
+ """After a refused write: the plan again when a newer revision explains the refusal (a 409), else the
174
+ original error. Telling a conflict by the revision, not by the wording of the message, keeps this
175
+ independent of how the hub phrases it. The pause comes before the read, so the retry goes out on
176
+ a revision as fresh as it can be."""
177
+ if failure.unavailable:
178
+ raise failure.adding(target.untouched) from None
179
+ _pause(retry)
180
+ try:
181
+ latest = _show(target)
182
+ except click.ClickException:
183
+ raise failure.adding(target.untouched) from None
184
+ if latest["revision"] == sent_on["revision"]:
185
+ raise failure.adding(target.untouched) from None
186
+ return latest
187
+
188
+
189
+ def _pause(retry: int) -> None:
190
+ time.sleep(random.uniform(0, min(MAX_PAUSE, PAUSE * 2**retry)))
191
+
192
+
193
+ def _identity(item: dict) -> dict:
194
+ return {key: value for key, value in item.items() if key not in UPDATABLE}
195
+
196
+
197
+ def _hub_item(target: Target, plan: dict, section: str, index: int, key: str | None, local: dict) -> dict:
198
+ items = plan["body"].get(section)
199
+ items = items if isinstance(items, list) else []
200
+ stale = (
201
+ f"{target.copy} may be older than the hub's plan: `evo-agents hub plan export {target.root}` refreshes it. "
202
+ f"Nothing was written."
203
+ )
204
+ if key is not None:
205
+ position = next(
206
+ (i for i, entry in enumerate(items) if step_key(entry if isinstance(entry, dict) else {}, i) == key),
207
+ None,
208
+ )
209
+ if position is None:
210
+ raise click.ClickException(
211
+ f"Plan {target.plan.id} on the hub (revision {plan['revision']}) has no step {key}. {stale}"
212
+ )
213
+ else:
214
+ position = index
215
+ if not 0 <= index < len(items) or not isinstance(items[index], dict) or _identity(items[index]) != local:
216
+ raise click.ClickException(
217
+ f"{section}[{index}] of plan {target.plan.id} on the hub (revision {plan['revision']}) is not the item "
218
+ f"{target.copy} shows there. {stale}"
219
+ )
220
+ item = items[position]
221
+ if not isinstance(item, dict):
222
+ raise click.ClickException(f"Step {key} of plan {target.plan.id} on the hub is not a mapping. {stale}")
223
+ return item
224
+
225
+
226
+ def _target(manifest_path: Path, plan: Plan, project: str) -> Target:
227
+ return Target(manifest_path, harness_root(manifest_path), project, plan)
228
+
229
+
230
+ def _export(target: Target, written: dict, area: str | None = None) -> tuple[Path, list[str]]:
231
+ """Write the copies, then confirm the copy of this plan holds at least the revision just written."""
232
+ held = f"The hub holds the change (plan {target.plan.id}, revision {written['revision']}), but"
233
+ retry = f"Run `evo-agents hub plan export {target.root}` to write it."
234
+ try:
235
+ result = call(["plan", "export", str(target.root), "--project", target.project], target.root)
236
+ except HubError as exc:
237
+ raise click.ClickException(f"{held} writing its copy failed: {exc.message} {retry}") from None
238
+
239
+ areas = (area,) if area else AREAS
240
+ for name in areas:
241
+ path = target.root / "plans" / name / f"{target.plan.id}.yaml"
242
+ if not path.is_file():
243
+ continue
244
+ found = classify(target.root, target.project, path)
245
+ if found.state == INTACT and (found.revision or 0) >= written["revision"]:
246
+ notes = result.get("notes") if isinstance(result, dict) else None
247
+ return path, [str(note) for note in notes or []]
248
+ where = " or ".join(f"plans/{name}/{target.plan.id}.yaml" for name in areas)
249
+ raise click.ClickException(f"{held} `evo-agents hub plan export` left no copy of that revision at {where}. {retry}")
250
+
251
+
252
+ def patch_item(
253
+ manifest_path: Path, plan: Plan, project: str, section: str, index: int, updates: dict, append: dict
254
+ ) -> dict:
255
+ """Set `updates` and append `append` on one item of `plan` on the hub, then write the copies."""
256
+ target = _target(manifest_path, plan, project)
257
+ _preflight(target)
258
+
259
+ entries = plan.raw.get(section)
260
+ local = entries[index] if isinstance(entries, list) and 0 <= index < len(entries) else None
261
+ if not isinstance(local, dict):
262
+ raise click.ClickException(
263
+ f"{section}[{index}] of {target.copy} is not a mapping, so it has no status to set. Nothing was written."
264
+ )
265
+ local = normalized(local)
266
+ key = step_key(local, index) if section == "steps" else None
267
+ where = f"step {key}" if key is not None else f"{section}[{index}]"
268
+ selector = ["--step", key] if key is not None else ["--index", str(index)]
269
+
270
+ current = _show(target)
271
+ first = previous = None
272
+ for attempt in range(ATTEMPTS):
273
+ item = _hub_item(target, current, section, index, key, _identity(local))
274
+ if first is None:
275
+ first = item
276
+ else:
277
+ clashes = [k for k in updates if previous.get(k) != item.get(k) and item.get(k) != updates[k]]
278
+ if clashes:
279
+ raise click.ClickException(
280
+ f"{', '.join(clashes)} of {where} in plan {plan.id} changed on the hub (revision "
281
+ f"{current['revision']}) while this ran, so it was not overwritten. Look at it with "
282
+ f"`evo-agents hub plan show {plan.id}` and run the command again to set it anyway. "
283
+ f"{target.untouched}"
284
+ )
285
+ values = dict(updates)
286
+ for name, addition in append.items():
287
+ values[name] = join_text(item.get(name), addition, f"{where} {name}")
288
+ sending = {name: value for name, value in values.items() if item.get(name) != value}
289
+ if not sending: # the hub already holds all of it, a retry of a write that landed included
290
+ written = {"revision": current["revision"], "changed": False}
291
+ break
292
+ sets = [part for name, value in sending.items() for part in ("--set", f"{name}={value}")]
293
+ args = ["plan", "patch", plan.id, "--project", project, "--section", section, *selector, *sets]
294
+ try:
295
+ written = call([*args, "--if-revision", str(current["revision"])], target.root)
296
+ break
297
+ except HubError as exc:
298
+ previous, current = item, _reread(target, exc, current, attempt)
299
+ else:
300
+ raise click.ClickException(
301
+ f"Plan {plan.id} changed on the hub {ATTEMPTS} times while {where} was being written; nothing was "
302
+ f"written. Run the command again. {target.untouched}"
303
+ )
304
+
305
+ path, notes = _export(target, written)
306
+ return {
307
+ "old": {name: first.get(name) for name in values},
308
+ "new": values,
309
+ "revision": written["revision"],
310
+ "changed": bool(written.get("changed")),
311
+ "path": path,
312
+ "notes": notes,
313
+ }
314
+
315
+
316
+ def complete(manifest_path: Path, plan: Plan, project: str) -> Path:
317
+ """Move `plan` to completed on the hub, then write the copies; the path of the completed copy."""
318
+ target = _target(manifest_path, plan, project)
319
+ _preflight(target)
320
+ current = _show(target)
321
+ for attempt in range(ATTEMPTS):
322
+ if current["area"] == "completed":
323
+ written = {"revision": current["revision"], "area": "completed", "changed": False}
324
+ break
325
+ args = ["plan", "complete", plan.id, "--project", project, "--if-revision", str(current["revision"])]
326
+ try:
327
+ written = call(args, target.root)
328
+ break
329
+ except HubError as exc:
330
+ current = _reread(target, exc, current, attempt)
331
+ else:
332
+ raise click.ClickException(
333
+ f"Plan {plan.id} changed on the hub {ATTEMPTS} times while it was being completed; nothing was "
334
+ f"written. Run it again. {target.untouched}"
335
+ )
336
+ path, _ = _export(target, written, "completed")
337
+ return path
@@ -0,0 +1,119 @@
1
+ """Read-only copies of hub plans (seam hub-plan-mirror-v1, owned by evo-agents).
2
+
3
+ When harness.yaml has `hub.project`, the evo-agents hub owns the plans of the harness, and every
4
+ plans/<area>/<id>.yaml is a copy the hub wrote with `evo-agents hub plan export`: a header line,
5
+ the plan, and a `hub: {project, revision, digest}` key. The digest is "sha256:" plus the SHA-256 of
6
+ the canonical JSON of the plan without the hub key (keys sorted, no whitespace, UTF-8 with non-ASCII
7
+ characters left as they are), read the way YAML loads it, with dates turned into ISO text.
8
+
9
+ This module recomputes that digest so `evo harness check` tells a copy the hub wrote from one someone
10
+ edited by hand, and so a write refuses to run an export that would overwrite such an edit. The
11
+ messages are the ones `evo-agents harness validate` prints, so both tools point at the same fix.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import datetime as _dt
17
+ import hashlib
18
+ import json
19
+ from dataclasses import dataclass
20
+ from pathlib import Path
21
+
22
+ import yaml
23
+
24
+ from evo_cli.commands.harness._model import harness_root
25
+ from evo_cli.commands.harness._paths import read_yaml, yaml_load
26
+
27
+ HUB_KEY = "hub"
28
+
29
+ INTACT = "intact" # a copy of a plan of this project, exactly as the hub wrote it
30
+ EDITED = "edited" # a copy of a plan of this project whose content no longer has its digest
31
+ FOREIGN = "foreign" # a copy of a plan of another hub project
32
+ DRAFT = "draft" # a plan file without a hub key: never a copy, or a copy whose key was removed
33
+ BROKEN = "broken" # not YAML holding a mapping
34
+
35
+
36
+ def hub_project(manifest_path: Path) -> str | None:
37
+ """The project harness.yaml names under `hub`; when set, the hub owns the plans and git keeps copies."""
38
+ hub = read_yaml(manifest_path).get(HUB_KEY)
39
+ project = hub.get("project") if isinstance(hub, dict) else None
40
+ return project if isinstance(project, str) and project.strip() else None
41
+
42
+
43
+ def normalized(value):
44
+ """A loaded YAML value as the hub sees it: dates as ISO text, every mapping key as text."""
45
+ if isinstance(value, (_dt.datetime, _dt.date)):
46
+ return value.isoformat()
47
+ if isinstance(value, dict):
48
+ return {str(k): normalized(v) for k, v in value.items()}
49
+ if isinstance(value, list):
50
+ return [normalized(v) for v in value]
51
+ return value
52
+
53
+
54
+ def plan_body(data: dict) -> dict:
55
+ """The plan without the hub key: what the hub stores and what its digest covers."""
56
+ return {key: value for key, value in normalized(data).items() if key != HUB_KEY}
57
+
58
+
59
+ def plan_digest(data: dict) -> str:
60
+ canonical = json.dumps(plan_body(data), sort_keys=True, separators=(",", ":"), ensure_ascii=False)
61
+ return "sha256:" + hashlib.sha256(canonical.encode("utf-8")).hexdigest()
62
+
63
+
64
+ @dataclass(frozen=True)
65
+ class Copy:
66
+ path: Path
67
+ relative: str # the path from the harness root, as messages name it
68
+ state: str
69
+ revision: int | None
70
+ message: str
71
+
72
+
73
+ def classify(root: Path, project: str, path: Path) -> Copy:
74
+ try:
75
+ relative = path.resolve().relative_to(root).as_posix()
76
+ except ValueError:
77
+ relative = path.as_posix()
78
+ try:
79
+ data = yaml_load(path.read_bytes())
80
+ except (OSError, yaml.YAMLError) as exc:
81
+ reason = str(exc).splitlines()[0] if str(exc) else type(exc).__name__
82
+ message = f"{relative} cannot be read as YAML ({reason}): restore it with `evo-agents hub plan export .`"
83
+ return Copy(path, relative, BROKEN, None, message)
84
+ if not isinstance(data, dict):
85
+ message = f"{relative} does not hold a plan (a YAML mapping): restore it with `evo-agents hub plan export .`"
86
+ return Copy(path, relative, BROKEN, None, message)
87
+
88
+ hub = data.get(HUB_KEY)
89
+ if not isinstance(hub, dict):
90
+ message = (
91
+ f"{relative} has no hub key, so it is not a copy of a hub plan. Push it with "
92
+ f"`evo-agents hub plan put {relative}` or restore the hub's copy with `evo-agents hub plan export .`"
93
+ )
94
+ return Copy(path, relative, DRAFT, None, message)
95
+
96
+ revision = hub.get("revision") if type(hub.get("revision")) is int else None
97
+ if hub.get("project") != project:
98
+ message = (
99
+ f"{relative} is a copy of a plan of hub project {hub.get('project')!r}, but harness.yaml says "
100
+ f"hub.project {project!r}: restore it with `evo-agents hub plan export .`"
101
+ )
102
+ return Copy(path, relative, FOREIGN, revision, message)
103
+ if hub.get("digest") != plan_digest(data):
104
+ message = (
105
+ f"{relative} was edited outside the hub (digest mismatch). Push it with "
106
+ f"`evo-agents hub plan put {relative} --if-revision {revision or 'N'}` or restore it with "
107
+ "`evo-agents hub plan export .`"
108
+ )
109
+ return Copy(path, relative, EDITED, revision, message)
110
+ return Copy(path, relative, INTACT, revision, "")
111
+
112
+
113
+ def copies(manifest_path: Path, project: str) -> list[Copy]:
114
+ """Every plans/*/*.yaml of the harness, classified; the same files `evo-agents harness validate` reads."""
115
+ root = harness_root(manifest_path)
116
+ plans = root / "plans"
117
+ if not plans.is_dir():
118
+ return []
119
+ return [classify(root, project, path) for path in sorted(plans.glob("*/*.yaml"))]
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import datetime as _dt
3
4
  import hashlib
4
5
  from dataclasses import dataclass
5
6
  from pathlib import Path
@@ -83,6 +84,34 @@ def _title_of(section: str, item: dict) -> str:
83
84
  return str(item.get("what") or "?")
84
85
 
85
86
 
87
+ def join_text(existing: Any, addition: str, where: str) -> Any:
88
+ """`existing` with `addition` on a line of its own after it, as `--note` and `--evidence` add text.
89
+
90
+ What was there is never dropped. When `existing` already ends with that line it comes back
91
+ unchanged, so running the same command again (or retrying a write that did land) adds nothing.
92
+ """
93
+ addition = addition.strip()
94
+ if not addition:
95
+ return existing
96
+ if existing is None or (isinstance(existing, str) and not existing.strip()):
97
+ return addition
98
+ if isinstance(existing, (dict, list)):
99
+ kind = "a mapping" if isinstance(existing, dict) else "a list"
100
+ raise click.ClickException(
101
+ f"{where} holds {kind}, not text, so nothing can be appended to it. Turn it into text first."
102
+ )
103
+ if isinstance(existing, bool):
104
+ text = "true" if existing else "false"
105
+ elif isinstance(existing, (_dt.date, _dt.datetime)):
106
+ text = existing.isoformat()
107
+ else:
108
+ text = str(existing)
109
+ text = text.rstrip()
110
+ if text == addition or text.endswith("\n" + addition):
111
+ return existing
112
+ return text + "\n" + addition
113
+
114
+
86
115
  def plain(value: Any) -> Any:
87
116
  if isinstance(value, dict):
88
117
  return {str(k): plain(v) for k, v in value.items()}