horsegraph 0.1.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.
- horsegraph-0.1.0/.gitignore +5 -0
- horsegraph-0.1.0/LICENSE +202 -0
- horsegraph-0.1.0/PKG-INFO +97 -0
- horsegraph-0.1.0/README.md +76 -0
- horsegraph-0.1.0/examples/NODE-GRAPH.json +51 -0
- horsegraph-0.1.0/pyproject.toml +36 -0
- horsegraph-0.1.0/src/horsegraph/__init__.py +18 -0
- horsegraph-0.1.0/src/horsegraph/__main__.py +2 -0
- horsegraph-0.1.0/src/horsegraph/cli.py +108 -0
- horsegraph-0.1.0/src/horsegraph/graph.py +193 -0
- horsegraph-0.1.0/src/horsegraph/source.py +116 -0
- horsegraph-0.1.0/src/horsegraph/world.py +212 -0
- horsegraph-0.1.0/tests/test_horsegraph.py +132 -0
horsegraph-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
|
|
2
|
+
Apache License
|
|
3
|
+
Version 2.0, January 2004
|
|
4
|
+
http://www.apache.org/licenses/
|
|
5
|
+
|
|
6
|
+
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
|
7
|
+
|
|
8
|
+
1. Definitions.
|
|
9
|
+
|
|
10
|
+
"License" shall mean the terms and conditions for use, reproduction,
|
|
11
|
+
and distribution as defined by Sections 1 through 9 of this document.
|
|
12
|
+
|
|
13
|
+
"Licensor" shall mean the copyright owner or entity authorized by
|
|
14
|
+
the copyright owner that is granting the License.
|
|
15
|
+
|
|
16
|
+
"Legal Entity" shall mean the union of the acting entity and all
|
|
17
|
+
other entities that control, are controlled by, or are under common
|
|
18
|
+
control with that entity. For the purposes of this definition,
|
|
19
|
+
"control" means (i) the power, direct or indirect, to cause the
|
|
20
|
+
direction or management of such entity, whether by contract or
|
|
21
|
+
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
|
22
|
+
outstanding shares, or (iii) beneficial ownership of such entity.
|
|
23
|
+
|
|
24
|
+
"You" (or "Your") shall mean an individual or Legal Entity
|
|
25
|
+
exercising permissions granted by this License.
|
|
26
|
+
|
|
27
|
+
"Source" form shall mean the preferred form for making modifications,
|
|
28
|
+
including but not limited to software source code, documentation
|
|
29
|
+
source, and configuration files.
|
|
30
|
+
|
|
31
|
+
"Object" form shall mean any form resulting from mechanical
|
|
32
|
+
transformation or translation of a Source form, including but
|
|
33
|
+
not limited to compiled object code, generated documentation,
|
|
34
|
+
and conversions to other media types.
|
|
35
|
+
|
|
36
|
+
"Work" shall mean the work of authorship, whether in Source or
|
|
37
|
+
Object form, made available under the License, as indicated by a
|
|
38
|
+
copyright notice that is included in or attached to the work
|
|
39
|
+
(an example is provided in the Appendix below).
|
|
40
|
+
|
|
41
|
+
"Derivative Works" shall mean any work, whether in Source or Object
|
|
42
|
+
form, that is based on (or derived from) the Work and for which the
|
|
43
|
+
editorial revisions, annotations, elaborations, or other modifications
|
|
44
|
+
represent, as a whole, an original work of authorship. For the purposes
|
|
45
|
+
of this License, Derivative Works shall not include works that remain
|
|
46
|
+
separable from, or merely link (or bind by name) to the interfaces of,
|
|
47
|
+
the Work and Derivative Works thereof.
|
|
48
|
+
|
|
49
|
+
"Contribution" shall mean any work of authorship, including
|
|
50
|
+
the original version of the Work and any modifications or additions
|
|
51
|
+
to that Work or Derivative Works thereof, that is intentionally
|
|
52
|
+
submitted to Licensor for inclusion in the Work by the copyright owner
|
|
53
|
+
or by an individual or Legal Entity authorized to submit on behalf of
|
|
54
|
+
the copyright owner. For the purposes of this definition, "submitted"
|
|
55
|
+
means any form of electronic, verbal, or written communication sent
|
|
56
|
+
to the Licensor or its representatives, including but not limited to
|
|
57
|
+
communication on electronic mailing lists, source code control systems,
|
|
58
|
+
and issue tracking systems that are managed by, or on behalf of, the
|
|
59
|
+
Licensor for the purpose of discussing and improving the Work, but
|
|
60
|
+
excluding communication that is conspicuously marked or otherwise
|
|
61
|
+
designated in writing by the copyright owner as "Not a Contribution."
|
|
62
|
+
|
|
63
|
+
"Contributor" shall mean Licensor and any individual or Legal Entity
|
|
64
|
+
on behalf of whom a Contribution has been received by Licensor and
|
|
65
|
+
subsequently incorporated within the Work.
|
|
66
|
+
|
|
67
|
+
2. Grant of Copyright License. Subject to the terms and conditions of
|
|
68
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
69
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
70
|
+
copyright license to reproduce, prepare Derivative Works of,
|
|
71
|
+
publicly display, publicly perform, sublicense, and distribute the
|
|
72
|
+
Work and such Derivative Works in Source or Object form.
|
|
73
|
+
|
|
74
|
+
3. Grant of Patent License. Subject to the terms and conditions of
|
|
75
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
76
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
77
|
+
(except as stated in this section) patent license to make, have made,
|
|
78
|
+
use, offer to sell, sell, import, and otherwise transfer the Work,
|
|
79
|
+
where such license applies only to those patent claims licensable
|
|
80
|
+
by such Contributor that are necessarily infringed by their
|
|
81
|
+
Contribution(s) alone or by combination of their Contribution(s)
|
|
82
|
+
with the Work to which such Contribution(s) was submitted. If You
|
|
83
|
+
institute patent litigation against any entity (including a
|
|
84
|
+
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
|
85
|
+
or a Contribution incorporated within the Work constitutes direct
|
|
86
|
+
or contributory patent infringement, then any patent licenses
|
|
87
|
+
granted to You under this License for that Work shall terminate
|
|
88
|
+
as of the date such litigation is filed.
|
|
89
|
+
|
|
90
|
+
4. Redistribution. You may reproduce and distribute copies of the
|
|
91
|
+
Work or Derivative Works thereof in any medium, with or without
|
|
92
|
+
modifications, and in Source or Object form, provided that You
|
|
93
|
+
meet the following conditions:
|
|
94
|
+
|
|
95
|
+
(a) You must give any other recipients of the Work or
|
|
96
|
+
Derivative Works a copy of this License; and
|
|
97
|
+
|
|
98
|
+
(b) You must cause any modified files to carry prominent notices
|
|
99
|
+
stating that You changed the files; and
|
|
100
|
+
|
|
101
|
+
(c) You must retain, in the Source form of any Derivative Works
|
|
102
|
+
that You distribute, all copyright, patent, trademark, and
|
|
103
|
+
attribution notices from the Source form of the Work,
|
|
104
|
+
excluding those notices that do not pertain to any part of
|
|
105
|
+
the Derivative Works; and
|
|
106
|
+
|
|
107
|
+
(d) If the Work includes a "NOTICE" text file as part of its
|
|
108
|
+
distribution, then any Derivative Works that You distribute must
|
|
109
|
+
include a readable copy of the attribution notices contained
|
|
110
|
+
within such NOTICE file, excluding those notices that do not
|
|
111
|
+
pertain to any part of the Derivative Works, in at least one
|
|
112
|
+
of the following places: within a NOTICE text file distributed
|
|
113
|
+
as part of the Derivative Works; within the Source form or
|
|
114
|
+
documentation, if provided along with the Derivative Works; or,
|
|
115
|
+
within a display generated by the Derivative Works, if and
|
|
116
|
+
wherever such third-party notices normally appear. The contents
|
|
117
|
+
of the NOTICE file are for informational purposes only and
|
|
118
|
+
do not modify the License. You may add Your own attribution
|
|
119
|
+
notices within Derivative Works that You distribute, alongside
|
|
120
|
+
or as an addendum to the NOTICE text from the Work, provided
|
|
121
|
+
that such additional attribution notices cannot be construed
|
|
122
|
+
as modifying the License.
|
|
123
|
+
|
|
124
|
+
You may add Your own copyright statement to Your modifications and
|
|
125
|
+
may provide additional or different license terms and conditions
|
|
126
|
+
for use, reproduction, or distribution of Your modifications, or
|
|
127
|
+
for any such Derivative Works as a whole, provided Your use,
|
|
128
|
+
reproduction, and distribution of the Work otherwise complies with
|
|
129
|
+
the conditions stated in this License.
|
|
130
|
+
|
|
131
|
+
5. Submission of Contributions. Unless You explicitly state otherwise,
|
|
132
|
+
any Contribution intentionally submitted for inclusion in the Work
|
|
133
|
+
by You to the Licensor shall be under the terms and conditions of
|
|
134
|
+
this License, without any additional terms or conditions.
|
|
135
|
+
Notwithstanding the above, nothing herein shall supersede or modify
|
|
136
|
+
the terms of any separate license agreement you may have executed
|
|
137
|
+
with Licensor regarding such Contributions.
|
|
138
|
+
|
|
139
|
+
6. Trademarks. This License does not grant permission to use the trade
|
|
140
|
+
names, trademarks, service marks, or product names of the Licensor,
|
|
141
|
+
except as required for reasonable and customary use in describing the
|
|
142
|
+
origin of the Work and reproducing the content of the NOTICE file.
|
|
143
|
+
|
|
144
|
+
7. Disclaimer of Warranty. Unless required by applicable law or
|
|
145
|
+
agreed to in writing, Licensor provides the Work (and each
|
|
146
|
+
Contributor provides its Contributions) on an "AS IS" BASIS,
|
|
147
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
148
|
+
implied, including, without limitation, any warranties or conditions
|
|
149
|
+
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
|
150
|
+
PARTICULAR PURPOSE. You are solely responsible for determining the
|
|
151
|
+
appropriateness of using or redistributing the Work and assume any
|
|
152
|
+
risks associated with Your exercise of permissions under this License.
|
|
153
|
+
|
|
154
|
+
8. Limitation of Liability. In no event and under no legal theory,
|
|
155
|
+
whether in tort (including negligence), contract, or otherwise,
|
|
156
|
+
unless required by applicable law (such as deliberate and grossly
|
|
157
|
+
negligent acts) or agreed to in writing, shall any Contributor be
|
|
158
|
+
liable to You for damages, including any direct, indirect, special,
|
|
159
|
+
incidental, or consequential damages of any character arising as a
|
|
160
|
+
result of this License or out of the use or inability to use the
|
|
161
|
+
Work (including but not limited to damages for loss of goodwill,
|
|
162
|
+
work stoppage, computer failure or malfunction, or any and all
|
|
163
|
+
other commercial damages or losses), even if such Contributor
|
|
164
|
+
has been advised of the possibility of such damages.
|
|
165
|
+
|
|
166
|
+
9. Accepting Warranty or Additional Liability. While redistributing
|
|
167
|
+
the Work or Derivative Works thereof, You may choose to offer,
|
|
168
|
+
and charge a fee for, acceptance of support, warranty, indemnity,
|
|
169
|
+
or other liability obligations and/or rights consistent with this
|
|
170
|
+
License. However, in accepting such obligations, You may act only
|
|
171
|
+
on Your own behalf and on Your sole responsibility, not on behalf
|
|
172
|
+
of any other Contributor, and only if You agree to indemnify,
|
|
173
|
+
defend, and hold each Contributor harmless for any liability
|
|
174
|
+
incurred by, or claims asserted against, such Contributor by reason
|
|
175
|
+
of your accepting any such warranty or additional liability.
|
|
176
|
+
|
|
177
|
+
END OF TERMS AND CONDITIONS
|
|
178
|
+
|
|
179
|
+
APPENDIX: How to apply the Apache License to your work.
|
|
180
|
+
|
|
181
|
+
To apply the Apache License to your work, attach the following
|
|
182
|
+
boilerplate notice, with the fields enclosed by brackets "[]"
|
|
183
|
+
replaced with your own identifying information. (Don't include
|
|
184
|
+
the brackets!) The text should be enclosed in the appropriate
|
|
185
|
+
comment syntax for the file format. We also recommend that a
|
|
186
|
+
file or class name and description of purpose be included on the
|
|
187
|
+
same "printed page" as the copyright notice for easier
|
|
188
|
+
identification within third-party archives.
|
|
189
|
+
|
|
190
|
+
Copyright [yyyy] [name of copyright owner]
|
|
191
|
+
|
|
192
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
193
|
+
you may not use this file except in compliance with the License.
|
|
194
|
+
You may obtain a copy of the License at
|
|
195
|
+
|
|
196
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
197
|
+
|
|
198
|
+
Unless required by applicable law or agreed to in writing, software
|
|
199
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
200
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
201
|
+
See the License for the specific language governing permissions and
|
|
202
|
+
limitations under the License.
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: horsegraph
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Plan node graphs read through Tiny Horse canonical storage and visualized as ClaySpace worlds.
|
|
5
|
+
Author: Magus
|
|
6
|
+
License-Expression: Apache-2.0
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Keywords: clayspace,dependency-graph,git,planning,tinyhorse
|
|
9
|
+
Classifier: Operating System :: OS Independent
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Topic :: Scientific/Engineering :: Visualization
|
|
12
|
+
Classifier: Topic :: Software Development :: Version Control :: Git
|
|
13
|
+
Requires-Python: >=3.10
|
|
14
|
+
Requires-Dist: clayspace<0.2,>=0.1.3
|
|
15
|
+
Requires-Dist: tinyhorse>=0.2.4
|
|
16
|
+
Provides-Extra: gdrive
|
|
17
|
+
Requires-Dist: tinyhorse[gdrive]>=0.2.4; extra == 'gdrive'
|
|
18
|
+
Provides-Extra: test
|
|
19
|
+
Requires-Dist: pytest>=7; extra == 'test'
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
# horsegraph
|
|
23
|
+
|
|
24
|
+
Plan node graphs (`NODE-GRAPH.json`) read through **Tiny Horse** canonical storage and visualized as **ClaySpace** worlds.
|
|
25
|
+
|
|
26
|
+
- **Tiny Horse** is the source of truth. horsegraph reads the graph from a bound checkout (and can run `tinyhorse doctor` on it), or straight from the canonical bundle in storage (`file:` or `gdrive://`), or from a Drive project by name. When it reads from storage it fetches and verifies the bundle and reads the graph at canonical HEAD, so the view shows what was parked, not unparked local edits.
|
|
27
|
+
- **ClaySpace** draws it. Every node is a ClaySpace Cell, every dependency is a Cell with an arrowhead, and the whole graph is a valid `clayspace.world.snapshot`. The HTML is ClaySpace's own self-contained orbit preview, plus a label, hover-detail and legend overlay.
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pip install horsegraph # add [gdrive] for Drive storage / --project
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Use
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
# from a Tiny Horse-bound checkout (default graph path docs/runner-gateway/NODE-GRAPH.json)
|
|
37
|
+
horsegraph render --checkout . --doctor --out plan.html --world plan.world.json
|
|
38
|
+
|
|
39
|
+
# straight from canonical storage, no checkout needed
|
|
40
|
+
horsegraph render --storage gdrive://FOLDER_ID/DUD3.bundle
|
|
41
|
+
horsegraph render --project "DUD3" # resolved like `tinyhorse claim`
|
|
42
|
+
|
|
43
|
+
# plan views
|
|
44
|
+
horsegraph next --checkout . # "Needs you now" / "Claude starts next"
|
|
45
|
+
horsegraph path --checkout . # critical path to the goal node
|
|
46
|
+
horsegraph check --checkout . # validate; exit 1 on warnings (e.g. done before its deps)
|
|
47
|
+
|
|
48
|
+
# any JSON file, no Tiny Horse
|
|
49
|
+
horsegraph render --file examples/NODE-GRAPH.json
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
`--graph` changes the path inside the repo; `--ref` reads a tag or commit from storage instead of HEAD.
|
|
53
|
+
|
|
54
|
+
The ClaySpace world it writes with `--world` opens with the usual tools (`clayspace world inspect`, `clayspace world preview`).
|
|
55
|
+
|
|
56
|
+
## Reading the 3D view
|
|
57
|
+
|
|
58
|
+
| Shape | Meaning |
|
|
59
|
+
|---|---|
|
|
60
|
+
| low green slab | done |
|
|
61
|
+
| blue block | open, Claude's (agent) node |
|
|
62
|
+
| tall amber tower with a flagpole | open owner action, needs you |
|
|
63
|
+
| cyan | on the critical path to the goal |
|
|
64
|
+
| pink + sphere | goal node |
|
|
65
|
+
| grey, thin | parked (`later`, `after-proof`) |
|
|
66
|
+
|
|
67
|
+
Columns run left to right by dependency depth, and rows group by track. Drag to orbit, scroll to zoom, and hover a label for the node's title, deps and the contract items it advances.
|
|
68
|
+
|
|
69
|
+
## Graph format
|
|
70
|
+
|
|
71
|
+
```json
|
|
72
|
+
{
|
|
73
|
+
"title": "…", "goal": "G-PROOF",
|
|
74
|
+
"tracks": {"MAC": "DUD3 for Mac"},
|
|
75
|
+
"nodes": [
|
|
76
|
+
{"id": "MAC-4", "title": "…", "owner": "agent", "status": "open",
|
|
77
|
+
"deps": ["MAC-1", "MAC-2"], "track": "MAC", "closes": ["C2"]}
|
|
78
|
+
]
|
|
79
|
+
}
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
`owner` is `agent` or `owner`. `status` is `done`, `open`, `later`, `after-proof` or `available-verified`. Extra fields are kept. Cycles, unknown deps and duplicate ids are rejected.
|
|
83
|
+
|
|
84
|
+
## Python
|
|
85
|
+
|
|
86
|
+
```python
|
|
87
|
+
from horsegraph import from_storage, render
|
|
88
|
+
graph, prov = from_storage("file:/srv/git/dud3.bundle")
|
|
89
|
+
print(graph.critical_path(), [n.id for n in graph.ready("owner")])
|
|
90
|
+
render(graph, "plan.html", provenance=prov.to_dict(), subtitle=prov.summary())
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## Release
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
python -m build && twine upload dist/*
|
|
97
|
+
```
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# horsegraph
|
|
2
|
+
|
|
3
|
+
Plan node graphs (`NODE-GRAPH.json`) read through **Tiny Horse** canonical storage and visualized as **ClaySpace** worlds.
|
|
4
|
+
|
|
5
|
+
- **Tiny Horse** is the source of truth. horsegraph reads the graph from a bound checkout (and can run `tinyhorse doctor` on it), or straight from the canonical bundle in storage (`file:` or `gdrive://`), or from a Drive project by name. When it reads from storage it fetches and verifies the bundle and reads the graph at canonical HEAD, so the view shows what was parked, not unparked local edits.
|
|
6
|
+
- **ClaySpace** draws it. Every node is a ClaySpace Cell, every dependency is a Cell with an arrowhead, and the whole graph is a valid `clayspace.world.snapshot`. The HTML is ClaySpace's own self-contained orbit preview, plus a label, hover-detail and legend overlay.
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
pip install horsegraph # add [gdrive] for Drive storage / --project
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## Use
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
# from a Tiny Horse-bound checkout (default graph path docs/runner-gateway/NODE-GRAPH.json)
|
|
16
|
+
horsegraph render --checkout . --doctor --out plan.html --world plan.world.json
|
|
17
|
+
|
|
18
|
+
# straight from canonical storage, no checkout needed
|
|
19
|
+
horsegraph render --storage gdrive://FOLDER_ID/DUD3.bundle
|
|
20
|
+
horsegraph render --project "DUD3" # resolved like `tinyhorse claim`
|
|
21
|
+
|
|
22
|
+
# plan views
|
|
23
|
+
horsegraph next --checkout . # "Needs you now" / "Claude starts next"
|
|
24
|
+
horsegraph path --checkout . # critical path to the goal node
|
|
25
|
+
horsegraph check --checkout . # validate; exit 1 on warnings (e.g. done before its deps)
|
|
26
|
+
|
|
27
|
+
# any JSON file, no Tiny Horse
|
|
28
|
+
horsegraph render --file examples/NODE-GRAPH.json
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
`--graph` changes the path inside the repo; `--ref` reads a tag or commit from storage instead of HEAD.
|
|
32
|
+
|
|
33
|
+
The ClaySpace world it writes with `--world` opens with the usual tools (`clayspace world inspect`, `clayspace world preview`).
|
|
34
|
+
|
|
35
|
+
## Reading the 3D view
|
|
36
|
+
|
|
37
|
+
| Shape | Meaning |
|
|
38
|
+
|---|---|
|
|
39
|
+
| low green slab | done |
|
|
40
|
+
| blue block | open, Claude's (agent) node |
|
|
41
|
+
| tall amber tower with a flagpole | open owner action, needs you |
|
|
42
|
+
| cyan | on the critical path to the goal |
|
|
43
|
+
| pink + sphere | goal node |
|
|
44
|
+
| grey, thin | parked (`later`, `after-proof`) |
|
|
45
|
+
|
|
46
|
+
Columns run left to right by dependency depth, and rows group by track. Drag to orbit, scroll to zoom, and hover a label for the node's title, deps and the contract items it advances.
|
|
47
|
+
|
|
48
|
+
## Graph format
|
|
49
|
+
|
|
50
|
+
```json
|
|
51
|
+
{
|
|
52
|
+
"title": "…", "goal": "G-PROOF",
|
|
53
|
+
"tracks": {"MAC": "DUD3 for Mac"},
|
|
54
|
+
"nodes": [
|
|
55
|
+
{"id": "MAC-4", "title": "…", "owner": "agent", "status": "open",
|
|
56
|
+
"deps": ["MAC-1", "MAC-2"], "track": "MAC", "closes": ["C2"]}
|
|
57
|
+
]
|
|
58
|
+
}
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
`owner` is `agent` or `owner`. `status` is `done`, `open`, `later`, `after-proof` or `available-verified`. Extra fields are kept. Cycles, unknown deps and duplicate ids are rejected.
|
|
62
|
+
|
|
63
|
+
## Python
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
from horsegraph import from_storage, render
|
|
67
|
+
graph, prov = from_storage("file:/srv/git/dud3.bundle")
|
|
68
|
+
print(graph.critical_path(), [n.id for n in graph.ready("owner")])
|
|
69
|
+
render(graph, "plan.html", provenance=prov.to_dict(), subtitle=prov.summary())
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Release
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
python -m build && twine upload dist/*
|
|
76
|
+
```
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"title": "DUD3 v4: Mac MCP server, phone 496, scripts, proof on Icarus",
|
|
3
|
+
"goal": "G-PROOF",
|
|
4
|
+
"tracks": {
|
|
5
|
+
"SRC": "Source and publication",
|
|
6
|
+
"MAC": "DUD3 for Mac (dude-mcp, installer)",
|
|
7
|
+
"PH": "Phone 1.2.8 (496)",
|
|
8
|
+
"SCR": "DUD3 scripts",
|
|
9
|
+
"REL": "Release and proof",
|
|
10
|
+
"THR": "Thorondor router (parallel project)"
|
|
11
|
+
},
|
|
12
|
+
"nodes": [
|
|
13
|
+
{"id": "SRC-0", "title": "DUD3 on Tiny Horse; docs switched; mirror job removed", "owner": "agent", "status": "done", "deps": [], "track": "SRC", "closes": ["C6"], "evidence": ["Tiny Horse DUD3 in_sync", "b3fccbdbe", "snx automaton rm repo-drive-mirror"]},
|
|
14
|
+
{"id": "SRC-1", "title": "Tiny Horse resumable chunked Drive uploads", "owner": "agent", "status": "open", "deps": ["SRC-1a"], "track": "SRC", "closes": ["C6"], "accept": ["16 MiB chunks, resume from Drive-reported offset, backoff, session survives restart", "If-Match conflict check kept", "md5 verified before success", "fake-Drive tests for stall/503/404/412/kill"]},
|
|
15
|
+
{"id": "SRC-1a", "title": "Tiny Horse source location", "owner": "owner", "status": "done", "deps": [], "track": "SRC"},
|
|
16
|
+
{"id": "SRC-2", "title": "Fallback: browser upload of a tinyhorse bundle as a new version of DUD3.bundle, then tinyhorse doctor", "owner": "agent", "status": "available-verified", "deps": [], "track": "SRC"},
|
|
17
|
+
{"id": "H-0", "title": "Tests isolated from live runner state (RPG-F62)", "owner": "agent", "status": "done", "deps": [], "track": "SRC"},
|
|
18
|
+
{"id": "MAC-1", "title": "Engine compiled into dude-mcp (embedded-engine-text, scrambled); remove engine-pack capsule, its ack and the refusal", "owner": "agent", "status": "done", "deps": [], "track": "MAC", "closes": ["C3"]},
|
|
19
|
+
{"id": "MAC-2", "title": "Keys on the Mac: dude-mcp key add <provider> (hidden prompt -> Keychain)", "owner": "agent", "status": "open", "deps": [], "track": "MAC", "closes": ["C2"]},
|
|
20
|
+
{"id": "MAC-3", "title": "Licence = link: tools on once a link is accepted; remove activation-token path and v3 leftovers", "owner": "agent", "status": "open", "deps": ["MAC-3a"], "track": "MAC", "closes": ["C1"]},
|
|
21
|
+
{"id": "MAC-3a", "title": "Owner: confirm dropping the activation-token path also drops headless Linux/Windows runners", "owner": "owner", "status": "done", "deps": [], "track": "MAC"},
|
|
22
|
+
{"id": "MAC-4", "title": "One-file mission format: script id/body + inputs + provider/model + sealed key, signed by phone; Mac handler writes one signed result or a plain error", "owner": "agent", "status": "open", "deps": ["MAC-1", "MAC-2", "MAC-3"], "track": "MAC", "closes": ["C2", "C3", "C4"]},
|
|
23
|
+
{"id": "MAC-5", "title": "CLI: link (QR in Terminal) + link_qr MCP tool, register, status, update, config, token", "owner": "agent", "status": "open", "deps": ["MAC-3"], "track": "MAC", "closes": ["C1", "C5"]},
|
|
24
|
+
{"id": "MAC-6", "title": "serve --http: MCP Streamable HTTP on localhost with a bearer token", "owner": "agent", "status": "open", "deps": ["MAC-3"], "track": "MAC"},
|
|
25
|
+
{"id": "MAC-7", "title": "LaunchAgent + notarized .pkg installer (Developer ID Installer) + uninstall", "owner": "agent", "status": "open", "deps": ["MAC-1", "MAC-2", "MAC-4", "MAC-5", "MAC-6"], "track": "MAC", "closes": ["C5"]},
|
|
26
|
+
{"id": "MAC-8", "title": "Delete DUD3Runner.app, its updater, v1 build receipt, legacy repository digest, menu UI", "owner": "agent", "status": "open", "deps": ["MAC-7"], "track": "MAC", "closes": ["C5"]},
|
|
27
|
+
{"id": "MAC-9", "title": "Owner-approved: retire DUD3Runner 164 and the net.llmtech.dud3p0.runner.watch LaunchAgent on this Mac when the pkg lands", "owner": "owner", "status": "open", "deps": ["MAC-7"], "track": "MAC", "closes": ["C5"]},
|
|
28
|
+
{"id": "PH-1", "title": "Phone sends one-file missions; delete prepareMissionProvider polling, engine-pack publish/ack", "owner": "agent", "status": "open", "deps": ["MAC-4"], "track": "PH", "closes": ["C3", "C4"]},
|
|
29
|
+
{"id": "PH-2", "title": "Engine scrambled in the iOS binary; leak gate's iOS profile checks engine text", "owner": "agent", "status": "open", "deps": ["MAC-1"], "track": "PH"},
|
|
30
|
+
{"id": "PH-3", "title": "Pricing in app: $0.99 unlock; grandfather paid-app buyers and Super Mode owners; remove Super Mode paywall/gates", "owner": "agent", "status": "open", "deps": ["PH-3a"], "track": "PH", "closes": ["C1"]},
|
|
31
|
+
{"id": "PH-3a", "title": "Owner: create the $0.99 non-consumable IAP in App Store Connect and send its product id", "owner": "owner", "status": "open", "deps": [], "track": "PH"},
|
|
32
|
+
{"id": "PH-4", "title": "'Connect your AI agents' screen (download link, steps); Thorondor promotion; home-screen name DUD3", "owner": "agent", "status": "open", "deps": ["MAC-7"], "track": "PH", "closes": ["C7"]},
|
|
33
|
+
{"id": "SCR-1", "title": "dud3.script/1 parser + validator (Markdown + header -> wire JSON), locked floor enforced", "owner": "agent", "status": "open", "deps": ["MAC-4"], "track": "SCR", "closes": ["C3"]},
|
|
34
|
+
{"id": "SCR-2", "title": "Starter pack: research and coding missions rewritten as scripts; one execution path", "owner": "agent", "status": "open", "deps": ["SCR-1"], "track": "SCR", "closes": ["C3"]},
|
|
35
|
+
{"id": "SCR-3", "title": "MCP: script_list, script_validate, script_save, script_run; scripts as MCP prompts", "owner": "agent", "status": "open", "deps": ["SCR-1"], "track": "SCR", "closes": ["C3"]},
|
|
36
|
+
{"id": "SCR-4", "title": "Phone: DUD3 drafts a script and sends it to the Mac (one-file)", "owner": "agent", "status": "open", "deps": ["SCR-1", "PH-1"], "track": "SCR", "closes": ["C3"]},
|
|
37
|
+
{"id": "REL-0", "title": "Owner: enable Remote Login on Icarus (centaur-macmini-1) or allow Screen Sharing", "owner": "owner", "status": "open", "deps": [], "track": "REL"},
|
|
38
|
+
{"id": "REL-0b", "title": "Icarus clean baseline: read-only inventory, owner-approved removal, record the clean state", "owner": "agent", "status": "open", "deps": ["REL-0"], "track": "REL", "closes": ["C5"]},
|
|
39
|
+
{"id": "REL-0c", "title": "Owner: an OpenRouter key in a DUD3 provider profile on the iPhone for the live routing call", "owner": "owner", "status": "open", "deps": [], "track": "REL"},
|
|
40
|
+
{"id": "REL-1b", "title": "dud3.ai: DUD3 for Mac download page for the pkg; retire runner.json and the DMG updater flow", "owner": "agent", "status": "open", "deps": ["MAC-7"], "track": "REL", "closes": ["C7"]},
|
|
41
|
+
{"id": "REL-1", "title": "Gates + artifacts: local gate, archive 496, notarize pkg, strings gate on IPA and pkg", "owner": "agent", "status": "open", "deps": ["MAC-8", "PH-1", "PH-2", "PH-3", "PH-4", "SCR-2", "SCR-3", "SCR-4", "REL-1b"], "track": "REL", "closes": ["C6"]},
|
|
42
|
+
{"id": "REL-1c", "title": "Release copy: What's New, App Review notes (guideline 3.1.3(b)), dud3.ai page text", "owner": "agent", "status": "open", "deps": ["PH-3", "PH-4", "REL-1b"], "track": "REL", "closes": ["C7", "C8"]},
|
|
43
|
+
{"id": "REL-1d", "title": "Owner approves installing the exact builds on the iPhone and the pkg on Icarus", "owner": "owner", "status": "open", "deps": ["REL-1"], "track": "REL"},
|
|
44
|
+
{"id": "G-PROOF", "title": "Proof on Icarus from the release pkg: t-tool + script from Claude Code and an IDE with the phone off; script sent from the phone; one live OpenRouter call", "owner": "agent", "status": "open", "deps": ["REL-0b", "REL-0c", "REL-1", "REL-1d"], "track": "REL", "closes": ["C1", "C2", "C3", "C4", "C5"]},
|
|
45
|
+
{"id": "REL-2", "title": "Owner approvals: exact builds; App Store submit; price Free at 496 release; publish pkg + site", "owner": "owner", "status": "open", "deps": ["G-PROOF", "REL-1c"], "track": "REL", "closes": ["C6", "C7", "C8", "C9"]},
|
|
46
|
+
{"id": "THR-1", "title": "Thorondor: OpenAI-compatible inbound API (/v1/chat/completions, /v1/models)", "owner": "agent", "status": "after-proof", "deps": [], "track": "THR"},
|
|
47
|
+
{"id": "THR-2", "title": "Thorondor absorbs dude-mcp serve-api; telemetry + learned routing on clean outcomes", "owner": "agent", "status": "open", "deps": ["THR-1", "H-0"], "track": "THR"},
|
|
48
|
+
{"id": "THR-3", "title": "DUD3 route.rs / completion.rs fallback move onto Thorondor", "owner": "agent", "status": "open", "deps": ["THR-2"], "track": "THR"},
|
|
49
|
+
{"id": "V5", "title": "Super Mode v5: cloud/browser agents via Thorondor", "owner": "agent", "status": "later", "deps": ["THR-3", "MAC-6", "REL-2"], "track": "THR"}
|
|
50
|
+
]
|
|
51
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.24"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "horsegraph"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Plan node graphs read through Tiny Horse canonical storage and visualized as ClaySpace worlds."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "Apache-2.0"
|
|
12
|
+
authors = [{ name = "Magus" }]
|
|
13
|
+
keywords = ["tinyhorse", "clayspace", "dependency-graph", "planning", "git"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"Operating System :: OS Independent",
|
|
17
|
+
"Topic :: Software Development :: Version Control :: Git",
|
|
18
|
+
"Topic :: Scientific/Engineering :: Visualization",
|
|
19
|
+
]
|
|
20
|
+
dependencies = [
|
|
21
|
+
"tinyhorse>=0.2.4",
|
|
22
|
+
"clayspace>=0.1.3,<0.2",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
[project.optional-dependencies]
|
|
26
|
+
gdrive = ["tinyhorse[gdrive]>=0.2.4"]
|
|
27
|
+
test = ["pytest>=7"]
|
|
28
|
+
|
|
29
|
+
[project.scripts]
|
|
30
|
+
horsegraph = "horsegraph.cli:main"
|
|
31
|
+
|
|
32
|
+
[tool.hatch.build.targets.wheel]
|
|
33
|
+
packages = ["src/horsegraph"]
|
|
34
|
+
|
|
35
|
+
[tool.hatch.build.targets.sdist]
|
|
36
|
+
include = ["src", "tests", "examples", "README.md", "LICENSE"]
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""horsegraph: plan node graphs read through Tiny Horse, visualized with ClaySpace."""
|
|
2
|
+
__version__ = "0.1.0"
|
|
3
|
+
|
|
4
|
+
from .graph import GraphError, Node, NodeGraph
|
|
5
|
+
from .source import DEFAULT_GRAPH_PATH, Provenance, SourceError, from_checkout, from_file, from_project, from_storage
|
|
6
|
+
|
|
7
|
+
__all__ = ["GraphError", "Node", "NodeGraph", "DEFAULT_GRAPH_PATH", "Provenance", "SourceError",
|
|
8
|
+
"from_checkout", "from_file", "from_project", "from_storage", "build_world", "render"]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def build_world(*a, **k):
|
|
12
|
+
from .world import build_world as f
|
|
13
|
+
return f(*a, **k)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def render(*a, **k):
|
|
17
|
+
from .world import render as f
|
|
18
|
+
return f(*a, **k)
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from . import __version__
|
|
9
|
+
from .graph import GraphError, NodeGraph
|
|
10
|
+
from .source import DEFAULT_GRAPH_PATH, Provenance, SourceError, from_checkout, from_file, from_project, from_storage
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _add_source(p: argparse.ArgumentParser) -> None:
|
|
14
|
+
src = p.add_mutually_exclusive_group()
|
|
15
|
+
src.add_argument("--checkout", metavar="PATH", help="Tiny Horse-bound git checkout (default: .)")
|
|
16
|
+
src.add_argument("--storage", metavar="LOCATOR", help="Tiny Horse locator: file:... or gdrive://FOLDER/FILE.bundle")
|
|
17
|
+
src.add_argument("--project", metavar="NAME", help="Tiny Horse Drive project by exact name")
|
|
18
|
+
src.add_argument("--file", metavar="JSON", help="a NODE-GRAPH.json on disk (no Tiny Horse)")
|
|
19
|
+
p.add_argument("--graph", default=DEFAULT_GRAPH_PATH, help=f"graph path inside the repo (default: {DEFAULT_GRAPH_PATH})")
|
|
20
|
+
p.add_argument("--ref", help="commit/ref to read from storage (default: canonical HEAD)")
|
|
21
|
+
p.add_argument("--doctor", action="store_true", help="with --checkout: run tinyhorse doctor and record the relation")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _load(args) -> tuple[NodeGraph, Provenance]:
|
|
25
|
+
if args.file:
|
|
26
|
+
return from_file(args.file)
|
|
27
|
+
if args.storage:
|
|
28
|
+
return from_storage(args.storage, args.graph, ref=args.ref)
|
|
29
|
+
if args.project:
|
|
30
|
+
return from_project(args.project, args.graph, ref=args.ref)
|
|
31
|
+
return from_checkout(args.checkout or ".", args.graph, doctor=args.doctor)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _line(n) -> str:
|
|
35
|
+
return f" {n.id:<9} {n.title}"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def main(argv: list[str] | None = None) -> int:
|
|
39
|
+
parser = argparse.ArgumentParser(prog="horsegraph",
|
|
40
|
+
description="Plan node graphs read through Tiny Horse, visualized with ClaySpace.")
|
|
41
|
+
parser.add_argument("--version", action="version", version=f"horsegraph {__version__}")
|
|
42
|
+
sub = parser.add_subparsers(dest="cmd", required=True)
|
|
43
|
+
|
|
44
|
+
p = sub.add_parser("render", help="build a ClaySpace world and a self-contained 3D HTML view")
|
|
45
|
+
_add_source(p)
|
|
46
|
+
p.add_argument("--out", default="node-graph.html", help="HTML output (default: node-graph.html)")
|
|
47
|
+
p.add_argument("--world", metavar="JSON", help="also save the ClaySpace world snapshot here")
|
|
48
|
+
p.add_argument("--no-labels", action="store_true", help="plain ClaySpace preview, no label/legend overlay")
|
|
49
|
+
|
|
50
|
+
p = sub.add_parser("check", help="validate the graph and print stats + warnings (exit 1 on warnings)")
|
|
51
|
+
_add_source(p)
|
|
52
|
+
p.add_argument("--json", action="store_true")
|
|
53
|
+
|
|
54
|
+
p = sub.add_parser("next", help="what can start now: Claude's nodes and yours")
|
|
55
|
+
_add_source(p)
|
|
56
|
+
p.add_argument("--json", action="store_true")
|
|
57
|
+
|
|
58
|
+
p = sub.add_parser("path", help="critical path to the goal node")
|
|
59
|
+
_add_source(p)
|
|
60
|
+
p.add_argument("--goal", help="override the graph's goal node")
|
|
61
|
+
|
|
62
|
+
args = parser.parse_args(argv)
|
|
63
|
+
try:
|
|
64
|
+
graph, prov = _load(args)
|
|
65
|
+
if args.cmd == "render":
|
|
66
|
+
from .world import render
|
|
67
|
+
out = render(graph, args.out, provenance=prov.to_dict(), subtitle=prov.summary(),
|
|
68
|
+
labels=not args.no_labels, world_out=args.world)
|
|
69
|
+
print(json.dumps({"html": str(Path(out).resolve()), "world": args.world,
|
|
70
|
+
"provenance": prov.to_dict(), "stats": graph.stats()}, indent=2))
|
|
71
|
+
return 0
|
|
72
|
+
if args.cmd == "check":
|
|
73
|
+
warn = graph.problems()
|
|
74
|
+
if args.json:
|
|
75
|
+
print(json.dumps({"provenance": prov.to_dict(), "stats": graph.stats(), "warnings": warn}, indent=2))
|
|
76
|
+
else:
|
|
77
|
+
print(prov.summary())
|
|
78
|
+
print(" " + " ".join(f"{k}={v}" for k, v in graph.stats().items()))
|
|
79
|
+
for w in warn:
|
|
80
|
+
print(" warning: " + w)
|
|
81
|
+
return 1 if warn else 0
|
|
82
|
+
if args.cmd == "next":
|
|
83
|
+
agent, owner = graph.ready("agent"), graph.ready("owner")
|
|
84
|
+
if args.json:
|
|
85
|
+
print(json.dumps({"agent": [n.id for n in agent], "owner": [n.id for n in owner]}))
|
|
86
|
+
else:
|
|
87
|
+
print("Needs you now:")
|
|
88
|
+
print("\n".join(map(_line, owner)) or " (nothing)")
|
|
89
|
+
print("Claude starts next:")
|
|
90
|
+
print("\n".join(map(_line, agent)) or " (nothing)")
|
|
91
|
+
return 0
|
|
92
|
+
if args.cmd == "path":
|
|
93
|
+
chain = graph.critical_path(args.goal)
|
|
94
|
+
if not chain:
|
|
95
|
+
print("no goal: pass --goal or set \"goal\" in the graph", file=sys.stderr)
|
|
96
|
+
return 2
|
|
97
|
+
for nid in chain:
|
|
98
|
+
n = graph.nodes[nid]
|
|
99
|
+
print(f" [{'x' if n.done else ' '}] {n.id:<9} {n.owner:<5} {n.title}")
|
|
100
|
+
return 0
|
|
101
|
+
except (GraphError, SourceError, OSError, ValueError, RuntimeError) as exc:
|
|
102
|
+
print(f"horsegraph: {exc}", file=sys.stderr)
|
|
103
|
+
return 2
|
|
104
|
+
return 2
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
if __name__ == "__main__":
|
|
108
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""NODE-GRAPH.json model: load, validate, and derive plan views.
|
|
2
|
+
|
|
3
|
+
A node graph is a JSON object with a ``nodes`` array. Each node has an ``id``,
|
|
4
|
+
``title``, ``owner`` ("agent" or "owner"), ``status``, ``deps`` (ids that must
|
|
5
|
+
be done first) and an optional ``track``. Extra fields (``closes``, ``accept``,
|
|
6
|
+
``evidence``, ``note``) are carried through untouched.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
DONE = "done"
|
|
16
|
+
PARKED = frozenset({"later", "after-proof"})
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class GraphError(ValueError):
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class Node:
|
|
25
|
+
id: str
|
|
26
|
+
title: str
|
|
27
|
+
owner: str = "agent"
|
|
28
|
+
status: str = "open"
|
|
29
|
+
deps: list[str] = field(default_factory=list)
|
|
30
|
+
track: str | None = None
|
|
31
|
+
extra: dict[str, Any] = field(default_factory=dict)
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def done(self) -> bool:
|
|
35
|
+
return self.status == DONE
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def parked(self) -> bool:
|
|
39
|
+
return self.status in PARKED
|
|
40
|
+
|
|
41
|
+
def to_dict(self) -> dict[str, Any]:
|
|
42
|
+
out = {"id": self.id, "title": self.title, "owner": self.owner,
|
|
43
|
+
"status": self.status, "deps": list(self.deps)}
|
|
44
|
+
if self.track is not None:
|
|
45
|
+
out["track"] = self.track
|
|
46
|
+
out.update(self.extra)
|
|
47
|
+
return out
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass
|
|
51
|
+
class NodeGraph:
|
|
52
|
+
nodes: dict[str, Node]
|
|
53
|
+
title: str | None = None
|
|
54
|
+
goal: str | None = None
|
|
55
|
+
tracks: dict[str, str] = field(default_factory=dict)
|
|
56
|
+
meta: dict[str, Any] = field(default_factory=dict)
|
|
57
|
+
|
|
58
|
+
# ---- construction -------------------------------------------------
|
|
59
|
+
@classmethod
|
|
60
|
+
def from_dict(cls, data: dict[str, Any]) -> "NodeGraph":
|
|
61
|
+
if not isinstance(data, dict) or not isinstance(data.get("nodes"), list):
|
|
62
|
+
raise GraphError("node graph must be an object with a 'nodes' array")
|
|
63
|
+
nodes: dict[str, Node] = {}
|
|
64
|
+
known = {"id", "title", "owner", "status", "deps", "track"}
|
|
65
|
+
for i, raw in enumerate(data["nodes"]):
|
|
66
|
+
if not isinstance(raw, dict):
|
|
67
|
+
raise GraphError(f"nodes[{i}] must be an object")
|
|
68
|
+
nid = raw.get("id")
|
|
69
|
+
if not isinstance(nid, str) or not nid.strip():
|
|
70
|
+
raise GraphError(f"nodes[{i}] needs a non-empty string id")
|
|
71
|
+
if nid in nodes:
|
|
72
|
+
raise GraphError(f"duplicate node id {nid}")
|
|
73
|
+
deps = raw.get("deps", [])
|
|
74
|
+
if not isinstance(deps, list) or not all(isinstance(d, str) for d in deps):
|
|
75
|
+
raise GraphError(f"{nid}: deps must be a list of ids")
|
|
76
|
+
nodes[nid] = Node(
|
|
77
|
+
id=nid, title=str(raw.get("title", nid)),
|
|
78
|
+
owner=str(raw.get("owner", "agent")), status=str(raw.get("status", "open")),
|
|
79
|
+
deps=list(deps), track=raw.get("track"),
|
|
80
|
+
extra={k: v for k, v in raw.items() if k not in known},
|
|
81
|
+
)
|
|
82
|
+
tracks = data.get("tracks") or {}
|
|
83
|
+
g = cls(nodes=nodes, title=data.get("title"), goal=data.get("goal"),
|
|
84
|
+
tracks=dict(tracks) if isinstance(tracks, dict) else {},
|
|
85
|
+
meta={k: v for k, v in data.items() if k not in {"nodes", "title", "goal", "tracks"}})
|
|
86
|
+
g.validate()
|
|
87
|
+
return g
|
|
88
|
+
|
|
89
|
+
@classmethod
|
|
90
|
+
def from_json(cls, text: str | bytes) -> "NodeGraph":
|
|
91
|
+
return cls.from_dict(json.loads(text))
|
|
92
|
+
|
|
93
|
+
@classmethod
|
|
94
|
+
def load(cls, path: str | Path) -> "NodeGraph":
|
|
95
|
+
return cls.from_json(Path(path).read_bytes())
|
|
96
|
+
|
|
97
|
+
def to_dict(self) -> dict[str, Any]:
|
|
98
|
+
out: dict[str, Any] = dict(self.meta)
|
|
99
|
+
if self.title:
|
|
100
|
+
out["title"] = self.title
|
|
101
|
+
if self.goal:
|
|
102
|
+
out["goal"] = self.goal
|
|
103
|
+
if self.tracks:
|
|
104
|
+
out["tracks"] = dict(self.tracks)
|
|
105
|
+
out["nodes"] = [n.to_dict() for n in self.nodes.values()]
|
|
106
|
+
return out
|
|
107
|
+
|
|
108
|
+
# ---- checks -------------------------------------------------------
|
|
109
|
+
def validate(self) -> None:
|
|
110
|
+
for n in self.nodes.values():
|
|
111
|
+
for d in n.deps:
|
|
112
|
+
if d not in self.nodes:
|
|
113
|
+
raise GraphError(f"{n.id} depends on unknown node {d}")
|
|
114
|
+
if self.goal and self.goal not in self.nodes:
|
|
115
|
+
raise GraphError(f"goal {self.goal} is not a node")
|
|
116
|
+
self.depths() # raises on cycles
|
|
117
|
+
|
|
118
|
+
def problems(self) -> list[str]:
|
|
119
|
+
"""Soft consistency warnings (graph is still usable)."""
|
|
120
|
+
out = []
|
|
121
|
+
for n in self.nodes.values():
|
|
122
|
+
if n.done:
|
|
123
|
+
for d in n.deps:
|
|
124
|
+
if not self.nodes[d].done:
|
|
125
|
+
out.append(f"{n.id} is done but its dependency {d} is {self.nodes[d].status}")
|
|
126
|
+
if n.owner not in ("agent", "owner"):
|
|
127
|
+
out.append(f"{n.id} has unknown owner {n.owner!r}")
|
|
128
|
+
return out
|
|
129
|
+
|
|
130
|
+
# ---- derived views ------------------------------------------------
|
|
131
|
+
def depths(self) -> dict[str, int]:
|
|
132
|
+
depth: dict[str, int] = {}
|
|
133
|
+
visiting: set[str] = set()
|
|
134
|
+
|
|
135
|
+
def visit(nid: str, chain: tuple[str, ...]) -> int:
|
|
136
|
+
if nid in depth:
|
|
137
|
+
return depth[nid]
|
|
138
|
+
if nid in visiting:
|
|
139
|
+
cyc = chain[chain.index(nid):] + (nid,)
|
|
140
|
+
raise GraphError("dependency cycle: " + " -> ".join(cyc))
|
|
141
|
+
visiting.add(nid)
|
|
142
|
+
deps = self.nodes[nid].deps
|
|
143
|
+
depth[nid] = 1 + max(visit(d, chain + (nid,)) for d in deps) if deps else 0
|
|
144
|
+
visiting.discard(nid)
|
|
145
|
+
return depth[nid]
|
|
146
|
+
|
|
147
|
+
for nid in self.nodes:
|
|
148
|
+
visit(nid, ())
|
|
149
|
+
return depth
|
|
150
|
+
|
|
151
|
+
def critical_path(self, goal: str | None = None) -> list[str]:
|
|
152
|
+
"""Longest dependency chain ending at ``goal`` (root first)."""
|
|
153
|
+
goal = goal or self.goal
|
|
154
|
+
if not goal:
|
|
155
|
+
return []
|
|
156
|
+
depth = self.depths()
|
|
157
|
+
path = [goal]
|
|
158
|
+
cur = goal
|
|
159
|
+
while self.nodes[cur].deps:
|
|
160
|
+
cur = max(self.nodes[cur].deps, key=lambda d: depth[d])
|
|
161
|
+
path.append(cur)
|
|
162
|
+
return path[::-1]
|
|
163
|
+
|
|
164
|
+
def ready(self, owner: str | None = None) -> list[Node]:
|
|
165
|
+
"""Open nodes whose dependencies are all done."""
|
|
166
|
+
out = [n for n in self.nodes.values()
|
|
167
|
+
if not n.done and not n.parked and n.status != "available-verified"
|
|
168
|
+
and all(self.nodes[d].done for d in n.deps)]
|
|
169
|
+
return [n for n in out if owner is None or n.owner == owner]
|
|
170
|
+
|
|
171
|
+
def blocked(self, owner: str | None = None) -> list[Node]:
|
|
172
|
+
out = [n for n in self.nodes.values()
|
|
173
|
+
if not n.done and not n.parked and any(not self.nodes[d].done for d in n.deps)]
|
|
174
|
+
return [n for n in out if owner is None or n.owner == owner]
|
|
175
|
+
|
|
176
|
+
def stats(self) -> dict[str, int]:
|
|
177
|
+
ns = list(self.nodes.values())
|
|
178
|
+
return {
|
|
179
|
+
"nodes": len(ns),
|
|
180
|
+
"done": sum(n.done for n in ns),
|
|
181
|
+
"open": sum(n.status == "open" for n in ns),
|
|
182
|
+
"owner_open": sum(n.owner == "owner" and not n.done for n in ns),
|
|
183
|
+
"ready_agent": len(self.ready("agent")),
|
|
184
|
+
"ready_owner": len(self.ready("owner")),
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
def track_order(self) -> list[str]:
|
|
188
|
+
seen: list[str] = list(self.tracks)
|
|
189
|
+
for n in self.nodes.values():
|
|
190
|
+
t = n.track or "-"
|
|
191
|
+
if t not in seen:
|
|
192
|
+
seen.append(t)
|
|
193
|
+
return seen
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Read a node graph through Tiny Horse.
|
|
2
|
+
|
|
3
|
+
Three sources, all returning ``(NodeGraph, Provenance)``:
|
|
4
|
+
|
|
5
|
+
* :func:`from_checkout` - a git checkout bound with ``.tinyhorse.toml``; the
|
|
6
|
+
provenance records HEAD, dirtiness and (optionally) ``tinyhorse doctor``.
|
|
7
|
+
* :func:`from_storage` - a Tiny Horse storage locator (``file:...`` or
|
|
8
|
+
``gdrive://FOLDER/FILE.bundle``). The canonical bundle is fetched and
|
|
9
|
+
verified, and the graph is read from its HEAD commit - no checkout needed.
|
|
10
|
+
* :func:`from_project` - a Drive project by display name (as ``tinyhorse
|
|
11
|
+
projects`` lists it), resolved to its locator, then read like storage.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import subprocess
|
|
16
|
+
import tempfile
|
|
17
|
+
from dataclasses import asdict, dataclass, field
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
from tinyhorse.config import ConfigError, load_config
|
|
22
|
+
from tinyhorse.git import bundle_head, repository_state, verify_bundle
|
|
23
|
+
from tinyhorse.storage import backend_for
|
|
24
|
+
|
|
25
|
+
from .graph import NodeGraph
|
|
26
|
+
|
|
27
|
+
DEFAULT_GRAPH_PATH = "docs/runner-gateway/NODE-GRAPH.json"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class SourceError(RuntimeError):
|
|
31
|
+
pass
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class Provenance:
|
|
36
|
+
kind: str # "file" | "checkout" | "storage"
|
|
37
|
+
graph_path: str
|
|
38
|
+
project: str | None = None
|
|
39
|
+
storage: str | None = None
|
|
40
|
+
commit: str | None = None
|
|
41
|
+
dirty: bool | None = None
|
|
42
|
+
relation: str | None = None # from tinyhorse doctor, when asked
|
|
43
|
+
doctor_ok: bool | None = None
|
|
44
|
+
details: list[str] = field(default_factory=list)
|
|
45
|
+
|
|
46
|
+
def to_dict(self) -> dict[str, Any]:
|
|
47
|
+
return {k: v for k, v in asdict(self).items() if v not in (None, [])}
|
|
48
|
+
|
|
49
|
+
def summary(self) -> str:
|
|
50
|
+
bits = [self.project or Path(self.graph_path).name]
|
|
51
|
+
if self.commit:
|
|
52
|
+
bits.append(self.commit[:12] + (" +dirty" if self.dirty else ""))
|
|
53
|
+
if self.relation:
|
|
54
|
+
bits.append(f"tinyhorse {self.relation}")
|
|
55
|
+
return " · ".join(bits)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def from_file(path: str | Path) -> tuple[NodeGraph, Provenance]:
|
|
59
|
+
return NodeGraph.load(path), Provenance(kind="file", graph_path=str(path))
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def from_checkout(repo: str | Path = ".", graph_path: str = DEFAULT_GRAPH_PATH,
|
|
63
|
+
*, doctor: bool = False) -> tuple[NodeGraph, Provenance]:
|
|
64
|
+
state = repository_state(repo)
|
|
65
|
+
root = Path(state.root)
|
|
66
|
+
target = root / graph_path
|
|
67
|
+
if not target.is_file():
|
|
68
|
+
raise SourceError(f"{graph_path} not found in {root}")
|
|
69
|
+
prov = Provenance(kind="checkout", graph_path=graph_path,
|
|
70
|
+
commit=state.head, dirty=state.dirty)
|
|
71
|
+
try:
|
|
72
|
+
cfg = load_config(root)
|
|
73
|
+
prov.project, prov.storage = cfg.project, cfg.storage
|
|
74
|
+
except ConfigError as exc:
|
|
75
|
+
prov.details.append(f"not bound to Tiny Horse: {exc}")
|
|
76
|
+
if doctor and prov.storage:
|
|
77
|
+
from tinyhorse.workflow import doctor as th_doctor
|
|
78
|
+
result = th_doctor(root)
|
|
79
|
+
prov.relation, prov.doctor_ok = result.relation, result.ok
|
|
80
|
+
prov.details.extend(result.details)
|
|
81
|
+
return NodeGraph.load(target), prov
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _git(*args: str) -> bytes:
|
|
85
|
+
proc = subprocess.run(["git", "--no-replace-objects", *args],
|
|
86
|
+
stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
|
87
|
+
if proc.returncode != 0:
|
|
88
|
+
raise SourceError(proc.stderr.decode(errors="replace").strip() or f"git {args[0]} failed")
|
|
89
|
+
return proc.stdout
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def from_storage(locator: str, graph_path: str = DEFAULT_GRAPH_PATH, *,
|
|
93
|
+
ref: str | None = None, project: str | None = None) -> tuple[NodeGraph, Provenance]:
|
|
94
|
+
"""Fetch the canonical bundle from Tiny Horse storage and read the graph at HEAD (or ``ref``)."""
|
|
95
|
+
with tempfile.TemporaryDirectory(prefix="horsegraph-") as td:
|
|
96
|
+
bundle = Path(td) / "canonical.bundle"
|
|
97
|
+
backend_for(locator).get(bundle)
|
|
98
|
+
verify_bundle(bundle)
|
|
99
|
+
mirror = Path(td) / "canonical.git"
|
|
100
|
+
_git("clone", "--quiet", "--mirror", str(bundle), str(mirror))
|
|
101
|
+
commit = _git("--git-dir", str(mirror), "rev-parse", ref).decode().strip() if ref else bundle_head(bundle)
|
|
102
|
+
try:
|
|
103
|
+
raw = _git("--git-dir", str(mirror), "show", f"{commit}:{graph_path}")
|
|
104
|
+
except SourceError as exc:
|
|
105
|
+
raise SourceError(f"{graph_path} not found at {commit[:12]} in {locator}: {exc}") from exc
|
|
106
|
+
prov = Provenance(kind="storage", graph_path=graph_path, project=project,
|
|
107
|
+
storage=locator, commit=commit, dirty=False)
|
|
108
|
+
return NodeGraph.from_json(raw), prov
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def from_project(name: str, graph_path: str = DEFAULT_GRAPH_PATH, *,
|
|
112
|
+
ref: str | None = None) -> tuple[NodeGraph, Provenance]:
|
|
113
|
+
"""Resolve a Tiny Horse Drive project by display name and read its canonical graph."""
|
|
114
|
+
from tinyhorse.storage import resolve_google_drive_project
|
|
115
|
+
found = resolve_google_drive_project(name) # exact match only, like `tinyhorse claim`
|
|
116
|
+
return from_storage(found.locator, graph_path, ref=ref, project=found.project)
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"""Turn a NodeGraph into a ClaySpace world and render it.
|
|
2
|
+
|
|
3
|
+
Layout: x runs along dependency depth (left = no deps), z runs across rows
|
|
4
|
+
inside a column (grouped by track). Every node is a ClaySpace Cell; its height
|
|
5
|
+
encodes state (done = low slab, open = block, owner action = tall tower,
|
|
6
|
+
parked = thin). Every dependency edge is its own Cell: a beam with an
|
|
7
|
+
arrowhead, placed and yawed from the dependency to the dependant.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import html
|
|
12
|
+
import json
|
|
13
|
+
import math
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
import clayspace as cs
|
|
18
|
+
|
|
19
|
+
from .graph import Node, NodeGraph
|
|
20
|
+
|
|
21
|
+
DX, DZ = 7.0, 5.5 # column / row pitch, meters
|
|
22
|
+
FOOT = 3.0 # node footprint
|
|
23
|
+
COLORS = {
|
|
24
|
+
"done": "#6cc070",
|
|
25
|
+
"owner": "#e7a13e",
|
|
26
|
+
"agent": "#a7d7ff",
|
|
27
|
+
"path": "#4fe0e6",
|
|
28
|
+
"parked": "#6b777d",
|
|
29
|
+
"edge": "#46545b",
|
|
30
|
+
"edge_path": "#4fe0e6",
|
|
31
|
+
"goal": "#ff7ab6",
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _height(n: Node) -> float:
|
|
36
|
+
if n.done:
|
|
37
|
+
return 0.35
|
|
38
|
+
if n.parked:
|
|
39
|
+
return 0.6
|
|
40
|
+
return 2.6 if n.owner == "owner" else 1.4
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _color(n: Node, on_path: bool, goal: bool) -> str:
|
|
44
|
+
if goal:
|
|
45
|
+
return COLORS["goal"]
|
|
46
|
+
if n.done:
|
|
47
|
+
return COLORS["done"]
|
|
48
|
+
if n.parked:
|
|
49
|
+
return COLORS["parked"]
|
|
50
|
+
if n.owner == "owner":
|
|
51
|
+
return COLORS["owner"]
|
|
52
|
+
return COLORS["path"] if on_path else COLORS["agent"]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _slug(text: str) -> str:
|
|
56
|
+
return "".join(c if c.isalnum() or c in "-_." else "-" for c in text)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def layout(graph: NodeGraph) -> dict[str, tuple[float, float]]:
|
|
60
|
+
"""Node id -> (x, z) centre, columns by depth, rows grouped by track."""
|
|
61
|
+
depth = graph.depths()
|
|
62
|
+
order = graph.track_order()
|
|
63
|
+
cols: dict[int, list[Node]] = {}
|
|
64
|
+
for n in graph.nodes.values():
|
|
65
|
+
cols.setdefault(depth[n.id], []).append(n)
|
|
66
|
+
pos = {}
|
|
67
|
+
for d, col in cols.items():
|
|
68
|
+
col.sort(key=lambda n: order.index(n.track or "-"))
|
|
69
|
+
offset = (len(col) - 1) / 2
|
|
70
|
+
for i, n in enumerate(col):
|
|
71
|
+
pos[n.id] = (d * DX, (i - offset) * DZ)
|
|
72
|
+
return pos
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def node_cell(n: Node, *, on_path: bool = False, goal: bool = False) -> dict:
|
|
76
|
+
h = _height(n)
|
|
77
|
+
cell = cs.new_box_cell(cell_id=f"horsegraph:node:{n.id}", name=n.title, size=(FOOT, h, FOOT))
|
|
78
|
+
color = _color(n, on_path, goal)
|
|
79
|
+
cell["geometry"][0]["stroke"] = color # ClaySpace preview honours per-shape stroke
|
|
80
|
+
if goal:
|
|
81
|
+
cell = cs.add_sphere(cell, object_id="GOAL", position=(0, h + 0.9, 0), radius=0.6)
|
|
82
|
+
cell["geometry"][-1]["stroke"] = color
|
|
83
|
+
if n.owner == "owner" and not n.done:
|
|
84
|
+
# a flag on owner actions so they read as "needs a human" from any angle
|
|
85
|
+
cell = cs.add_cylinder(cell, object_id="FLAGPOLE", position=(FOOT / 2 - 0.2, h + 0.6, FOOT / 2 - 0.2),
|
|
86
|
+
radius=0.05, height=1.2, segments=8)
|
|
87
|
+
cell["geometry"][-1]["stroke"] = color
|
|
88
|
+
cell["horsegraph"] = {"node": n.to_dict()} # host metadata; ClaySpace ignores it
|
|
89
|
+
return cell
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def edge_cell(src: str, dst: str, length: float, *, on_path: bool) -> dict:
|
|
93
|
+
"""A beam along +x of ``length`` with an arrowhead at the far end; origin at the start."""
|
|
94
|
+
color = COLORS["edge_path" if on_path else "edge"]
|
|
95
|
+
thick = 0.12 if on_path else 0.06
|
|
96
|
+
beam = max(length - 0.6, 0.1)
|
|
97
|
+
cell = cs.new_box_cell(cell_id=f"horsegraph:edge:{src}->{dst}", name=f"{src} -> {dst}",
|
|
98
|
+
size=(beam, thick, thick))
|
|
99
|
+
cell = cs.move_geometry(cell, object_id="MASS-001", position=(beam / 2, 0.25, 0))
|
|
100
|
+
cell = cs.add_prism(cell, object_id="ARROW", footprint_xz=[(beam, -0.3), (beam, 0.3), (length, 0)],
|
|
101
|
+
y0=0.2, y1=0.3)
|
|
102
|
+
for g in cell["geometry"]:
|
|
103
|
+
g["stroke"] = color
|
|
104
|
+
return cell
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def build_world(graph: NodeGraph, *, provenance: dict[str, Any] | None = None,
|
|
108
|
+
world_id: str | None = None) -> dict:
|
|
109
|
+
pos = layout(graph)
|
|
110
|
+
path = set(graph.critical_path())
|
|
111
|
+
title = graph.title or "Node graph"
|
|
112
|
+
world = cs.new_world(world_id=world_id or f"horsegraph:{_slug(title)[:48]}", label=title)
|
|
113
|
+
|
|
114
|
+
for n in graph.nodes.values():
|
|
115
|
+
x, z = pos[n.id]
|
|
116
|
+
cell = node_cell(n, on_path=n.id in path, goal=n.id == graph.goal)
|
|
117
|
+
world = cs.place_cell(world, cell, entity_id=f"node:{n.id}", position=(x, 0, z),
|
|
118
|
+
display_name=f"{n.id}: {n.title}")
|
|
119
|
+
half = FOOT / 2
|
|
120
|
+
for n in graph.nodes.values():
|
|
121
|
+
for d in n.deps:
|
|
122
|
+
(x1, z1), (x2, z2) = pos[d], pos[n.id]
|
|
123
|
+
x1 += half
|
|
124
|
+
x2 -= half
|
|
125
|
+
dx, dz = x2 - x1, z2 - z1
|
|
126
|
+
length = math.hypot(dx, dz)
|
|
127
|
+
cell = edge_cell(d, n.id, length, on_path=d in path and n.id in path)
|
|
128
|
+
world = cs.place_cell(world, cell, entity_id=f"edge:{d}->{n.id}", position=(x1, 0, z1),
|
|
129
|
+
yaw_radians=math.atan2(dz, dx), display_name=f"{d} -> {n.id}")
|
|
130
|
+
world["metadata"] = {
|
|
131
|
+
"generator": "horsegraph",
|
|
132
|
+
"stats": graph.stats(),
|
|
133
|
+
"criticalPath": graph.critical_path(),
|
|
134
|
+
"provenance": provenance or {},
|
|
135
|
+
}
|
|
136
|
+
check = cs.validate_world_snapshot(world)
|
|
137
|
+
if not check["valid"]:
|
|
138
|
+
raise ValueError("horsegraph built an invalid ClaySpace world: " + "; ".join(check["errors"]))
|
|
139
|
+
return world
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _overlay(graph: NodeGraph, world: dict, subtitle: str) -> str:
|
|
143
|
+
"""Labels, hover details and a legend layered on ClaySpace's own preview."""
|
|
144
|
+
pos = layout(graph)
|
|
145
|
+
path = set(graph.critical_path())
|
|
146
|
+
labels = []
|
|
147
|
+
for n in graph.nodes.values():
|
|
148
|
+
x, z = pos[n.id]
|
|
149
|
+
labels.append({
|
|
150
|
+
"p": [x, _height(n) + 0.5, z], "id": n.id, "t": n.title, "s": n.status, "o": n.owner,
|
|
151
|
+
"d": n.deps, "c": _color(n, n.id in path, n.id == graph.goal),
|
|
152
|
+
"k": n.extra.get("closes", []),
|
|
153
|
+
})
|
|
154
|
+
data = json.dumps(labels, ensure_ascii=False).replace("</", "<\\/")
|
|
155
|
+
stats = graph.stats()
|
|
156
|
+
legend = "".join(
|
|
157
|
+
f'<span><i style="background:{c}"></i>{html.escape(t)}</span>' for t, c in [
|
|
158
|
+
("done", COLORS["done"]), ("Claude / agent", COLORS["agent"]), ("owner action", COLORS["owner"]),
|
|
159
|
+
("critical path", COLORS["path"]), ("goal", COLORS["goal"]), ("parked", COLORS["parked"])])
|
|
160
|
+
return f"""
|
|
161
|
+
<style>
|
|
162
|
+
#hg{{position:fixed;right:14px;top:12px;background:#111c;padding:10px 12px;border:1px solid #ffffff22;border-radius:8px;backdrop-filter:blur(6px);max-width:min(360px,calc(100vw - 40px));font:12px ui-monospace,Menlo,monospace;color:#ddd}}
|
|
163
|
+
#hg .lg{{display:flex;flex-wrap:wrap;gap:6px 12px;margin-top:6px}}#hg i{{display:inline-block;width:10px;height:10px;border-radius:2px;margin-right:5px;vertical-align:-1px}}
|
|
164
|
+
#hgtip{{position:fixed;pointer-events:none;display:none;background:#000d;border:1px solid #ffffff33;border-radius:6px;padding:8px 10px;max-width:340px;font:12px system-ui,sans-serif;color:#eee}}
|
|
165
|
+
#hgtip b{{font-family:ui-monospace,Menlo,monospace}}#hgtip small{{display:block;color:#aaa;margin-top:4px}}
|
|
166
|
+
@media (max-width:640px){{#hg{{top:auto;bottom:12px;left:14px;right:14px;max-width:none}}}}
|
|
167
|
+
</style>
|
|
168
|
+
<div id="hg"><div>{html.escape(subtitle)}</div>
|
|
169
|
+
<div>{stats['nodes']} nodes · {stats['done']} done · {stats['ready_agent']} ready for Claude · {stats['ready_owner']} waiting on you</div>
|
|
170
|
+
<div class="lg">{legend}</div><label style="display:block;margin-top:6px"><input type="checkbox" id="hglab" checked> labels</label></div>
|
|
171
|
+
<div id="hgtip"></div>
|
|
172
|
+
<script>
|
|
173
|
+
const HG={data};
|
|
174
|
+
(function(){{
|
|
175
|
+
const tip=document.getElementById('hgtip'),tog=document.getElementById('hglab');
|
|
176
|
+
let shown=[];const base=draw;
|
|
177
|
+
draw=function(){{base();shown=[];if(!tog.checked)return;const b=bounds(collect());
|
|
178
|
+
ctx.font='600 11px ui-monospace,Menlo,monospace';ctx.textAlign='center';
|
|
179
|
+
for(const L of HG){{const p=project(L.p,b);shown.push([p,L]);ctx.fillStyle='#000a';const w=ctx.measureText(L.id).width+8;
|
|
180
|
+
ctx.fillRect(p[0]-w/2,p[1]-14,w,16);ctx.fillStyle=L.c;ctx.fillText(L.id,p[0],p[1]-2);}}}};
|
|
181
|
+
tog.onchange=()=>draw();
|
|
182
|
+
// start (and reset) looking down the plan: depth left->right, tracks top->bottom
|
|
183
|
+
const home=()=>{{yaw=0;pitch=-1.3;zoom=6;draw()}};
|
|
184
|
+
document.getElementById('reset').onclick=home;canvas.ondblclick=home;
|
|
185
|
+
yaw=0;pitch=-1.3;zoom=6;
|
|
186
|
+
canvas.addEventListener('pointermove',e=>{{if(drag){{tip.style.display='none';return}}let best=null,bd=18;
|
|
187
|
+
for(const [p,L] of shown){{const d=Math.hypot(p[0]-e.clientX,p[1]-8-e.clientY);if(d<bd){{bd=d;best=L}}}}
|
|
188
|
+
if(!best){{tip.style.display='none';return}}
|
|
189
|
+
const esc=s=>String(s).replace(/[&<>]/g,c=>({{'&':'&','<':'<','>':'>'}})[c]);
|
|
190
|
+
tip.innerHTML='<b>'+esc(best.id)+'</b> · '+esc(best.o)+' · '+esc(best.s)+'<div>'+esc(best.t)+'</div>'+
|
|
191
|
+
(best.d.length?'<small>after '+best.d.map(esc).join(', ')+'</small>':'')+(best.k.length?'<small>advances '+best.k.map(esc).join(', ')+'</small>':'');
|
|
192
|
+
tip.style.display='block';tip.style.left=Math.min(e.clientX+14,innerWidth-350)+'px';tip.style.top=(e.clientY+14)+'px';}});
|
|
193
|
+
draw();
|
|
194
|
+
}})();
|
|
195
|
+
</script>
|
|
196
|
+
"""
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def render(graph: NodeGraph, out: str | Path, *, provenance: dict[str, Any] | None = None,
|
|
200
|
+
subtitle: str | None = None, labels: bool = True, world_out: str | Path | None = None,
|
|
201
|
+
overwrite: bool = True) -> Path:
|
|
202
|
+
"""Build the ClaySpace world, render ClaySpace's preview to ``out``, add the overlay."""
|
|
203
|
+
world = build_world(graph, provenance=provenance)
|
|
204
|
+
if world_out:
|
|
205
|
+
cs.save_world_snapshot(world, world_out, overwrite=overwrite)
|
|
206
|
+
out = Path(out)
|
|
207
|
+
cs.preview_world(world, out)
|
|
208
|
+
if labels:
|
|
209
|
+
page = out.read_text(encoding="utf-8")
|
|
210
|
+
page += _overlay(graph, world, subtitle or "horsegraph")
|
|
211
|
+
out.write_text(page, encoding="utf-8")
|
|
212
|
+
return out
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import shutil
|
|
3
|
+
import subprocess
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import clayspace as cs
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
from horsegraph import GraphError, NodeGraph, from_checkout, from_storage
|
|
10
|
+
from horsegraph.cli import main
|
|
11
|
+
from horsegraph.world import build_world, render
|
|
12
|
+
|
|
13
|
+
EXAMPLE = Path(__file__).resolve().parents[1] / "examples" / "NODE-GRAPH.json"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def g(nodes, **kw):
|
|
17
|
+
return NodeGraph.from_dict({"nodes": nodes, **kw})
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_example_loads_and_views():
|
|
21
|
+
graph = NodeGraph.load(EXAMPLE)
|
|
22
|
+
s = graph.stats()
|
|
23
|
+
assert s["nodes"] == 37 and s["done"] == 5
|
|
24
|
+
assert {n.id for n in graph.ready("owner")} == {"PH-3a", "REL-0", "REL-0c"}
|
|
25
|
+
# matches the plan page's "Claude starts next" panel
|
|
26
|
+
assert {n.id for n in graph.ready("agent")} == {"SRC-1", "MAC-2", "MAC-3", "PH-2"}
|
|
27
|
+
path = graph.critical_path()
|
|
28
|
+
assert path[-1] == "G-PROOF" and path[0] in {"MAC-3a", "MAC-1", "MAC-2"}
|
|
29
|
+
assert graph.problems() == []
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_cycle_and_unknown_dep_rejected():
|
|
33
|
+
with pytest.raises(GraphError, match="cycle"):
|
|
34
|
+
g([{"id": "a", "deps": ["b"]}, {"id": "b", "deps": ["a"]}])
|
|
35
|
+
with pytest.raises(GraphError, match="unknown"):
|
|
36
|
+
g([{"id": "a", "deps": ["zz"]}])
|
|
37
|
+
with pytest.raises(GraphError, match="duplicate"):
|
|
38
|
+
g([{"id": "a"}, {"id": "a"}])
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_done_before_dep_warns():
|
|
42
|
+
graph = g([{"id": "a"}, {"id": "b", "deps": ["a"], "status": "done"}])
|
|
43
|
+
assert graph.problems()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_world_is_valid_clayspace(tmp_path):
|
|
47
|
+
graph = NodeGraph.load(EXAMPLE)
|
|
48
|
+
world = build_world(graph)
|
|
49
|
+
check = cs.inspect_world_snapshot(world)
|
|
50
|
+
assert check["valid"], check["errors"]
|
|
51
|
+
edges = sum(len(n.deps) for n in graph.nodes.values())
|
|
52
|
+
assert check["entityCount"] == len(graph.nodes) + edges
|
|
53
|
+
assert check["embeddedCellCount"] == check["entityCount"]
|
|
54
|
+
out = render(graph, tmp_path / "g.html", world_out=tmp_path / "w.json")
|
|
55
|
+
page = out.read_text()
|
|
56
|
+
assert "ClaySpace" in page and "const HG=" in page and "G-PROOF" in page
|
|
57
|
+
assert cs.validate_world_snapshot(cs.load_world_snapshot(tmp_path / "w.json"))["valid"]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_edge_points_at_dependant():
|
|
61
|
+
graph = g([{"id": "a"}, {"id": "b", "deps": ["a"]}, {"id": "c", "deps": ["a"]}, {"id": "d", "deps": ["b", "c"]}])
|
|
62
|
+
world = build_world(graph)
|
|
63
|
+
from horsegraph.world import layout
|
|
64
|
+
import math
|
|
65
|
+
pos = layout(graph)
|
|
66
|
+
for e in world["entities"]:
|
|
67
|
+
if not e["entityId"].startswith("edge:"):
|
|
68
|
+
continue
|
|
69
|
+
src, dst = e["entityId"][5:].split("->")
|
|
70
|
+
yaw = e["placement"]["yawRadians"]
|
|
71
|
+
start = e["placement"]["position"]
|
|
72
|
+
length = max(p[0] for gm in e["payload"]["cell"]["geometry"] if gm["primitive"] == "prism" for p in gm["footprintXZ"])
|
|
73
|
+
end = (start[0] + math.cos(yaw) * length, start[2] + math.sin(yaw) * length)
|
|
74
|
+
target = (pos[dst][0] - 1.5, pos[dst][1])
|
|
75
|
+
assert math.dist(end, target) < 1e-6
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
git_missing = shutil.which("git") is None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _git(cwd, *args):
|
|
82
|
+
subprocess.run(["git", *args], cwd=cwd, check=True, capture_output=True)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@pytest.fixture
|
|
86
|
+
def tinyhorse_repo(tmp_path):
|
|
87
|
+
repo = tmp_path / "proj"
|
|
88
|
+
(repo / "docs/runner-gateway").mkdir(parents=True)
|
|
89
|
+
shutil.copy(EXAMPLE, repo / "docs/runner-gateway/NODE-GRAPH.json")
|
|
90
|
+
_git(repo, "init", "-q", "-b", "main")
|
|
91
|
+
_git(repo, "-c", "user.email=t@t", "-c", "user.name=t", "add", ".")
|
|
92
|
+
_git(repo, "-c", "user.email=t@t", "-c", "user.name=t", "commit", "-qm", "graph")
|
|
93
|
+
locator = f"file:{tmp_path / 'store' / 'proj.bundle'}"
|
|
94
|
+
(tmp_path / "store").mkdir()
|
|
95
|
+
from tinyhorse.workflow import init_project, push
|
|
96
|
+
init_project(repo, locator, "Proj")
|
|
97
|
+
_git(repo, "-c", "user.email=t@t", "-c", "user.name=t", "add", ".tinyhorse.toml")
|
|
98
|
+
_git(repo, "-c", "user.email=t@t", "-c", "user.name=t", "commit", "-qm", "bind")
|
|
99
|
+
push(repo)
|
|
100
|
+
return repo, locator
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@pytest.mark.skipif(git_missing, reason="git not installed")
|
|
104
|
+
def test_checkout_with_doctor(tinyhorse_repo):
|
|
105
|
+
repo, locator = tinyhorse_repo
|
|
106
|
+
graph, prov = from_checkout(repo, doctor=True)
|
|
107
|
+
assert len(graph.nodes) == 37
|
|
108
|
+
assert prov.project == "Proj" and prov.storage == locator
|
|
109
|
+
assert prov.relation == "in_sync" and prov.doctor_ok
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@pytest.mark.skipif(git_missing, reason="git not installed")
|
|
113
|
+
def test_storage_reads_canonical_head_without_checkout(tinyhorse_repo, tmp_path):
|
|
114
|
+
repo, locator = tinyhorse_repo
|
|
115
|
+
head = subprocess.run(["git", "rev-parse", "HEAD"], cwd=repo, capture_output=True, text=True).stdout.strip()
|
|
116
|
+
# local edit that was never parked must NOT show up when reading from storage
|
|
117
|
+
p = repo / "docs/runner-gateway/NODE-GRAPH.json"
|
|
118
|
+
data = json.loads(p.read_text())
|
|
119
|
+
data["nodes"][0]["status"] = "open"
|
|
120
|
+
p.write_text(json.dumps(data))
|
|
121
|
+
graph, prov = from_storage(locator)
|
|
122
|
+
assert prov.commit == head
|
|
123
|
+
assert graph.nodes["SRC-0"].status == "done"
|
|
124
|
+
out = tmp_path / "s.html"
|
|
125
|
+
assert main(["render", "--storage", locator, "--out", str(out)]) == 0 and out.exists()
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def test_cli_file(tmp_path, capsys):
|
|
129
|
+
assert main(["check", "--file", str(EXAMPLE)]) == 0
|
|
130
|
+
assert main(["next", "--file", str(EXAMPLE), "--json"]) == 0
|
|
131
|
+
assert "REL-0" in capsys.readouterr().out
|
|
132
|
+
assert main(["path", "--file", str(EXAMPLE)]) == 0
|