evo-cli 0.14.0__tar.gz → 0.14.2__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 (107) hide show
  1. {evo_cli-0.14.0 → evo_cli-0.14.2}/PKG-INFO +58 -1
  2. {evo_cli-0.14.0 → evo_cli-0.14.2}/README.md +57 -0
  3. evo_cli-0.14.2/evo_cli/VERSION +1 -0
  4. evo_cli-0.14.2/evo_cli/commands/harness/__init__.py +29 -0
  5. evo_cli-0.14.2/evo_cli/commands/harness/_dag.py +407 -0
  6. evo_cli-0.14.2/evo_cli/commands/harness/_git.py +210 -0
  7. evo_cli-0.14.2/evo_cli/commands/harness/_model.py +274 -0
  8. evo_cli-0.14.2/evo_cli/commands/harness/_mutate.py +95 -0
  9. evo_cli-0.14.0/evo_cli/commands/harness.py → evo_cli-0.14.2/evo_cli/commands/harness/_paths.py +62 -93
  10. evo_cli-0.14.2/evo_cli/commands/harness/_render.py +89 -0
  11. evo_cli-0.14.2/evo_cli/commands/harness/_server.py +165 -0
  12. evo_cli-0.14.2/evo_cli/commands/harness/check.py +86 -0
  13. evo_cli-0.14.2/evo_cli/commands/harness/edit.py +161 -0
  14. evo_cli-0.14.2/evo_cli/commands/harness/pull.py +74 -0
  15. evo_cli-0.14.2/evo_cli/commands/harness/serve.py +47 -0
  16. evo_cli-0.14.2/evo_cli/commands/harness/show.py +65 -0
  17. evo_cli-0.14.2/evo_cli/commands/harness/views.py +155 -0
  18. evo_cli-0.14.2/evo_cli/commands/harness/web/assets/index-BpeLRwRd.js +242 -0
  19. evo_cli-0.14.2/evo_cli/commands/harness/web/assets/index-CiUsumT3.css +1 -0
  20. evo_cli-0.14.2/evo_cli/commands/harness/web/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
  21. evo_cli-0.14.2/evo_cli/commands/harness/web/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
  22. evo_cli-0.14.2/evo_cli/commands/harness/web/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
  23. evo_cli-0.14.2/evo_cli/commands/harness/web/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
  24. evo_cli-0.14.2/evo_cli/commands/harness/web/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
  25. evo_cli-0.14.2/evo_cli/commands/harness/web/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
  26. evo_cli-0.14.2/evo_cli/commands/harness/web/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
  27. evo_cli-0.14.2/evo_cli/commands/harness/web/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
  28. evo_cli-0.14.2/evo_cli/commands/harness/web/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
  29. evo_cli-0.14.2/evo_cli/commands/harness/web/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
  30. evo_cli-0.14.2/evo_cli/commands/harness/web/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
  31. evo_cli-0.14.2/evo_cli/commands/harness/web/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
  32. evo_cli-0.14.2/evo_cli/commands/harness/web/index.html +14 -0
  33. {evo_cli-0.14.0 → evo_cli-0.14.2}/evo_cli.egg-info/PKG-INFO +58 -1
  34. {evo_cli-0.14.0 → evo_cli-0.14.2}/evo_cli.egg-info/SOURCES.txt +30 -1
  35. {evo_cli-0.14.0 → evo_cli-0.14.2}/pyproject.toml +1 -1
  36. {evo_cli-0.14.0 → evo_cli-0.14.2}/tests/test_harness.py +12 -8
  37. evo_cli-0.14.2/tests/test_harness_dag.py +193 -0
  38. evo_cli-0.14.0/evo_cli/VERSION +0 -1
  39. {evo_cli-0.14.0 → evo_cli-0.14.2}/Containerfile +0 -0
  40. {evo_cli-0.14.0 → evo_cli-0.14.2}/HISTORY.md +0 -0
  41. {evo_cli-0.14.0 → evo_cli-0.14.2}/LICENSE +0 -0
  42. {evo_cli-0.14.0 → evo_cli-0.14.2}/MANIFEST.in +0 -0
  43. {evo_cli-0.14.0 → evo_cli-0.14.2}/evo_cli/__init__.py +0 -0
  44. {evo_cli-0.14.0 → evo_cli-0.14.2}/evo_cli/__main__.py +0 -0
  45. {evo_cli-0.14.0 → evo_cli-0.14.2}/evo_cli/base.py +0 -0
  46. {evo_cli-0.14.0 → evo_cli-0.14.2}/evo_cli/cli.py +0 -0
  47. {evo_cli-0.14.0 → evo_cli-0.14.2}/evo_cli/commands/__init__.py +0 -0
  48. {evo_cli-0.14.0 → evo_cli-0.14.2}/evo_cli/commands/claude_code.py +0 -0
  49. {evo_cli-0.14.0 → evo_cli-0.14.2}/evo_cli/commands/cloudflare.py +0 -0
  50. {evo_cli-0.14.0 → evo_cli-0.14.2}/evo_cli/commands/cred.py +0 -0
  51. {evo_cli-0.14.0 → evo_cli-0.14.2}/evo_cli/commands/download.py +0 -0
  52. {evo_cli-0.14.0 → evo_cli-0.14.2}/evo_cli/commands/fix_claude.py +0 -0
  53. {evo_cli-0.14.0 → evo_cli-0.14.2}/evo_cli/commands/gdrive.py +0 -0
  54. {evo_cli-0.14.0 → evo_cli-0.14.2}/evo_cli/commands/gh.py +0 -0
  55. {evo_cli-0.14.0 → evo_cli-0.14.2}/evo_cli/commands/hwid.py +0 -0
  56. {evo_cli-0.14.0 → evo_cli-0.14.2}/evo_cli/commands/hwid_reset.py +0 -0
  57. {evo_cli-0.14.0 → evo_cli-0.14.2}/evo_cli/commands/localproxy.py +0 -0
  58. {evo_cli-0.14.0 → evo_cli-0.14.2}/evo_cli/commands/mcp.py +0 -0
  59. {evo_cli-0.14.0 → evo_cli-0.14.2}/evo_cli/commands/miniconda.py +0 -0
  60. {evo_cli-0.14.0 → evo_cli-0.14.2}/evo_cli/commands/netcheck.py +0 -0
  61. {evo_cli-0.14.0 → evo_cli-0.14.2}/evo_cli/commands/opencode.py +0 -0
  62. {evo_cli-0.14.0 → evo_cli-0.14.2}/evo_cli/commands/plantuml.py +0 -0
  63. {evo_cli-0.14.0 → evo_cli-0.14.2}/evo_cli/commands/site2s.py +0 -0
  64. {evo_cli-0.14.0 → evo_cli-0.14.2}/evo_cli/commands/ssh.py +0 -0
  65. {evo_cli-0.14.0 → evo_cli-0.14.2}/evo_cli/commands/sysmon.py +0 -0
  66. {evo_cli-0.14.0 → evo_cli-0.14.2}/evo_cli/commands/tts.py +0 -0
  67. {evo_cli-0.14.0 → evo_cli-0.14.2}/evo_cli/commands/update.py +0 -0
  68. {evo_cli-0.14.0 → evo_cli-0.14.2}/evo_cli/commands/wifi.py +0 -0
  69. {evo_cli-0.14.0 → evo_cli-0.14.2}/evo_cli/console.py +0 -0
  70. {evo_cli-0.14.0 → evo_cli-0.14.2}/evo_cli/credentials/__init__.py +0 -0
  71. {evo_cli-0.14.0 → evo_cli-0.14.2}/evo_cli/credentials/doctor.py +0 -0
  72. {evo_cli-0.14.0 → evo_cli-0.14.2}/evo_cli/credentials/google_oauth.py +0 -0
  73. {evo_cli-0.14.0 → evo_cli-0.14.2}/evo_cli/credentials/migrate.py +0 -0
  74. {evo_cli-0.14.0 → evo_cli-0.14.2}/evo_cli/credentials/oauth_flow.py +0 -0
  75. {evo_cli-0.14.0 → evo_cli-0.14.2}/evo_cli/credentials/registry.py +0 -0
  76. {evo_cli-0.14.0 → evo_cli-0.14.2}/evo_cli/credentials/store.py +0 -0
  77. {evo_cli-0.14.0 → evo_cli-0.14.2}/evo_cli/credentials/sync.py +0 -0
  78. {evo_cli-0.14.0 → evo_cli-0.14.2}/evo_cli/mcp_registry.py +0 -0
  79. {evo_cli-0.14.0 → evo_cli-0.14.2}/evo_cli/tts/__init__.py +0 -0
  80. {evo_cli-0.14.0 → evo_cli-0.14.2}/evo_cli/tts/chunking.py +0 -0
  81. {evo_cli-0.14.0 → evo_cli-0.14.2}/evo_cli/tts/core.py +0 -0
  82. {evo_cli-0.14.0 → evo_cli-0.14.2}/evo_cli/tts/creds.py +0 -0
  83. {evo_cli-0.14.0 → evo_cli-0.14.2}/evo_cli/tts/errors.py +0 -0
  84. {evo_cli-0.14.0 → evo_cli-0.14.2}/evo_cli/tts/http.py +0 -0
  85. {evo_cli-0.14.0 → evo_cli-0.14.2}/evo_cli/tts/openai.py +0 -0
  86. {evo_cli-0.14.0 → evo_cli-0.14.2}/evo_cli/tts/player.py +0 -0
  87. {evo_cli-0.14.0 → evo_cli-0.14.2}/evo_cli/tts/vbee.py +0 -0
  88. {evo_cli-0.14.0 → evo_cli-0.14.2}/evo_cli.egg-info/dependency_links.txt +0 -0
  89. {evo_cli-0.14.0 → evo_cli-0.14.2}/evo_cli.egg-info/entry_points.txt +0 -0
  90. {evo_cli-0.14.0 → evo_cli-0.14.2}/evo_cli.egg-info/requires.txt +0 -0
  91. {evo_cli-0.14.0 → evo_cli-0.14.2}/evo_cli.egg-info/top_level.txt +0 -0
  92. {evo_cli-0.14.0 → evo_cli-0.14.2}/setup.cfg +0 -0
  93. {evo_cli-0.14.0 → evo_cli-0.14.2}/tests/__init__.py +0 -0
  94. {evo_cli-0.14.0 → evo_cli-0.14.2}/tests/test_claude_code.py +0 -0
  95. {evo_cli-0.14.0 → evo_cli-0.14.2}/tests/test_cli.py +0 -0
  96. {evo_cli-0.14.0 → evo_cli-0.14.2}/tests/test_console.py +0 -0
  97. {evo_cli-0.14.0 → evo_cli-0.14.2}/tests/test_cred.py +0 -0
  98. {evo_cli-0.14.0 → evo_cli-0.14.2}/tests/test_download.py +0 -0
  99. {evo_cli-0.14.0 → evo_cli-0.14.2}/tests/test_fix_claude.py +0 -0
  100. {evo_cli-0.14.0 → evo_cli-0.14.2}/tests/test_gh.py +0 -0
  101. {evo_cli-0.14.0 → evo_cli-0.14.2}/tests/test_mcp.py +0 -0
  102. {evo_cli-0.14.0 → evo_cli-0.14.2}/tests/test_opencode.py +0 -0
  103. {evo_cli-0.14.0 → evo_cli-0.14.2}/tests/test_plantuml.py +0 -0
  104. {evo_cli-0.14.0 → evo_cli-0.14.2}/tests/test_sysmon.py +0 -0
  105. {evo_cli-0.14.0 → evo_cli-0.14.2}/tests/test_tts.py +0 -0
  106. {evo_cli-0.14.0 → evo_cli-0.14.2}/tests/test_update.py +0 -0
  107. {evo_cli-0.14.0 → evo_cli-0.14.2}/tests/test_wifi.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: evo_cli
