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.
- {evo_cli-0.28.0 → evo_cli-0.29.0}/PKG-INFO +24 -2
- {evo_cli-0.28.0 → evo_cli-0.29.0}/README.md +23 -1
- evo_cli-0.29.0/evo_cli/VERSION +1 -0
- {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/commands/harness/__init__.py +5 -1
- evo_cli-0.29.0/evo_cli/commands/harness/_hub.py +337 -0
- evo_cli-0.29.0/evo_cli/commands/harness/_mirror.py +119 -0
- {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/commands/harness/_model.py +29 -0
- evo_cli-0.29.0/evo_cli/commands/harness/_mutate.py +181 -0
- {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/commands/harness/_server.py +1 -1
- {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/commands/harness/check.py +22 -1
- {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/commands/harness/edit.py +32 -16
- {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/commands/openvpn.py +3 -1
- {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/openvpn.py +53 -5
- {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli.egg-info/PKG-INFO +24 -2
- {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli.egg-info/SOURCES.txt +7 -1
- evo_cli-0.29.0/tests/fake_evo_agents.py +252 -0
- evo_cli-0.29.0/tests/fixtures/hub/contract-0.2.0.json +287 -0
- evo_cli-0.29.0/tests/fixtures/hub/mirror-demo.yaml +53 -0
- evo_cli-0.29.0/tests/test_harness_hub.py +727 -0
- {evo_cli-0.28.0 → evo_cli-0.29.0}/tests/test_openvpn.py +44 -3
- evo_cli-0.28.0/evo_cli/VERSION +0 -1
- evo_cli-0.28.0/evo_cli/commands/harness/_mutate.py +0 -118
- {evo_cli-0.28.0 → evo_cli-0.29.0}/Containerfile +0 -0
- {evo_cli-0.28.0 → evo_cli-0.29.0}/HISTORY.md +0 -0
- {evo_cli-0.28.0 → evo_cli-0.29.0}/LICENSE +0 -0
- {evo_cli-0.28.0 → evo_cli-0.29.0}/MANIFEST.in +0 -0
- {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/__init__.py +0 -0
- {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/__main__.py +0 -0
- {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/base.py +0 -0
- {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/cli.py +0 -0
- {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/commands/__init__.py +0 -0
- {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/commands/agent_toy.py +0 -0
- {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/commands/claude_code.py +0 -0
- {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/commands/cloudflare.py +0 -0
- {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/commands/cred.py +0 -0
- {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/commands/download.py +0 -0
- {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/commands/fix_claude.py +0 -0
- {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/commands/gdrive.py +0 -0
- {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/commands/gh.py +0 -0
- {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/commands/harness/_dag.py +0 -0
- {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/commands/harness/_git.py +0 -0
- {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/commands/harness/_paths.py +0 -0
- {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/commands/harness/_render.py +0 -0
- {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/commands/harness/clone.py +0 -0
- {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/commands/harness/pull.py +0 -0
- {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/commands/harness/serve.py +0 -0
- {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/commands/harness/show.py +0 -0
- {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/commands/harness/views.py +0 -0
- {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/commands/harness/web/assets/index-Cs7hXRPJ.css +0 -0
- {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/commands/harness/web/assets/index-CuNOlaH7.js +0 -0
- {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
- {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/commands/harness/web/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
- {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
- {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/commands/harness/web/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
- {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
- {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/commands/harness/web/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
- {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/commands/harness/web/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
- {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
- {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
- {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
- {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
- {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
- {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/commands/harness/web/index.html +0 -0
- {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/commands/hwid.py +0 -0
- {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/commands/hwid_reset.py +0 -0
- {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/commands/image.py +0 -0
- {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/commands/localproxy.py +0 -0
- {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/commands/mcp.py +0 -0
- {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/commands/miniconda.py +0 -0
- {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/commands/netcheck.py +0 -0
- {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/commands/ngrok.py +0 -0
- {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/commands/opencode.py +0 -0
- {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/commands/plantuml.py +0 -0
- {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/commands/route/__init__.py +0 -0
- {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/commands/route/_dns.py +0 -0
- {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/commands/route/_dpi.py +0 -0
- {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/commands/route/_hosts.py +0 -0
- {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/commands/route/_probe.py +0 -0
- {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/commands/route/check.py +0 -0
- {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/commands/route/fix.py +0 -0
- {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/commands/route/probe.py +0 -0
- {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/commands/route/status.py +0 -0
- {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/commands/serp.py +0 -0
- {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/commands/site2s.py +0 -0
- {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/commands/ssh.py +0 -0
- {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/commands/storage.py +0 -0
- {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/commands/sysmon.py +0 -0
- {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/commands/tts.py +0 -0
- {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/commands/update.py +0 -0
- {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/commands/wifi.py +0 -0
- {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/console.py +0 -0
- {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/credentials/__init__.py +0 -0
- {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/credentials/doctor.py +0 -0
- {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/credentials/google_oauth.py +0 -0
- {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/credentials/migrate.py +0 -0
- {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/credentials/oauth_flow.py +0 -0
- {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/credentials/registry.py +0 -0
- {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/credentials/store.py +0 -0
- {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/credentials/sync.py +0 -0
- {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/imaging/__init__.py +0 -0
- {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/imaging/align.py +0 -0
- {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/imaging/core.py +0 -0
- {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/imaging/creds.py +0 -0
- {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/imaging/errors.py +0 -0
- {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/imaging/gemini.py +0 -0
- {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/imaging/ncnn.py +0 -0
- {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/mcp_registry.py +0 -0
- {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/serp/__init__.py +0 -0
- {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/serp/client.py +0 -0
- {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/serp/creds.py +0 -0
- {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/serp/errors.py +0 -0
- {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/serp/install.py +0 -0
- {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/serp/render.py +0 -0
- {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/tts/__init__.py +0 -0
- {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/tts/chunking.py +0 -0
- {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/tts/core.py +0 -0
- {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/tts/creds.py +0 -0
- {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/tts/errors.py +0 -0
- {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/tts/gemini.py +0 -0
- {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/tts/http.py +0 -0
- {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/tts/openai.py +0 -0
- {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/tts/player.py +0 -0
- {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli/tts/vbee.py +0 -0
- {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli.egg-info/dependency_links.txt +0 -0
- {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli.egg-info/entry_points.txt +0 -0
- {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli.egg-info/requires.txt +0 -0
- {evo_cli-0.28.0 → evo_cli-0.29.0}/evo_cli.egg-info/top_level.txt +0 -0
- {evo_cli-0.28.0 → evo_cli-0.29.0}/pyproject.toml +0 -0
- {evo_cli-0.28.0 → evo_cli-0.29.0}/setup.cfg +0 -0
- {evo_cli-0.28.0 → evo_cli-0.29.0}/tests/__init__.py +0 -0
- {evo_cli-0.28.0 → evo_cli-0.29.0}/tests/test_agent_toy.py +0 -0
- {evo_cli-0.28.0 → evo_cli-0.29.0}/tests/test_claude_code.py +0 -0
- {evo_cli-0.28.0 → evo_cli-0.29.0}/tests/test_cli.py +0 -0
- {evo_cli-0.28.0 → evo_cli-0.29.0}/tests/test_console.py +0 -0
- {evo_cli-0.28.0 → evo_cli-0.29.0}/tests/test_cred.py +0 -0
- {evo_cli-0.28.0 → evo_cli-0.29.0}/tests/test_download.py +0 -0
- {evo_cli-0.28.0 → evo_cli-0.29.0}/tests/test_fix_claude.py +0 -0
- {evo_cli-0.28.0 → evo_cli-0.29.0}/tests/test_gdrive.py +0 -0
- {evo_cli-0.28.0 → evo_cli-0.29.0}/tests/test_gh.py +0 -0
- {evo_cli-0.28.0 → evo_cli-0.29.0}/tests/test_harness.py +0 -0
- {evo_cli-0.28.0 → evo_cli-0.29.0}/tests/test_harness_clone.py +0 -0
- {evo_cli-0.28.0 → evo_cli-0.29.0}/tests/test_harness_dag.py +0 -0
- {evo_cli-0.28.0 → evo_cli-0.29.0}/tests/test_image.py +0 -0
- {evo_cli-0.28.0 → evo_cli-0.29.0}/tests/test_mcp.py +0 -0
- {evo_cli-0.28.0 → evo_cli-0.29.0}/tests/test_ngrok.py +0 -0
- {evo_cli-0.28.0 → evo_cli-0.29.0}/tests/test_opencode.py +0 -0
- {evo_cli-0.28.0 → evo_cli-0.29.0}/tests/test_plantuml.py +0 -0
- {evo_cli-0.28.0 → evo_cli-0.29.0}/tests/test_route.py +0 -0
- {evo_cli-0.28.0 → evo_cli-0.29.0}/tests/test_serp.py +0 -0
- {evo_cli-0.28.0 → evo_cli-0.29.0}/tests/test_storage.py +0 -0
- {evo_cli-0.28.0 → evo_cli-0.29.0}/tests/test_sysmon.py +0 -0
- {evo_cli-0.28.0 → evo_cli-0.29.0}/tests/test_tts.py +0 -0
- {evo_cli-0.28.0 → evo_cli-0.29.0}/tests/test_update.py +0 -0
- {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.
|
|
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()}
|