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.
@@ -0,0 +1,5 @@
1
+ .venv/
2
+ dist/
3
+ __pycache__/
4
+ *.html
5
+ *.world.json
@@ -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,2 @@
1
+ from .cli import main
2
+ raise SystemExit(main())
@@ -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=>({{'&':'&amp;','<':'&lt;','>':'&gt;'}})[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