3
- Version: 0.14.0
3
+ Version: 0.14.2
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
@@ -234,3 +234,60 @@ to disk and the command warns.
234
234
 
235
235
  The same engine backs the `evo-tts` MCP server in
236
236
  [agent-skills](https://github.com/maycuatroi1/agent-skills), which gives an agent a `speak` tool.
237
+
238
+ ## `evo harness` - read a repo cluster
239
+
240
+ A harness is a repo that describes a *cluster* of repos: `harness.yaml` lists them, `contracts.yaml`
241
+ declares the seams between them (who owns what, who consumes it, what verifies it), and `plans/`
242
+ holds exec-plans for changes that span several repos at once.
243
+
244
+ `evo harness` finds that repo by walking up from the current directory to a `harness.yaml`, falling
245
+ back to `~/.claude/harness/registry.json`, so every command works with no arguments from anywhere
246
+ inside the cluster. Pass `--harness PATH` to override.
247
+
248
+ ```bash
249
+ evo harness serve # dashboard on http://127.0.0.1:8788
250
+ evo harness repos # repos in the manifest, with branch and dirty state
251
+ evo harness seams # contract seams; --graph for the owner -> consumer DAG
252
+ evo harness plans # progress across every exec-plan
253
+ evo harness show <plan>
254
+ evo harness check <plan> [--fetch] # what the plan claims vs what git says
255
+ evo harness graph <plan>:steps # the same DAG as an adjacency list
256
+ evo harness pull # fast-forward every repo
257
+ ```
258
+
259
+ ### The dashboard
260
+
261
+ `serve` draws every dependency in the cluster as a DAG: seam ownership between repos, repo merge
262
+ order inside a plan, and step order built from `depends_on` / `depends_on_step` / `blocked_by` /
263
+ `blocks`. It detects cycles (Tarjan) and reports them, because a cycle means no merge order
264
+ satisfies every seam - which is exactly the thing a plan gets wrong silently.
265
+
266
+ Every graph has a **table view** beside it. A node-link diagram conveys nothing to a screen reader
267
+ and cannot be pasted into a document, so the adjacency list is a first-class view rather than a
268
+ fallback, and the terminal gets the same data from `evo harness graph`.
269
+
270
+ The dashboard is **read-only**. Plan YAML is folded block scalars and hand-written prose; re-emitting
271
+ it with a YAML dumper would destroy the formatting. Writes go through the CLI, which splices the one
272
+ line it needs and refuses to save if the reparsed file is not exactly the intended change:
273
+
274
+ ```bash
275
+ evo harness step <plan> 3 done --note "..." # keyed by the step's `id` (or `order`)
276
+ evo harness debt <plan> 0 fixed
277
+ evo harness question <plan> 1 answered
278
+ evo harness repo <plan> 2 merged
279
+ ```
280
+
281
+ The server is stdlib `http.server`, so `serve` needs no dependency beyond what `pip install evo_cli`
282
+ already brings.
283
+
284
+ ### Building the dashboard bundle
285
+
286
+ Release wheels ship the built bundle, so users never need Node. Only changing the UI does:
287
+
288
+ ```bash
289
+ cd web && npm install && npm run build
290
+ ```
291
+
292
+ That writes `evo_cli/commands/harness/web/`, which is committed. `npm run dev` serves the UI on
293
+ :5178 and proxies `/api` to a running `evo harness serve` for a live-reload loop.
@@ -206,3 +206,60 @@ to disk and the command warns.
206
206
 
207
207
  The same engine backs the `evo-tts` MCP server in
208
208
  [agent-skills](https://github.com/maycuatroi1/agent-skills), which gives an agent a `speak` tool.
209
+
210
+ ## `evo harness` - read a repo cluster
211
+
212
+ A harness is a repo that describes a *cluster* of repos: `harness.yaml` lists them, `contracts.yaml`
213
+ declares the seams between them (who owns what, who consumes it, what verifies it), and `plans/`
214
+ holds exec-plans for changes that span several repos at once.
215
+
216
+ `evo harness` finds that repo by walking up from the current directory to a `harness.yaml`, falling
217
+ back to `~/.claude/harness/registry.json`, so every command works with no arguments from anywhere
218
+ inside the cluster. Pass `--harness PATH` to override.
219
+
220
+ ```bash
221
+ evo harness serve # dashboard on http://127.0.0.1:8788
222
+ evo harness repos # repos in the manifest, with branch and dirty state
223
+ evo harness seams # contract seams; --graph for the owner -> consumer DAG
224
+ evo harness plans # progress across every exec-plan
225
+ evo harness show <plan>
226
+ evo harness check <plan> [--fetch] # what the plan claims vs what git says
227
+ evo harness graph <plan>:steps # the same DAG as an adjacency list
228
+ evo harness pull # fast-forward every repo
229
+ ```
230
+
231
+ ### The dashboard
232
+
233
+ `serve` draws every dependency in the cluster as a DAG: seam ownership between repos, repo merge
234
+ order inside a plan, and step order built from `depends_on` / `depends_on_step` / `blocked_by` /
235
+ `blocks`. It detects cycles (Tarjan) and reports them, because a cycle means no merge order
236
+ satisfies every seam - which is exactly the thing a plan gets wrong silently.
237
+
238
+ Every graph has a **table view** beside it. A node-link diagram conveys nothing to a screen reader
239
+ and cannot be pasted into a document, so the adjacency list is a first-class view rather than a
240
+ fallback, and the terminal gets the same data from `evo harness graph`.
241
+
242
+ The dashboard is **read-only**. Plan YAML is folded block scalars and hand-written prose; re-emitting
243
+ it with a YAML dumper would destroy the formatting. Writes go through the CLI, which splices the one
244
+ line it needs and refuses to save if the reparsed file is not exactly the intended change:
245
+
246
+ ```bash
247
+ evo harness step <plan> 3 done --note "..." # keyed by the step's `id` (or `order`)
248
+ evo harness debt <plan> 0 fixed
249
+ evo harness question <plan> 1 answered
250
+ evo harness repo <plan> 2 merged
251
+ ```
252
+
253
+ The server is stdlib `http.server`, so `serve` needs no dependency beyond what `pip install evo_cli`
254
+ already brings.
255
+
256
+ ### Building the dashboard bundle
257
+
258
+ Release wheels ship the built bundle, so users never need Node. Only changing the UI does:
259
+
260
+ ```bash
261
+ cd web && npm install && npm run build
262
+ ```
263
+
264
+ That writes `evo_cli/commands/harness/web/`, which is committed. `npm run dev` serves the UI on
265
+ :5178 and proxies `/api` to a running `evo harness serve` for a live-reload loop.
@@ -0,0 +1 @@
1
+ 0.14.2
@@ -0,0 +1,29 @@
1
+ import rich_click as click
2
+
3
+ from evo_cli.commands.harness.check import check
4
+ from evo_cli.commands.harness.edit import debt, question, repo, step
5
+ from evo_cli.commands.harness.pull import pull
6
+ from evo_cli.commands.harness.serve import serve
7
+ from evo_cli.commands.harness.show import show
8
+ from evo_cli.commands.harness.views import graph, plans, repos, seams
9
+
10
+
11
+ @click.group("harness")
12
+ def harness_group():
13
+ """Read and track a repo cluster: its manifest, its contract seams, its exec-plans.
14
+
15
+ \b
16
+ evo harness serve dashboard with every dependency drawn as a DAG
17
+ evo harness repos repos in the manifest, with git state
18
+ evo harness seams contract seams: owner, consumers, verify command
19
+ evo harness plans progress across every exec-plan
20
+ evo harness show <plan> read one plan
21
+ evo harness graph <plan> print a DAG as an adjacency list
22
+ evo harness check <plan> check what the plan claims against real git
23
+ evo harness step <plan> 3 done mark a step
24
+ evo harness pull fast-forward every repo
25
+ """
26
+
27
+
28
+ for command in (serve, repos, seams, plans, show, graph, check, step, debt, question, repo, pull):
29
+ harness_group.add_command(command)
@@ -0,0 +1,407 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from evo_cli.commands.harness._model import Plan, cluster, load_seams, tone_of
6
+
7
+ CLUSTER_REPO = "__cluster__"
8
+
9
+
10
+ def _strongly_connected(nodes: list[str], edges: list[tuple[str, str]]) -> list[list[str]]:
11
+ """Tarjan. Returns components of size > 1 plus any self-loop, i.e. every real cycle."""
12
+ adjacency: dict[str, list[str]] = {n: [] for n in nodes}
13
+ for source, target in edges:
14
+ if source in adjacency and target in adjacency:
15
+ adjacency[source].append(target)
16
+
17
+ index_of: dict[str, int] = {}
18
+ low: dict[str, int] = {}
19
+ on_stack: dict[str, bool] = {}
20
+ stack: list[str] = []
21
+ counter = [0]
22
+ found: list[list[str]] = []
23
+
24
+ def visit(root: str) -> None:
25
+ work = [(root, 0)]
26
+ while work:
27
+ node, child = work[-1]
28
+ if child == 0:
29
+ index_of[node] = low[node] = counter[0]
30
+ counter[0] += 1
31
+ stack.append(node)
32
+ on_stack[node] = True
33
+ recursed = False
34
+ neighbours = adjacency[node]
35
+ while child < len(neighbours):
36
+ nxt = neighbours[child]
37
+ child += 1
38
+ if nxt not in index_of:
39
+ work[-1] = (node, child)
40
+ work.append((nxt, 0))
41
+ recursed = True
42
+ break
43
+ if on_stack.get(nxt):
44
+ low[node] = min(low[node], index_of[nxt])
45
+ if recursed:
46
+ continue
47
+ work[-1] = (node, child)
48
+ if low[node] == index_of[node]:
49
+ component = []
50
+ while True:
51
+ top = stack.pop()
52
+ on_stack[top] = False
53
+ component.append(top)
54
+ if top == node:
55
+ break
56
+ if len(component) > 1 or (node, node) in edges:
57
+ found.append(sorted(component))
58
+ work.pop()
59
+ if work:
60
+ parent = work[-1][0]
61
+ low[parent] = min(low[parent], low[node])
62
+
63
+ for node in nodes:
64
+ if node not in index_of:
65
+ visit(node)
66
+ return found
67
+
68
+
69
+ def _rank(nodes: list[str], edges: list[tuple[str, str]], cyclic: set[str]) -> dict[str, int]:
70
+ """Longest-path layering over the acyclic part. Nodes inside a cycle share their entry rank."""
71
+ live = [(s, t) for s, t in edges if s not in cyclic or t not in cyclic]
72
+ incoming: dict[str, int] = {n: 0 for n in nodes}
73
+ outgoing: dict[str, list[str]] = {n: [] for n in nodes}
74
+ for source, target in live:
75
+ if source in outgoing and target in incoming:
76
+ outgoing[source].append(target)
77
+ incoming[target] += 1
78
+ rank = {n: 0 for n in nodes}
79
+ queue = [n for n in nodes if incoming[n] == 0]
80
+ seen = 0
81
+ while queue:
82
+ node = queue.pop(0)
83
+ seen += 1
84
+ for target in outgoing[node]:
85
+ rank[target] = max(rank[target], rank[node] + 1)
86
+ incoming[target] -= 1
87
+ if incoming[target] == 0:
88
+ queue.append(target)
89
+ return rank
90
+
91
+
92
+ def _finish(nodes: list[dict], edges: list[dict], warnings: list[dict]) -> dict:
93
+ ids = [n["id"] for n in nodes]
94
+ pairs = [(e["source"], e["target"]) for e in edges]
95
+ cycles = _strongly_connected(ids, pairs)
96
+ cyclic = {n for component in cycles for n in component}
97
+ ranks = _rank(ids, pairs, cyclic)
98
+
99
+ for node in nodes:
100
+ node["rank"] = ranks.get(node["id"], 0)
101
+ node["inCycle"] = node["id"] in cyclic
102
+ for edge in edges:
103
+ both = edge["source"] in cyclic and edge["target"] in cyclic
104
+ edge["inCycle"] = both and any(
105
+ edge["source"] in component and edge["target"] in component for component in cycles
106
+ )
107
+
108
+ if cycles:
109
+ for component in cycles:
110
+ warnings.append(
111
+ {
112
+ "level": "warn",
113
+ "text": f"Cycle between {' -> '.join(component)} -> {component[0]}. Merge order is undefined here.",
114
+ }
115
+ )
116
+ return {
117
+ "nodes": nodes,
118
+ "edges": edges,
119
+ "cycles": cycles,
120
+ "acyclic": not cycles,
121
+ "warnings": warnings,
122
+ "depth": (max(ranks.values()) + 1) if ranks else 0,
123
+ }
124
+
125
+
126
+ def seam_graph(manifest_path: Path) -> dict:
127
+ info = cluster(manifest_path)
128
+ seams = load_seams(manifest_path)
129
+ known = {r["name"]: r for r in info["repos"]}
130
+
131
+ order: list[str] = []
132
+ for seam in seams:
133
+ for name in [seam["owner"], *seam["consumers"]]:
134
+ if name and name not in order:
135
+ order.append(name)
136
+ for name in known:
137
+ if name not in order:
138
+ order.append(name)
139
+
140
+ owned: dict[str, int] = {}
141
+ consumed: dict[str, int] = {}
142
+ for seam in seams:
143
+ owned[seam["owner"]] = owned.get(seam["owner"], 0) + 1
144
+ for name in seam["consumers"]:
145
+ consumed[name] = consumed.get(name, 0) + 1
146
+
147
+ nodes = []
148
+ for name in order:
149
+ repo = known.get(name)
150
+ nodes.append(
151
+ {
152
+ "id": name,
153
+ "label": name,
154
+ "kind": "repo" if repo else "external",
155
+ "tone": "idle" if repo and repo["present"] else ("bad" if repo else "warn"),
156
+ "meta": {
157
+ "role": repo["role"] if repo else "outside the manifest",
158
+ "path": repo["path"] if repo else "",
159
+ "present": bool(repo and repo["present"]),
160
+ "note": repo["note"] if repo else "",
161
+ "owns": owned.get(name, 0),
162
+ "consumes": consumed.get(name, 0),
163
+ },
164
+ }
165
+ )
166
+
167
+ edges = []
168
+ for seam in seams:
169
+ for consumer in seam["consumers"]:
170
+ if consumer == seam["owner"]:
171
+ continue
172
+ edges.append(
173
+ {
174
+ "id": f"{seam['name']}:{seam['owner']}->{consumer}",
175
+ "source": seam["owner"],
176
+ "target": consumer,
177
+ "label": seam["name"],
178
+ "kind": seam["kind"],
179
+ # Blocking is a stronger contract, not a fault, so it reads as a solid line
180
+ # rather than a red one. Red stays reserved for something actually wrong.
181
+ "tone": "idle",
182
+ "dashed": not seam["blocking"],
183
+ "meta": {
184
+ "seam": seam["name"],
185
+ "blocking": seam["blocking"],
186
+ "verify": seam["verify"],
187
+ "source": seam["source"],
188
+ "mirrors": seam["mirrors"],
189
+ },
190
+ }
191
+ )
192
+
193
+ warnings = []
194
+ for seam in seams:
195
+ if seam["owner"] and seam["owner"] not in known:
196
+ warnings.append(
197
+ {
198
+ "level": "warn",
199
+ "text": f"Seam {seam['name']!r} is owned by {seam['owner']!r}, absent from harness.yaml.",
200
+ }
201
+ )
202
+ if not seam["verify"]:
203
+ warnings.append({"level": "warn", "text": f"Seam {seam['name']!r} declares no verify command."})
204
+ return _finish(nodes, edges, warnings)
205
+
206
+
207
+ def plan_repo_graph(plan: Plan) -> dict:
208
+ entries = plan.items("repos")
209
+ names = [str(e.get("repo") or f"repo-{i}") for i, e in enumerate(entries)]
210
+
211
+ nodes = []
212
+ for index, entry in enumerate(entries):
213
+ status = str(entry.get("status") or "")
214
+ nodes.append(
215
+ {
216
+ "id": names[index],
217
+ "label": names[index],
218
+ "kind": "repo",
219
+ "tone": tone_of("repos", status),
220
+ "meta": {
221
+ "index": index,
222
+ "status": status,
223
+ "order": entry.get("order"),
224
+ "branch": str(entry.get("branch") or ""),
225
+ "note": " ".join(str(entry.get("note") or "").split()),
226
+ },
227
+ }
228
+ )
229
+
230
+ edges = []
231
+ warnings = []
232
+ for index, entry in enumerate(entries):
233
+ depends = entry.get("depends_on") or []
234
+ if isinstance(depends, str):
235
+ depends = [depends]
236
+ for dependency in depends:
237
+ dependency = str(dependency)
238
+ if dependency not in names:
239
+ warnings.append(
240
+ {
241
+ "level": "warn",
242
+ "text": f"{names[index]} depends_on {dependency!r}, which this plan never lists.",
243
+ }
244
+ )
245
+ continue
246
+ edges.append(
247
+ {
248
+ "id": f"dep:{dependency}->{names[index]}",
249
+ "source": dependency,
250
+ "target": names[index],
251
+ "label": "merges before",
252
+ "kind": "depends_on",
253
+ "tone": "ok"
254
+ if tone_of("repos", str(entries[names.index(dependency)].get("status") or "")) == "ok"
255
+ else "idle",
256
+ "dashed": False,
257
+ "meta": {},
258
+ }
259
+ )
260
+
261
+ declared = {name: entries[i].get("order") for i, name in enumerate(names)}
262
+ for edge in edges:
263
+ before, after = declared.get(edge["source"]), declared.get(edge["target"])
264
+ if isinstance(before, int) and isinstance(after, int) and before >= after:
265
+ warnings.append(
266
+ {
267
+ "level": "error",
268
+ "text": f"{edge['target']} depends on {edge['source']} but declares order {after} <= {before}. "
269
+ "The declared order contradicts the dependency.",
270
+ }
271
+ )
272
+ return _finish(nodes, edges, warnings)
273
+
274
+
275
+ FORWARD_KEYS = ("depends_on", "depends_on_step", "blocked_by")
276
+ BACKWARD_KEYS = ("blocks",)
277
+
278
+
279
+ def step_key(entry: dict, index: int) -> str:
280
+ """Plans key their steps by `id` or by `order`, never both. Dependencies quote that key."""
281
+ for name in ("id", "order"):
282
+ if entry.get(name) is not None:
283
+ return str(entry[name])
284
+ return str(index)
285
+
286
+
287
+ def _as_list(value) -> list:
288
+ if value is None:
289
+ return []
290
+ if isinstance(value, (list, tuple)):
291
+ return list(value)
292
+ return [value]
293
+
294
+
295
+ def plan_step_graph(plan: Plan) -> dict:
296
+ steps = plan.items("steps")
297
+ repo_entries = plan.items("repos")
298
+ repo_names = [str(e.get("repo") or "") for e in repo_entries]
299
+ repo_depends: dict[str, list[str]] = {}
300
+ for index, entry in enumerate(repo_entries):
301
+ depends = [str(d) for d in _as_list(entry.get("depends_on"))]
302
+ repo_depends[repo_names[index]] = [d for d in depends if d in repo_names]
303
+
304
+ keys = [step_key(entry, index) for index, entry in enumerate(steps)]
305
+ node_ids = [f"step-{key}" for key in keys]
306
+ by_key = dict(zip(keys, node_ids))
307
+
308
+ nodes = []
309
+ lanes: dict[str, list[str]] = {}
310
+ for index, entry in enumerate(steps):
311
+ repo = str(entry.get("repo") or "unassigned")
312
+ status = str(entry.get("status") or "")
313
+ lanes.setdefault(repo, []).append(node_ids[index])
314
+ nodes.append(
315
+ {
316
+ "id": node_ids[index],
317
+ "label": keys[index],
318
+ "kind": "step",
319
+ "tone": tone_of("steps", status),
320
+ "meta": {
321
+ "index": index,
322
+ "key": keys[index],
323
+ "repo": repo,
324
+ "status": status,
325
+ "blocking": bool(entry.get("blocking")),
326
+ "what": " ".join(str(entry.get("what") or entry.get("issue") or "").split()),
327
+ "verify": str(entry.get("verify") or ""),
328
+ "note": " ".join(str(entry.get("note") or "").split()),
329
+ },
330
+ }
331
+ )
332
+
333
+ edges = []
334
+ warnings = []
335
+ seen: set = set()
336
+
337
+ def add(source: str, target: str, label: str, kind: str) -> None:
338
+ if source == target or (source, target) in seen:
339
+ return
340
+ seen.add((source, target))
341
+ edges.append(
342
+ {
343
+ "id": f"{kind}:{source}->{target}",
344
+ "source": source,
345
+ "target": target,
346
+ "label": label,
347
+ "kind": kind,
348
+ "tone": "idle",
349
+ "dashed": kind != "declared",
350
+ "meta": {},
351
+ }
352
+ )
353
+
354
+ def link(entry: dict, index: int, raw, forward: bool, key_name: str) -> None:
355
+ target_key = str(raw)
356
+ if target_key not in by_key:
357
+ warnings.append(
358
+ {
359
+ "level": "warn",
360
+ "text": f"Step {keys[index]} declares {key_name}: {target_key} - no step carries that id.",
361
+ }
362
+ )
363
+ return
364
+ here, there = node_ids[index], by_key[target_key]
365
+ if forward:
366
+ add(there, here, key_name, "declared")
367
+ else:
368
+ add(here, there, key_name, "declared")
369
+
370
+ declared = False
371
+ for index, entry in enumerate(steps):
372
+ for key_name in FORWARD_KEYS:
373
+ for raw in _as_list(entry.get(key_name)):
374
+ declared = True
375
+ link(entry, index, raw, True, key_name)
376
+ for key_name in BACKWARD_KEYS:
377
+ for raw in _as_list(entry.get(key_name)):
378
+ declared = True
379
+ link(entry, index, raw, False, key_name)
380
+
381
+ if not declared:
382
+ for lane in lanes.values():
383
+ for first, second in zip(lane, lane[1:]):
384
+ add(first, second, "same repo, in order", "sequence")
385
+ cluster_lane = lanes.get(CLUSTER_REPO, [])
386
+ for repo, lane in lanes.items():
387
+ if repo == CLUSTER_REPO or not lane:
388
+ continue
389
+ for dependency in repo_depends.get(repo, []):
390
+ upstream = lanes.get(dependency) or []
391
+ if upstream:
392
+ add(upstream[-1], lane[0], f"{dependency} merges first", "repo-order")
393
+ if cluster_lane:
394
+ add(lane[-1], cluster_lane[0], "cluster wrap-up", "repo-order")
395
+ if steps:
396
+ warnings.append(
397
+ {
398
+ "level": "info",
399
+ "text": "No step declares depends_on, so these edges are inferred from repo merge order and "
400
+ "step numbering. Add depends_on to a step to state the real order.",
401
+ }
402
+ )
403
+ return _finish(nodes, edges, warnings)
404
+
405
+
406
+ def plan_graphs(plan: Plan) -> dict:
407
+ return {"repos": plan_repo_graph(plan), "steps": plan_step_graph(plan)}