evo-cli 0.29.2__tar.gz → 0.31.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 (155) hide show
  1. {evo_cli-0.29.2 → evo_cli-0.31.0}/PKG-INFO +6 -2
  2. {evo_cli-0.29.2 → evo_cli-0.31.0}/README.md +5 -1
  3. evo_cli-0.31.0/evo_cli/VERSION +1 -0
  4. {evo_cli-0.29.2 → evo_cli-0.31.0}/evo_cli/commands/harness/__init__.py +1 -1
  5. {evo_cli-0.29.2 → evo_cli-0.31.0}/evo_cli/commands/harness/_dag.py +23 -0
  6. {evo_cli-0.29.2 → evo_cli-0.31.0}/evo_cli/commands/harness/_render.py +34 -0
  7. {evo_cli-0.29.2 → evo_cli-0.31.0}/evo_cli/commands/harness/edit.py +3 -13
  8. evo_cli-0.31.0/evo_cli/commands/harness/show.py +274 -0
  9. {evo_cli-0.29.2 → evo_cli-0.31.0}/evo_cli/commands/harness/views.py +8 -4
  10. {evo_cli-0.29.2 → evo_cli-0.31.0}/evo_cli.egg-info/PKG-INFO +6 -2
  11. {evo_cli-0.29.2 → evo_cli-0.31.0}/evo_cli.egg-info/SOURCES.txt +1 -0
  12. evo_cli-0.31.0/tests/test_harness_show.py +461 -0
  13. evo_cli-0.29.2/evo_cli/VERSION +0 -1
  14. evo_cli-0.29.2/evo_cli/commands/harness/show.py +0 -68
  15. {evo_cli-0.29.2 → evo_cli-0.31.0}/Containerfile +0 -0
  16. {evo_cli-0.29.2 → evo_cli-0.31.0}/HISTORY.md +0 -0
  17. {evo_cli-0.29.2 → evo_cli-0.31.0}/LICENSE +0 -0
  18. {evo_cli-0.29.2 → evo_cli-0.31.0}/MANIFEST.in +0 -0
  19. {evo_cli-0.29.2 → evo_cli-0.31.0}/evo_cli/__init__.py +0 -0
  20. {evo_cli-0.29.2 → evo_cli-0.31.0}/evo_cli/__main__.py +0 -0
  21. {evo_cli-0.29.2 → evo_cli-0.31.0}/evo_cli/base.py +0 -0
  22. {evo_cli-0.29.2 → evo_cli-0.31.0}/evo_cli/cli.py +0 -0
  23. {evo_cli-0.29.2 → evo_cli-0.31.0}/evo_cli/commands/__init__.py +0 -0
  24. {evo_cli-0.29.2 → evo_cli-0.31.0}/evo_cli/commands/agent_toy.py +0 -0
  25. {evo_cli-0.29.2 → evo_cli-0.31.0}/evo_cli/commands/claude_code.py +0 -0
  26. {evo_cli-0.29.2 → evo_cli-0.31.0}/evo_cli/commands/cloudflare.py +0 -0
  27. {evo_cli-0.29.2 → evo_cli-0.31.0}/evo_cli/commands/cred.py +0 -0
  28. {evo_cli-0.29.2 → evo_cli-0.31.0}/evo_cli/commands/download.py +0 -0
  29. {evo_cli-0.29.2 → evo_cli-0.31.0}/evo_cli/commands/fix_claude.py +0 -0
  30. {evo_cli-0.29.2 → evo_cli-0.31.0}/evo_cli/commands/gdrive.py +0 -0
  31. {evo_cli-0.29.2 → evo_cli-0.31.0}/evo_cli/commands/gh.py +0 -0
  32. {evo_cli-0.29.2 → evo_cli-0.31.0}/evo_cli/commands/harness/_git.py +0 -0
  33. {evo_cli-0.29.2 → evo_cli-0.31.0}/evo_cli/commands/harness/_hub.py +0 -0
  34. {evo_cli-0.29.2 → evo_cli-0.31.0}/evo_cli/commands/harness/_mirror.py +0 -0
  35. {evo_cli-0.29.2 → evo_cli-0.31.0}/evo_cli/commands/harness/_model.py +0 -0
  36. {evo_cli-0.29.2 → evo_cli-0.31.0}/evo_cli/commands/harness/_mutate.py +0 -0
  37. {evo_cli-0.29.2 → evo_cli-0.31.0}/evo_cli/commands/harness/_paths.py +0 -0
  38. {evo_cli-0.29.2 → evo_cli-0.31.0}/evo_cli/commands/harness/_server.py +0 -0
  39. {evo_cli-0.29.2 → evo_cli-0.31.0}/evo_cli/commands/harness/check.py +0 -0
  40. {evo_cli-0.29.2 → evo_cli-0.31.0}/evo_cli/commands/harness/clone.py +0 -0
  41. {evo_cli-0.29.2 → evo_cli-0.31.0}/evo_cli/commands/harness/pull.py +0 -0
  42. {evo_cli-0.29.2 → evo_cli-0.31.0}/evo_cli/commands/harness/serve.py +0 -0
  43. {evo_cli-0.29.2 → evo_cli-0.31.0}/evo_cli/commands/harness/web/assets/index-Cs7hXRPJ.css +0 -0
  44. {evo_cli-0.29.2 → evo_cli-0.31.0}/evo_cli/commands/harness/web/assets/index-CuNOlaH7.js +0 -0
  45. {evo_cli-0.29.2 → evo_cli-0.31.0}/evo_cli/commands/harness/web/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
  46. {evo_cli-0.29.2 → evo_cli-0.31.0}/evo_cli/commands/harness/web/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
  47. {evo_cli-0.29.2 → evo_cli-0.31.0}/evo_cli/commands/harness/web/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
  48. {evo_cli-0.29.2 → evo_cli-0.31.0}/evo_cli/commands/harness/web/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
  49. {evo_cli-0.29.2 → evo_cli-0.31.0}/evo_cli/commands/harness/web/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
  50. {evo_cli-0.29.2 → evo_cli-0.31.0}/evo_cli/commands/harness/web/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
  51. {evo_cli-0.29.2 → evo_cli-0.31.0}/evo_cli/commands/harness/web/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
  52. {evo_cli-0.29.2 → evo_cli-0.31.0}/evo_cli/commands/harness/web/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
  53. {evo_cli-0.29.2 → evo_cli-0.31.0}/evo_cli/commands/harness/web/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
  54. {evo_cli-0.29.2 → evo_cli-0.31.0}/evo_cli/commands/harness/web/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
  55. {evo_cli-0.29.2 → evo_cli-0.31.0}/evo_cli/commands/harness/web/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
  56. {evo_cli-0.29.2 → evo_cli-0.31.0}/evo_cli/commands/harness/web/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
  57. {evo_cli-0.29.2 → evo_cli-0.31.0}/evo_cli/commands/harness/web/index.html +0 -0
  58. {evo_cli-0.29.2 → evo_cli-0.31.0}/evo_cli/commands/hwid.py +0 -0
  59. {evo_cli-0.29.2 → evo_cli-0.31.0}/evo_cli/commands/hwid_reset.py +0 -0
  60. {evo_cli-0.29.2 → evo_cli-0.31.0}/evo_cli/commands/image.py +0 -0
  61. {evo_cli-0.29.2 → evo_cli-0.31.0}/evo_cli/commands/localproxy.py +0 -0
  62. {evo_cli-0.29.2 → evo_cli-0.31.0}/evo_cli/commands/mcp.py +0 -0
  63. {evo_cli-0.29.2 → evo_cli-0.31.0}/evo_cli/commands/miniconda.py +0 -0
  64. {evo_cli-0.29.2 → evo_cli-0.31.0}/evo_cli/commands/netcheck.py +0 -0
  65. {evo_cli-0.29.2 → evo_cli-0.31.0}/evo_cli/commands/ngrok.py +0 -0
  66. {evo_cli-0.29.2 → evo_cli-0.31.0}/evo_cli/commands/opencode.py +0 -0
  67. {evo_cli-0.29.2 → evo_cli-0.31.0}/evo_cli/commands/openvpn.py +0 -0
  68. {evo_cli-0.29.2 → evo_cli-0.31.0}/evo_cli/commands/plantuml.py +0 -0
  69. {evo_cli-0.29.2 → evo_cli-0.31.0}/evo_cli/commands/route/__init__.py +0 -0
  70. {evo_cli-0.29.2 → evo_cli-0.31.0}/evo_cli/commands/route/_dns.py +0 -0
  71. {evo_cli-0.29.2 → evo_cli-0.31.0}/evo_cli/commands/route/_dpi.py +0 -0
  72. {evo_cli-0.29.2 → evo_cli-0.31.0}/evo_cli/commands/route/_hosts.py +0 -0
  73. {evo_cli-0.29.2 → evo_cli-0.31.0}/evo_cli/commands/route/_probe.py +0 -0
  74. {evo_cli-0.29.2 → evo_cli-0.31.0}/evo_cli/commands/route/check.py +0 -0
  75. {evo_cli-0.29.2 → evo_cli-0.31.0}/evo_cli/commands/route/fix.py +0 -0
  76. {evo_cli-0.29.2 → evo_cli-0.31.0}/evo_cli/commands/route/probe.py +0 -0
  77. {evo_cli-0.29.2 → evo_cli-0.31.0}/evo_cli/commands/route/status.py +0 -0
  78. {evo_cli-0.29.2 → evo_cli-0.31.0}/evo_cli/commands/serp.py +0 -0
  79. {evo_cli-0.29.2 → evo_cli-0.31.0}/evo_cli/commands/site2s.py +0 -0
  80. {evo_cli-0.29.2 → evo_cli-0.31.0}/evo_cli/commands/ssh.py +0 -0
  81. {evo_cli-0.29.2 → evo_cli-0.31.0}/evo_cli/commands/storage.py +0 -0
  82. {evo_cli-0.29.2 → evo_cli-0.31.0}/evo_cli/commands/sysmon.py +0 -0
  83. {evo_cli-0.29.2 → evo_cli-0.31.0}/evo_cli/commands/tts.py +0 -0
  84. {evo_cli-0.29.2 → evo_cli-0.31.0}/evo_cli/commands/update.py +0 -0
  85. {evo_cli-0.29.2 → evo_cli-0.31.0}/evo_cli/commands/wifi.py +0 -0
  86. {evo_cli-0.29.2 → evo_cli-0.31.0}/evo_cli/console.py +0 -0
  87. {evo_cli-0.29.2 → evo_cli-0.31.0}/evo_cli/credentials/__init__.py +0 -0
  88. {evo_cli-0.29.2 → evo_cli-0.31.0}/evo_cli/credentials/doctor.py +0 -0
  89. {evo_cli-0.29.2 → evo_cli-0.31.0}/evo_cli/credentials/google_oauth.py +0 -0
  90. {evo_cli-0.29.2 → evo_cli-0.31.0}/evo_cli/credentials/migrate.py +0 -0
  91. {evo_cli-0.29.2 → evo_cli-0.31.0}/evo_cli/credentials/oauth_flow.py +0 -0
  92. {evo_cli-0.29.2 → evo_cli-0.31.0}/evo_cli/credentials/registry.py +0 -0
  93. {evo_cli-0.29.2 → evo_cli-0.31.0}/evo_cli/credentials/store.py +0 -0
  94. {evo_cli-0.29.2 → evo_cli-0.31.0}/evo_cli/credentials/sync.py +0 -0
  95. {evo_cli-0.29.2 → evo_cli-0.31.0}/evo_cli/imaging/__init__.py +0 -0
  96. {evo_cli-0.29.2 → evo_cli-0.31.0}/evo_cli/imaging/align.py +0 -0
  97. {evo_cli-0.29.2 → evo_cli-0.31.0}/evo_cli/imaging/core.py +0 -0
  98. {evo_cli-0.29.2 → evo_cli-0.31.0}/evo_cli/imaging/creds.py +0 -0
  99. {evo_cli-0.29.2 → evo_cli-0.31.0}/evo_cli/imaging/errors.py +0 -0
  100. {evo_cli-0.29.2 → evo_cli-0.31.0}/evo_cli/imaging/gemini.py +0 -0
  101. {evo_cli-0.29.2 → evo_cli-0.31.0}/evo_cli/imaging/ncnn.py +0 -0
  102. {evo_cli-0.29.2 → evo_cli-0.31.0}/evo_cli/mcp_registry.py +0 -0
  103. {evo_cli-0.29.2 → evo_cli-0.31.0}/evo_cli/openvpn.py +0 -0
  104. {evo_cli-0.29.2 → evo_cli-0.31.0}/evo_cli/serp/__init__.py +0 -0
  105. {evo_cli-0.29.2 → evo_cli-0.31.0}/evo_cli/serp/client.py +0 -0
  106. {evo_cli-0.29.2 → evo_cli-0.31.0}/evo_cli/serp/creds.py +0 -0
  107. {evo_cli-0.29.2 → evo_cli-0.31.0}/evo_cli/serp/errors.py +0 -0
  108. {evo_cli-0.29.2 → evo_cli-0.31.0}/evo_cli/serp/install.py +0 -0
  109. {evo_cli-0.29.2 → evo_cli-0.31.0}/evo_cli/serp/render.py +0 -0
  110. {evo_cli-0.29.2 → evo_cli-0.31.0}/evo_cli/tts/__init__.py +0 -0
  111. {evo_cli-0.29.2 → evo_cli-0.31.0}/evo_cli/tts/chunking.py +0 -0
  112. {evo_cli-0.29.2 → evo_cli-0.31.0}/evo_cli/tts/core.py +0 -0
  113. {evo_cli-0.29.2 → evo_cli-0.31.0}/evo_cli/tts/creds.py +0 -0
  114. {evo_cli-0.29.2 → evo_cli-0.31.0}/evo_cli/tts/errors.py +0 -0
  115. {evo_cli-0.29.2 → evo_cli-0.31.0}/evo_cli/tts/gemini.py +0 -0
  116. {evo_cli-0.29.2 → evo_cli-0.31.0}/evo_cli/tts/http.py +0 -0
  117. {evo_cli-0.29.2 → evo_cli-0.31.0}/evo_cli/tts/openai.py +0 -0
  118. {evo_cli-0.29.2 → evo_cli-0.31.0}/evo_cli/tts/player.py +0 -0
  119. {evo_cli-0.29.2 → evo_cli-0.31.0}/evo_cli/tts/vbee.py +0 -0
  120. {evo_cli-0.29.2 → evo_cli-0.31.0}/evo_cli.egg-info/dependency_links.txt +0 -0
  121. {evo_cli-0.29.2 → evo_cli-0.31.0}/evo_cli.egg-info/entry_points.txt +0 -0
  122. {evo_cli-0.29.2 → evo_cli-0.31.0}/evo_cli.egg-info/requires.txt +0 -0
  123. {evo_cli-0.29.2 → evo_cli-0.31.0}/evo_cli.egg-info/top_level.txt +0 -0
  124. {evo_cli-0.29.2 → evo_cli-0.31.0}/pyproject.toml +0 -0
  125. {evo_cli-0.29.2 → evo_cli-0.31.0}/setup.cfg +0 -0
  126. {evo_cli-0.29.2 → evo_cli-0.31.0}/tests/__init__.py +0 -0
  127. {evo_cli-0.29.2 → evo_cli-0.31.0}/tests/fake_evo_agents.py +0 -0
  128. {evo_cli-0.29.2 → evo_cli-0.31.0}/tests/fixtures/hub/contract-0.2.0.json +0 -0
  129. {evo_cli-0.29.2 → evo_cli-0.31.0}/tests/fixtures/hub/mirror-demo.yaml +0 -0
  130. {evo_cli-0.29.2 → evo_cli-0.31.0}/tests/test_agent_toy.py +0 -0
  131. {evo_cli-0.29.2 → evo_cli-0.31.0}/tests/test_claude_code.py +0 -0
  132. {evo_cli-0.29.2 → evo_cli-0.31.0}/tests/test_cli.py +0 -0
  133. {evo_cli-0.29.2 → evo_cli-0.31.0}/tests/test_console.py +0 -0
  134. {evo_cli-0.29.2 → evo_cli-0.31.0}/tests/test_cred.py +0 -0
  135. {evo_cli-0.29.2 → evo_cli-0.31.0}/tests/test_download.py +0 -0
  136. {evo_cli-0.29.2 → evo_cli-0.31.0}/tests/test_fix_claude.py +0 -0
  137. {evo_cli-0.29.2 → evo_cli-0.31.0}/tests/test_gdrive.py +0 -0
  138. {evo_cli-0.29.2 → evo_cli-0.31.0}/tests/test_gh.py +0 -0
  139. {evo_cli-0.29.2 → evo_cli-0.31.0}/tests/test_harness.py +0 -0
  140. {evo_cli-0.29.2 → evo_cli-0.31.0}/tests/test_harness_clone.py +0 -0
  141. {evo_cli-0.29.2 → evo_cli-0.31.0}/tests/test_harness_dag.py +0 -0
  142. {evo_cli-0.29.2 → evo_cli-0.31.0}/tests/test_harness_hub.py +0 -0
  143. {evo_cli-0.29.2 → evo_cli-0.31.0}/tests/test_image.py +0 -0
  144. {evo_cli-0.29.2 → evo_cli-0.31.0}/tests/test_mcp.py +0 -0
  145. {evo_cli-0.29.2 → evo_cli-0.31.0}/tests/test_ngrok.py +0 -0
  146. {evo_cli-0.29.2 → evo_cli-0.31.0}/tests/test_opencode.py +0 -0
  147. {evo_cli-0.29.2 → evo_cli-0.31.0}/tests/test_openvpn.py +0 -0
  148. {evo_cli-0.29.2 → evo_cli-0.31.0}/tests/test_plantuml.py +0 -0
  149. {evo_cli-0.29.2 → evo_cli-0.31.0}/tests/test_route.py +0 -0
  150. {evo_cli-0.29.2 → evo_cli-0.31.0}/tests/test_serp.py +0 -0
  151. {evo_cli-0.29.2 → evo_cli-0.31.0}/tests/test_storage.py +0 -0
  152. {evo_cli-0.29.2 → evo_cli-0.31.0}/tests/test_sysmon.py +0 -0
  153. {evo_cli-0.29.2 → evo_cli-0.31.0}/tests/test_tts.py +0 -0
  154. {evo_cli-0.29.2 → evo_cli-0.31.0}/tests/test_update.py +0 -0
  155. {evo_cli-0.29.2 → evo_cli-0.31.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.29.2
3
+ Version: 0.31.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
@@ -415,7 +415,11 @@ evo harness serve # dashboard on http://127.0.0.1:8788
415
415
  evo harness repos # repos in the manifest, with branch and dirty state
416
416
  evo harness seams # contract seams; --graph for the owner -> consumer DAG
417
417
  evo harness plans # progress across every exec-plan
418
- evo harness show <plan>
418
+ evo harness show <plan> # every section, open questions with status and blocking
419
+ evo harness show <plan> --step 3 # one step in full: what, verify, note, acceptance, evidence
420
+ evo harness show <plan> --section acceptance # each criterion in full: id, criterion, verify, steps naming it
421
+ evo harness show <plan> --section non_goals # each non-goal in full
422
+ evo harness step <plan> --no-input # the step board: repo, status, depends_on, title
419
423
  evo harness check <plan> [--fetch] # what the plan claims vs what git says
420
424
  evo harness graph <plan>:steps # the same DAG as an adjacency list
421
425
  evo harness clone # clone every declared repo into its declared path
@@ -382,7 +382,11 @@ evo harness serve # dashboard on http://127.0.0.1:8788
382
382
  evo harness repos # repos in the manifest, with branch and dirty state
383
383
  evo harness seams # contract seams; --graph for the owner -> consumer DAG
384
384
  evo harness plans # progress across every exec-plan
385
- evo harness show <plan>
385
+ evo harness show <plan> # every section, open questions with status and blocking
386
+ evo harness show <plan> --step 3 # one step in full: what, verify, note, acceptance, evidence
387
+ evo harness show <plan> --section acceptance # each criterion in full: id, criterion, verify, steps naming it
388
+ evo harness show <plan> --section non_goals # each non-goal in full
389
+ evo harness step <plan> --no-input # the step board: repo, status, depends_on, title
386
390
  evo harness check <plan> [--fetch] # what the plan claims vs what git says
387
391
  evo harness graph <plan>:steps # the same DAG as an adjacency list
388
392
  evo harness clone # clone every declared repo into its declared path
@@ -0,0 +1 @@
1
+ 0.31.0
@@ -18,7 +18,7 @@ def harness_group():
18
18
  evo harness repos repos in the manifest, with git state
19
19
  evo harness seams contract seams: owner, consumers, verify command
20
20
  evo harness plans progress across every exec-plan
21
- evo harness show <plan> read one plan
21
+ evo harness show <plan> read one plan (--step <id>, --section acceptance|non_goals: in full)
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
24
  evo harness step <plan> 3 done mark a step (--evidence and --note append)
@@ -3,6 +3,8 @@ from __future__ import annotations
3
3
  import itertools
4
4
  from pathlib import Path
5
5
 
6
+ import rich_click as click
7
+
6
8
  from evo_cli.commands.harness._model import Plan, cluster, load_seams, step_title, tone_of
7
9
 
8
10
  CLUSTER_REPO = "__cluster__"
@@ -285,6 +287,17 @@ def step_key(entry: dict, index: int) -> str:
285
287
  return str(index)
286
288
 
287
289
 
290
+ def resolve_step(plan: Plan, key) -> int:
291
+ """Index of the step whose key is KEY. An unknown key fails with every key the plan holds."""
292
+ entries = plan.items("steps")
293
+ wanted = str(key)
294
+ for index, entry in enumerate(entries):
295
+ if step_key(entry, index) == wanted:
296
+ return index
297
+ known = ", ".join(step_key(e, i) for i, e in enumerate(entries)) or "(the plan has no steps)"
298
+ raise click.ClickException(f"No step {wanted!r} in {plan.id}. Available: {known}")
299
+
300
+
288
301
  def _as_list(value) -> list:
289
302
  if value is None:
290
303
  return []
@@ -293,6 +306,16 @@ def _as_list(value) -> list:
293
306
  return [value]
294
307
 
295
308
 
309
+ def step_depends(entry: dict) -> list[str]:
310
+ """Keys of the steps this one waits on, in written order, from every spelling the step graph reads."""
311
+ found: list[str] = []
312
+ for name in FORWARD_KEYS:
313
+ for raw in _as_list(entry.get(name)):
314
+ if str(raw) not in found:
315
+ found.append(str(raw))
316
+ return found
317
+
318
+
296
319
  def plan_step_graph(plan: Plan) -> dict:
297
320
  steps = plan.items("steps")
298
321
  repo_entries = plan.items("repos")
@@ -31,6 +31,40 @@ def one_line(value: object, width: int) -> str:
31
31
  return text if len(text) <= width else text[: max(1, width - 3)] + "..."
32
32
 
33
33
 
34
+ def as_text(value: object) -> str:
35
+ """A plan value the way YAML spells it: booleans as true/false, lists joined, nothing as `-`."""
36
+ if value is None or value == "" or value == []:
37
+ return "-"
38
+ if isinstance(value, bool):
39
+ return "true" if value else "false"
40
+ if isinstance(value, (list, tuple)):
41
+ return ", ".join(as_text(v) for v in value)
42
+ return str(value)
43
+
44
+
45
+ def key_runs(keys: list[str]) -> str:
46
+ """Step keys in written order, with a run of three or more consecutive integers shown as `first-last`."""
47
+ parts: list[str] = []
48
+ run: list[int] = []
49
+
50
+ def flush() -> None:
51
+ parts.extend([f"{run[0]}-{run[-1]}"] if len(run) >= 3 else [str(n) for n in run])
52
+ run.clear()
53
+
54
+ for key in keys:
55
+ number = int(key) if key.isascii() and key.isdigit() and str(int(key)) == key else None
56
+ if number is not None and run and number == run[-1] + 1:
57
+ run.append(number)
58
+ continue
59
+ flush()
60
+ if number is None:
61
+ parts.append(key)
62
+ else:
63
+ run.append(number)
64
+ flush()
65
+ return ", ".join(parts) or "-"
66
+
67
+
34
68
  def title_width(reserved: int = 26) -> int:
35
69
  return max(28, min(console.width, 140) - reserved)
36
70
 
@@ -5,7 +5,7 @@ from datetime import datetime
5
5
  import rich_click as click
6
6
  from rich.markup import escape
7
7
 
8
- from evo_cli.commands.harness._dag import step_key
8
+ from evo_cli.commands.harness._dag import resolve_step, step_key
9
9
  from evo_cli.commands.harness._model import find_plan, step_title, tone_of
10
10
  from evo_cli.commands.harness._mutate import update_item
11
11
  from evo_cli.commands.harness._paths import find_manifest, harness_option
@@ -70,16 +70,6 @@ def _write(manifest_path, plan_id, section, index, status, note, no_date, eviden
70
70
  console.print(f" [yellow]note:[/] {escape(text)}")
71
71
 
72
72
 
73
- def _resolve_step(item, key) -> int:
74
- entries = item.items("steps")
75
- wanted = str(key)
76
- for index, entry in enumerate(entries):
77
- if step_key(entry, index) == wanted:
78
- return index
79
- known = ", ".join(step_key(e, i) for i, e in enumerate(entries))
80
- raise click.ClickException(f"No step {wanted!r} in {item.id}. Available: {known}")
81
-
82
-
83
73
  def _pick(item, key, status):
84
74
  """Interactive picker, but only when questionary is installed and we own a real terminal."""
85
75
  try:
@@ -104,7 +94,7 @@ def _pick(item, key, status):
104
94
  raise click.Abort()
105
95
 
106
96
  if status is None:
107
- index = _resolve_step(item, key)
97
+ index = resolve_step(item, key)
108
98
  current = str(entries[index].get("status") or "")
109
99
  suggested = NEXT_STATUS.get(current, STEP_STATUS[0])
110
100
  status = questionary.select(
@@ -150,7 +140,7 @@ def step(harness_path, plan_id, key, status, note, evidence, no_date, no_input):
150
140
  if key is None or status is None:
151
141
  return
152
142
 
153
- _write(manifest_path, plan_id, "steps", _resolve_step(item, key), status, note, no_date, evidence)
143
+ _write(manifest_path, plan_id, "steps", resolve_step(item, key), status, note, no_date, evidence)
154
144
  console.print()
155
145
  step_board(find_plan(manifest_path, plan_id), highlight=key)
156
146
 
@@ -0,0 +1,274 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+
5
+ import rich_click as click
6
+ import yaml
7
+ from rich.panel import Panel
8
+ from rich.text import Text
9
+
10
+ from evo_cli.commands.harness._dag import resolve_step, step_key
11
+ from evo_cli.commands.harness._model import SECTIONS, find_plan, plain, tone_of
12
+ from evo_cli.commands.harness._paths import find_manifest, harness_option
13
+ from evo_cli.commands.harness._render import TONE_STYLE, as_text, bar, key_runs, one_line, title_width
14
+ from evo_cli.console import console
15
+
16
+ SECTION_TITLES = {
17
+ "references": "References",
18
+ "repos": "Repos and merge order",
19
+ "steps": "Steps",
20
+ "decisions": "Decisions",
21
+ "tech_debt": "Tech debt",
22
+ "open_questions": "Open questions",
23
+ "acceptance": "Acceptance",
24
+ "non_goals": "Non-goals",
25
+ }
26
+ # Plan-level lists `show` prints whole. They stay out of SECTIONS, which also shapes the
27
+ # dashboard's plan detail, where both are still read from `extra`.
28
+ PLAN_LISTS = ("acceptance", "non_goals")
29
+ SHOW_SECTIONS = (*SECTIONS, *PLAN_LISTS)
30
+ HIDE_KEYS = {"status", "order", "what", "repo", "id"}
31
+ # Every open question says whether it blocks, even one whose author left the key out.
32
+ ALWAYS_SHOWN = {"open_questions": ("blocking",)}
33
+
34
+ STEP_HEAD = ("id", "title", "status", "repo", "depends_on", "blocking", "done_at")
35
+ STEP_BODY = ("what", "verify", "note", "acceptance", "evidence")
36
+ INDENT = " "
37
+ FIELD_INDENT = " "
38
+ # A step's `acceptance` names criteria by id, as a list or as text like "a1; a9"; prose around them is not an id.
39
+ CRITERION_ID = re.compile(r"^a\d+$")
40
+ CRITERION_TEXT = ("criterion", "what")
41
+
42
+
43
+ def _render_item(section: str, index: int, item: dict, full: bool) -> None:
44
+ status = item.get("status")
45
+ style = TONE_STYLE[tone_of(section, status)]
46
+ authored = item.get("title")
47
+ titled = isinstance(authored, str) and bool(authored.strip())
48
+ if titled:
49
+ head, hidden = authored.strip(), (HIDE_KEYS - {"what"}) | {"title"}
50
+ else:
51
+ head = item.get("what") or item.get("repo") or item.get("issue") or "?"
52
+ hidden = HIDE_KEYS | ({"issue"} if head == item.get("issue") else set())
53
+ label = item.get("id", item.get("order", index))
54
+ badge = f"[{style}]{status}[/]" if status else ""
55
+ if not full:
56
+ head = one_line(head, title_width(reserved=18))
57
+ console.print(f" [bold]{label:>3}[/] {head} {badge}")
58
+ fields = {**item, **{key: item.get(key) for key in ALWAYS_SHOWN.get(section, ())}}
59
+ for key, value in fields.items():
60
+ if key in hidden:
61
+ continue
62
+ if isinstance(value, list):
63
+ value = "; ".join(as_text(v) for v in value) or "-"
64
+ text = " ".join(as_text(value).split())
65
+ console.print(f" [dim]{key}:[/] {text if full else one_line(text, title_width(reserved=18))}")
66
+
67
+
68
+ def _block(value: object) -> list[str]:
69
+ """Lines of a value printed under its key: text keeps its line breaks, a list becomes `- ` items."""
70
+ if value is None or value == "" or value == []:
71
+ return ["-"]
72
+ if isinstance(value, list):
73
+ lines = []
74
+ for entry in value:
75
+ first, *rest = _block(entry)
76
+ lines.append(f"- {first}")
77
+ lines.extend(f" {line}" if line else "" for line in rest)
78
+ return lines
79
+ if isinstance(value, dict):
80
+ dumped = yaml.safe_dump(plain(value), allow_unicode=True, sort_keys=False, width=1_000_000)
81
+ return dumped.rstrip("\n").split("\n")
82
+ return as_text(value).rstrip("\n").split("\n")
83
+
84
+
85
+ def _is_short(value: object) -> bool:
86
+ nested = isinstance(value, dict) or (isinstance(value, list) and any(isinstance(v, (dict, list)) for v in value))
87
+ text = as_text(value)
88
+ return not nested and "\n" not in text and len(text) <= 80
89
+
90
+
91
+ def _print(text: Text) -> None:
92
+ # Soft wrap: rich never breaks a line itself, so a verify command stays one copyable line.
93
+ console.print(text, soft_wrap=True)
94
+
95
+
96
+ def _print_step(item, key: str) -> None:
97
+ index = resolve_step(item, key)
98
+ entry = item.items("steps")[index]
99
+ key = step_key(entry, index)
100
+ _print(Text.assemble((item.id, "bold cyan"), " step ", (key, "bold"), (f" ({item.area})", "dim")))
101
+
102
+ # Any other key the step carries is printed too: short scalars beside the head, the rest as blocks.
103
+ extra = [k for k in entry if k not in STEP_HEAD and k not in STEP_BODY]
104
+ inline = [k for k in extra if _is_short(entry[k])]
105
+ head = [*STEP_HEAD, *inline]
106
+ width = max(len(k) for k in head) + 1
107
+ for name in head:
108
+ value = as_text(entry.get(name))
109
+ style = TONE_STYLE[tone_of("steps", value)] if name == "status" and value != "-" else ""
110
+ _print(Text.assemble((f"{name + ':':<{width}} ", "dim"), (value, style)))
111
+
112
+ for name in [*STEP_BODY, *(k for k in extra if k not in inline)]:
113
+ _print(Text())
114
+ _print(Text(f"{name}:", style="bold"))
115
+ for line in _block(entry.get(name)):
116
+ _print(Text(INDENT + line if line else ""))
117
+
118
+
119
+ def _plan_list(item, name: str) -> list:
120
+ """A plan-level list as written: `acceptance` holds mappings or plain strings, `non_goals` mostly strings."""
121
+ value = item.raw.get(name)
122
+ if value is None or value == "" or value == []:
123
+ return []
124
+ return [entry for entry in (value if isinstance(value, list) else [value]) if entry is not None]
125
+
126
+
127
+ def criterion_refs(value: object) -> list[str]:
128
+ """The criterion ids a step's `acceptance` names, once each, in written order."""
129
+ found: list[str] = []
130
+ for entry in value if isinstance(value, list) else [value]:
131
+ if entry is None or isinstance(entry, (dict, list)):
132
+ continue
133
+ for token in re.split(r"[\s,;]+", as_text(entry)):
134
+ if CRITERION_ID.match(token) and token not in found:
135
+ found.append(token)
136
+ return found
137
+
138
+
139
+ def _steps_by_criterion(item) -> dict[str, list[str]]:
140
+ named: dict[str, list[str]] = {}
141
+ for index, entry in enumerate(item.items("steps")):
142
+ for ref in criterion_refs(entry.get("acceptance")):
143
+ named.setdefault(ref, []).append(step_key(entry, index))
144
+ return named
145
+
146
+
147
+ def _print_whole(label: str, value: object, fields: list[tuple[str, object]], badge: Text | None = None) -> None:
148
+ """One entry laid out as the board lays it out, label then text then `key: value` lines, but nothing clipped."""
149
+ first, *rest = _block(value)
150
+ _print(Text.assemble(" ", (f"{label:>3}", "bold"), " ", first, *((" ", badge) if badge else ())))
151
+ for line in rest:
152
+ _print(Text(FIELD_INDENT + line if line else ""))
153
+ for key, field in fields:
154
+ lines = _block(field)
155
+ if len(lines) == 1:
156
+ _print(Text.assemble(FIELD_INDENT, (f"{key}:", "dim"), " ", lines[0]))
157
+ continue
158
+ _print(Text.assemble(FIELD_INDENT, (f"{key}:", "dim")))
159
+ for line in lines:
160
+ _print(Text(FIELD_INDENT + INDENT + line if line else ""))
161
+
162
+
163
+ def _render_acceptance(item, asked: bool) -> None:
164
+ criteria = _plan_list(item, "acceptance")
165
+ named = _steps_by_criterion(item)
166
+ if not criteria and not asked:
167
+ return
168
+ count = f"({len(criteria)})" if criteria else "(none in this plan)"
169
+ _print(Text.assemble("\n", ("Acceptance", "bold cyan"), " ", (count, "dim")))
170
+ ids: set[str] = set()
171
+ for index, entry in enumerate(criteria):
172
+ if not isinstance(entry, dict):
173
+ _print_whole(str(index), entry, [])
174
+ continue
175
+ text_key = next((key for key in CRITERION_TEXT if key in entry), None)
176
+ status = entry.get("status")
177
+ badge = Text(as_text(status), style=TONE_STYLE[tone_of("acceptance", as_text(status))]) if status else None
178
+ rest = [(key, value) for key, value in entry.items() if key not in ("id", "status", text_key)]
179
+ # verify first, the way a reader checks a criterion; any other key after it, as written.
180
+ fields = sorted(rest, key=lambda pair: pair[0] != "verify")
181
+ label = str(index) if entry.get("id") is None else str(entry["id"])
182
+ if entry.get("id") is not None:
183
+ ids.add(label)
184
+ fields.append(("steps", key_runs(named.get(label, []))))
185
+ _print_whole(label, entry.get(text_key) if text_key else "?", fields, badge)
186
+ unknown = [f"{ref} (step {key_runs(keys)})" for ref, keys in named.items() if ref not in ids]
187
+ if unknown:
188
+ _print(Text.assemble((" steps name criteria this plan does not have: ", "yellow"), "; ".join(unknown)))
189
+
190
+
191
+ def _render_non_goals(item, asked: bool) -> None:
192
+ goals = _plan_list(item, "non_goals")
193
+ if not goals and not asked:
194
+ return
195
+ count = f"({len(goals)})" if goals else "(none in this plan)"
196
+ _print(Text.assemble("\n", ("Non-goals", "bold cyan"), " ", (count, "dim")))
197
+ for index, entry in enumerate(goals):
198
+ _print_whole(str(index), entry, [])
199
+
200
+
201
+ def _count_plan_lists(item) -> None:
202
+ """The board stays short: one line per plan-level list, saying how to print it whole."""
203
+ for name in PLAN_LISTS:
204
+ entries = _plan_list(item, name)
205
+ if entries:
206
+ _print(
207
+ Text.assemble(
208
+ "\n", (SECTION_TITLES[name], "bold cyan"), (f" ({len(entries)}) in full: --section {name}", "dim")
209
+ )
210
+ )
211
+
212
+
213
+ @click.command(
214
+ "show",
215
+ help="Read one exec-plan in the terminal, or one of its steps in full with --step. "
216
+ "The board counts the plan's acceptance criteria and non-goals; --section acceptance or "
217
+ "--section non_goals prints them in full.",
218
+ )
219
+ @harness_option
220
+ @click.argument("plan_id")
221
+ @click.option(
222
+ "--section",
223
+ "sections",
224
+ multiple=True,
225
+ type=click.Choice(SHOW_SECTIONS),
226
+ help="Only print some sections. acceptance prints each criterion whole (id, criterion, verify, the steps "
227
+ "naming it); non_goals prints each non-goal whole.",
228
+ )
229
+ @click.option(
230
+ "--full",
231
+ is_flag=True,
232
+ help="Print notes verbatim instead of clipping to one line, and acceptance and non_goals in full.",
233
+ )
234
+ @click.option(
235
+ "--step",
236
+ "step_id",
237
+ metavar="ID",
238
+ help="Print only this step, every field as written: what, verify, note, acceptance and evidence unclipped.",
239
+ )
240
+ def show(harness_path, plan_id, sections, full, step_id):
241
+ item = find_plan(find_manifest(harness_path), plan_id)
242
+ if step_id is not None:
243
+ if sections:
244
+ raise click.UsageError("--step prints one step; leave out --section.")
245
+ _print_step(item, step_id)
246
+ return
247
+
248
+ p = item.progress()
249
+
250
+ console.print(
251
+ Panel(
252
+ " ".join(str(item.raw.get("goal") or "").split()),
253
+ title=f"[bold]{item.id}[/] [dim]({item.area})[/]",
254
+ subtitle=f"{bar(p['steps_done'], p['steps_total'])} {p['steps_done']}/{p['steps_total']} steps "
255
+ f"debt: {p['debt_open']} questions: {p['questions_open']}",
256
+ border_style="cyan",
257
+ )
258
+ )
259
+
260
+ for name in sections or (SHOW_SECTIONS if full else SECTIONS):
261
+ if name == "acceptance":
262
+ _render_acceptance(item, asked=bool(sections))
263
+ continue
264
+ if name == "non_goals":
265
+ _render_non_goals(item, asked=bool(sections))
266
+ continue
267
+ entries = item.items(name)
268
+ if not entries:
269
+ continue
270
+ console.print(f"\n[bold cyan]{SECTION_TITLES[name]}[/] [dim]({len(entries)})[/]")
271
+ for index, entry in enumerate(entries):
272
+ _render_item(name, index, entry, full)
273
+ if not sections and not full:
274
+ _count_plan_lists(item)
@@ -2,12 +2,13 @@ from __future__ import annotations
2
2
 
3
3
  import rich_click as click
4
4
 
5
- from evo_cli.commands.harness._dag import plan_repo_graph, plan_step_graph, seam_graph, step_key
5
+ from evo_cli.commands.harness._dag import plan_repo_graph, plan_step_graph, seam_graph, step_depends, step_key
6
6
  from evo_cli.commands.harness._model import cluster, find_plan, load_plans, load_seams, step_title, tone_of
7
7
  from evo_cli.commands.harness._paths import find_manifest, git, harness_option
8
8
  from evo_cli.commands.harness._render import (
9
9
  TONE_STYLE,
10
10
  bar,
11
+ key_runs,
11
12
  one_line,
12
13
  print_graph,
13
14
  print_warnings,
@@ -144,18 +145,21 @@ def step_board(item, highlight=None) -> None:
144
145
  console.print("[dim] This plan has no steps yet.[/]")
145
146
  return
146
147
 
147
- width = title_width()
148
- grid = table("", "#", "REPO", "STATUS", "STEP")
148
+ width = title_width(reserved=38)
149
+ grid = table("", "#", "REPO", "STATUS", "DEPENDS ON", "STEP")
149
150
  for index, entry in enumerate(steps):
150
151
  status = entry.get("status")
151
152
  style = TONE_STYLE[tone_of("steps", status)]
152
153
  key = step_key(entry, index)
153
154
  marker = "[bold cyan]>[/]" if highlight is not None and key == str(highlight) else " "
155
+ # Nearly every step is blocking, so only the exception is marked.
156
+ optional = " [dim](non-blocking)[/]" if entry.get("blocking") is False else ""
154
157
  grid.add_row(
155
158
  marker,
156
159
  f"[bold]{key}[/]",
157
160
  str(entry.get("repo") or "-"),
158
161
  f"[{style}]{status or '-'}[/]",
159
- step_title(entry, width),
162
+ key_runs(step_depends(entry)),
163
+ step_title(entry, width) + optional,
160
164
  )
161
165
  console.print(grid)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: evo_cli
3
- Version: 0.29.2
3
+ Version: 0.31.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
@@ -415,7 +415,11 @@ evo harness serve # dashboard on http://127.0.0.1:8788
415
415
  evo harness repos # repos in the manifest, with branch and dirty state
416
416
  evo harness seams # contract seams; --graph for the owner -> consumer DAG
417
417
  evo harness plans # progress across every exec-plan
418
- evo harness show <plan>
418
+ evo harness show <plan> # every section, open questions with status and blocking
419
+ evo harness show <plan> --step 3 # one step in full: what, verify, note, acceptance, evidence
420
+ evo harness show <plan> --section acceptance # each criterion in full: id, criterion, verify, steps naming it
421
+ evo harness show <plan> --section non_goals # each non-goal in full
422
+ evo harness step <plan> --no-input # the step board: repo, status, depends_on, title
419
423
  evo harness check <plan> [--fetch] # what the plan claims vs what git says
420
424
  evo harness graph <plan>:steps # the same DAG as an adjacency list
421
425
  evo harness clone # clone every declared repo into its declared path
@@ -133,6 +133,7 @@ tests/test_harness.py
133
133
  tests/test_harness_clone.py
134
134
  tests/test_harness_dag.py
135
135
  tests/test_harness_hub.py
136
+ tests/test_harness_show.py
136
137
  tests/test_image.py
137
138
  tests/test_mcp.py
138
139
  tests/test_ngrok.py