techtree 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (174) hide show
  1. techtree/__init__.py +35 -0
  2. techtree/__main__.py +14 -0
  3. techtree/canonical.py +239 -0
  4. techtree/catalog/__init__.py +25 -0
  5. techtree/catalog/repository.py +400 -0
  6. techtree/catalog/service.py +419 -0
  7. techtree/cli/__init__.py +1 -0
  8. techtree/cli/app.py +416 -0
  9. techtree/cli/commands/__init__.py +1 -0
  10. techtree/cli/commands/climb.py +1223 -0
  11. techtree/cli/commands/doctor.py +147 -0
  12. techtree/cli/commands/engine.py +207 -0
  13. techtree/cli/commands/proof.py +556 -0
  14. techtree/cli/commands/publish.py +447 -0
  15. techtree/cli/commands/release.py +303 -0
  16. techtree/cli/commands/run.py +1067 -0
  17. techtree/cli/commands/setup.py +181 -0
  18. techtree/cli/commands/skill.py +221 -0
  19. techtree/cli/commands/uplift.py +698 -0
  20. techtree/cli/commands/withdraw.py +212 -0
  21. techtree/cli/confirm.py +47 -0
  22. techtree/cli/context.py +96 -0
  23. techtree/cli/invoke.py +220 -0
  24. techtree/cli/output.py +280 -0
  25. techtree/constants.py +138 -0
  26. techtree/crypto.py +128 -0
  27. techtree/doctor/__init__.py +1 -0
  28. techtree/doctor/checks.py +675 -0
  29. techtree/doctor/execution_checks.py +435 -0
  30. techtree/doctor/service.py +326 -0
  31. techtree/drafts/__init__.py +32 -0
  32. techtree/drafts/source.py +146 -0
  33. techtree/drafts/store.py +992 -0
  34. techtree/engines/__init__.py +1 -0
  35. techtree/engines/bundle.py +251 -0
  36. techtree/engines/installer.py +679 -0
  37. techtree/engines/registry.py +235 -0
  38. techtree/engines/runner.py +170 -0
  39. techtree/errors.py +262 -0
  40. techtree/fs.py +234 -0
  41. techtree/harness.py +108 -0
  42. techtree/identity/__init__.py +41 -0
  43. techtree/identity/models.py +113 -0
  44. techtree/identity/service.py +199 -0
  45. techtree/identity/store.py +263 -0
  46. techtree/ids.py +85 -0
  47. techtree/manifests/__init__.py +39 -0
  48. techtree/manifests/builder.py +433 -0
  49. techtree/manifests/compare.py +376 -0
  50. techtree/models/__init__.py +282 -0
  51. techtree/models/base.py +201 -0
  52. techtree/models/campaign.py +484 -0
  53. techtree/models/catalog.py +227 -0
  54. techtree/models/cli.py +151 -0
  55. techtree/models/climb.py +254 -0
  56. techtree/models/data_policy.py +130 -0
  57. techtree/models/engine.py +156 -0
  58. techtree/models/episode_receipt.py +130 -0
  59. techtree/models/evaluation_backend.py +113 -0
  60. techtree/models/experiment.py +154 -0
  61. techtree/models/run.py +214 -0
  62. techtree/models/skill.py +156 -0
  63. techtree/models/uplift_report.py +158 -0
  64. techtree/models/validation.py +299 -0
  65. techtree/paths.py +116 -0
  66. techtree/presentation/__init__.py +31 -0
  67. techtree/presentation/build.py +1242 -0
  68. techtree/presentation/compact.py +246 -0
  69. techtree/presentation/evidence.py +169 -0
  70. techtree/presentation/models.py +358 -0
  71. techtree/presentation/rich.py +312 -0
  72. techtree/presentation/sanitize.py +156 -0
  73. techtree/publication/__init__.py +44 -0
  74. techtree/publication/address.py +180 -0
  75. techtree/publication/coordinates.py +26 -0
  76. techtree/publication/journal.py +212 -0
  77. techtree/publication/keccak.py +183 -0
  78. techtree/publication/models.py +209 -0
  79. techtree/publication/offer.py +35 -0
  80. techtree/publication/service.py +618 -0
  81. techtree/publication/transport.py +296 -0
  82. techtree/publication/verify.py +242 -0
  83. techtree/publication/withdraw.py +156 -0
  84. techtree/py.typed +0 -0
  85. techtree/receipts/__init__.py +52 -0
  86. techtree/receipts/bundle.py +578 -0
  87. techtree/receipts/compare.py +1065 -0
  88. techtree/receipts/episode.py +672 -0
  89. techtree/receipts/execution.py +630 -0
  90. techtree/receipts/observed.py +474 -0
  91. techtree/receipts/set.py +336 -0
  92. techtree/receipts/uplift.py +655 -0
  93. techtree/receipts/verify.py +1055 -0
  94. techtree/release/__init__.py +9 -0
  95. techtree/release/bootstrap.py +509 -0
  96. techtree/release/checks.py +376 -0
  97. techtree/release/document.py +125 -0
  98. techtree/release/generate.py +221 -0
  99. techtree/release/models.py +293 -0
  100. techtree/release/provenance.py +109 -0
  101. techtree/resources/catalog/campaigns/hello-world-climb.json +1 -0
  102. techtree/resources/catalog/catalog.json +32 -0
  103. techtree/resources/catalog/climbs/hello-world-climb.json +1 -0
  104. techtree/resources/catalog/data-policies/hello-world-climb.json +1 -0
  105. techtree/resources/catalog/taskset-validations/hello-world-climb.json +1 -0
  106. techtree/resources/catalog/validation-evidence/hello-world-climb.json +1 -0
  107. techtree/resources/engines/default/engine.json +20 -0
  108. techtree/resources/engines/default/packages/procedure-transfer-v1/procedure_transfer_v1/__init__.py +7 -0
  109. techtree/resources/engines/default/packages/procedure-transfer-v1/procedure_transfer_v1/algorithm.py +136 -0
  110. techtree/resources/engines/default/packages/procedure-transfer-v1/procedure_transfer_v1/dataset.py +156 -0
  111. techtree/resources/engines/default/packages/procedure-transfer-v1/procedure_transfer_v1/env.py +48 -0
  112. techtree/resources/engines/default/packages/procedure-transfer-v1/procedure_transfer_v1/taskset.py +163 -0
  113. techtree/resources/engines/default/packages/procedure-transfer-v1/pyproject.toml +13 -0
  114. techtree/resources/engines/default/pyproject.toml +23 -0
  115. techtree/resources/engines/default/tools/inspect_taskset.py +124 -0
  116. techtree/resources/engines/default/tools/normalize_eval_output.py +470 -0
  117. techtree/resources/engines/default/tools/normalize_validation.py +222 -0
  118. techtree/resources/engines/default/uv.lock +1758 -0
  119. techtree/resources/harness/hermes-agent-0.19.0.json +69 -0
  120. techtree/resources/release/build-provenance.json +4 -0
  121. techtree/resources/release/release-core.json +24 -0
  122. techtree/runs/__init__.py +31 -0
  123. techtree/runs/artifacts.py +750 -0
  124. techtree/runs/child_registry.py +228 -0
  125. techtree/runs/events.py +478 -0
  126. techtree/runs/executor.py +140 -0
  127. techtree/runs/fake.py +741 -0
  128. techtree/runs/launcher.py +253 -0
  129. techtree/runs/machine.py +489 -0
  130. techtree/runs/real.py +789 -0
  131. techtree/runs/service.py +616 -0
  132. techtree/runs/store.py +555 -0
  133. techtree/runs/validation.py +259 -0
  134. techtree/runs/variants.py +684 -0
  135. techtree/settings.py +143 -0
  136. techtree/skills/__init__.py +14 -0
  137. techtree/skills/archive.py +282 -0
  138. techtree/skills/policy.py +62 -0
  139. techtree/skills/scanner.py +394 -0
  140. techtree/skills/service.py +752 -0
  141. techtree/skills/starter.py +434 -0
  142. techtree/tasksets/__init__.py +1 -0
  143. techtree/tasksets/membership.py +269 -0
  144. techtree/tasksets/provider.py +207 -0
  145. techtree/tasksets/resolver.py +311 -0
  146. techtree/tasksets/service.py +484 -0
  147. techtree/tasksets/verifiers_cli.py +538 -0
  148. techtree/uplift/__init__.py +20 -0
  149. techtree/uplift/context.py +544 -0
  150. techtree/uplift/derive.py +203 -0
  151. techtree/uplift/public_tasks.py +151 -0
  152. techtree/uplift/service.py +719 -0
  153. techtree/uplift/source.py +160 -0
  154. techtree/verifiers/__init__.py +31 -0
  155. techtree/verifiers/budget.py +219 -0
  156. techtree/verifiers/child.py +633 -0
  157. techtree/verifiers/compiler.py +432 -0
  158. techtree/verifiers/config.py +365 -0
  159. techtree/verifiers/credentials.py +321 -0
  160. techtree/verifiers/image.py +126 -0
  161. techtree/verifiers/models.py +527 -0
  162. techtree/verifiers/outputs.py +368 -0
  163. techtree/verifiers/progress.py +192 -0
  164. techtree/verifiers/supervisor.py +341 -0
  165. techtree/verifiers/verify.py +782 -0
  166. techtree/version.py +39 -0
  167. techtree/worker/__init__.py +18 -0
  168. techtree/worker/execute.py +487 -0
  169. techtree/worker/main.py +57 -0
  170. techtree-0.1.0.dist-info/METADATA +344 -0
  171. techtree-0.1.0.dist-info/RECORD +174 -0
  172. techtree-0.1.0.dist-info/WHEEL +4 -0
  173. techtree-0.1.0.dist-info/entry_points.txt +3 -0
  174. techtree-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,9 @@
1
+ """The release document that binds this build to one Climb v0.1 release.
2
+
3
+ Spec sections 6.6, 9.3-9.5 and 9.7. Everything here is about *coordinates*:
4
+ which CLI version, which source commit, which engine, which catalog, which
5
+ Skills. Nothing in this package runs a Climb, calls a model, or writes to the
6
+ network, and nothing outside it is allowed to invent a release coordinate.
7
+ """
8
+
9
+ from __future__ import annotations
@@ -0,0 +1,509 @@
1
+ """Checking a website bootstrap document from the side that produced it.
2
+
3
+ Spec sections 9.3.2, 9.4 and 9.7.
4
+
5
+ The website publishes a ``BootstrapRelease``: the wrapper that tells a new
6
+ operator which CLI version to install, which plugin commit to install, and
7
+ which Climb to start with. It is generated after this repository's ReleaseCore
8
+ exists, it is served as exact bytes, and the website refuses to import one that
9
+ does not hold together.
10
+
11
+ This module checks the same document from the producing end, and it does that
12
+ for two different reasons that are worth keeping apart.
13
+
14
+ *Does the wrapper still name this release?* The bootstrap and the ReleaseCore
15
+ repeat four coordinates — the CLI version, the minimum host Hermes version, the
16
+ introductory Climb, and the starter Skill. Repeated values drift, and when they
17
+ do the website tells operators to install one thing while the CLI believes
18
+ another. Each repeat is compared individually here so a failure names the
19
+ coordinate.
20
+
21
+ The wrapper also states one coordinate the release document deliberately does
22
+ not carry: which source commit the published wheel was built from. Decisions
23
+ 0026 puts that fact where it can be known — stamped into the wheel by the build
24
+ — so the comparison here is against the wheel itself, not against a claim the
25
+ release repeats about itself.
26
+
27
+ The starter Skill is the coordinate with the most to lose. Spec sections 4.1
28
+ and 10.5 make the wrapper the thing that says where the public Skill object is
29
+ served from, and the release document the thing that says which bytes count.
30
+ The wrapper carries both halves of the object: ``file_digest``, the bytes the
31
+ address returns, and ``tree_digest``, the one-file Skill the CLI builds out of
32
+ them and verifies before it runs anything. The address must be keyed by the
33
+ file digest, because that is what the website serves it under, and the tree
34
+ digest must be the one this release measured.
35
+
36
+ *Would the website accept these bytes at all?* The shape rules are the
37
+ website's, not this repository's, and they stay the website's: it re-checks
38
+ every one of them at import. Restating them here is a pre-flight, so that a
39
+ release that cannot be published is caught while it is still being assembled
40
+ rather than at deploy time.
41
+
42
+ One thing is deliberately not checked: the plugin commit. A ReleaseCore does
43
+ not contain one (spec section 6.6) precisely so that the plugin can embed the
44
+ ReleaseCore without a cycle, so nothing here can have an opinion about which
45
+ commit the wrapper pins — only that it pins a full, immutable one.
46
+ """
47
+
48
+ from __future__ import annotations
49
+
50
+ import json
51
+ import re
52
+ from collections.abc import Mapping
53
+ from datetime import datetime
54
+ from typing import Final
55
+
56
+ from techtree.models.base import DIGEST_PATTERN, JsonValue
57
+ from techtree.release.checks import (
58
+ ReleaseCheck,
59
+ ReleaseVerification,
60
+ )
61
+ from techtree.release.models import (
62
+ OBJECT_URL_PATTERN,
63
+ ReleaseCore,
64
+ object_url_digest,
65
+ )
66
+ from techtree.release.provenance import COMMIT_PATTERN, BuildProvenance
67
+
68
+ __all__ = [
69
+ "BOOTSTRAP_RELEASE_INVALID",
70
+ "BOOTSTRAP_RELEASE_MISMATCH",
71
+ "BOOTSTRAP_SCHEMA_VERSION",
72
+ "verify_bootstrap_document",
73
+ ]
74
+
75
+ #: The schema version the website's importer accepts, and nothing else.
76
+ BOOTSTRAP_SCHEMA_VERSION: Final = "techtree.bootstrap.v1alpha1"
77
+
78
+ BOOTSTRAP_RELEASE_INVALID: Final = "bootstrap_release_invalid"
79
+ BOOTSTRAP_RELEASE_MISMATCH: Final = "bootstrap_release_mismatch"
80
+
81
+ #: How the published install command names an interpreter to the installer.
82
+ #: The value beside it is never written here — it is read from the document
83
+ #: being checked (decision 0034).
84
+ _INTERPRETER_FLAG: Final = "--python"
85
+
86
+ _COMMIT_RE = re.compile(COMMIT_PATTERN)
87
+ _OBJECT_URL_RE = re.compile(OBJECT_URL_PATTERN)
88
+ _DIGEST_RE = re.compile(DIGEST_PATTERN)
89
+
90
+ #: Every field the website importer requires, and the kind it requires. Listed
91
+ #: as paths so a failure can name the field the way the document spells it.
92
+ _REQUIRED_FIELDS: Final[tuple[tuple[tuple[str, ...], str], ...]] = (
93
+ (("channel",), "string"),
94
+ # The website's own declaration of whether it is serving a development
95
+ # bootstrap or a published one (decisions 0026 section 3). It says nothing
96
+ # about the release document, which is concrete either way.
97
+ (("placeholder_release",), "boolean"),
98
+ (("published_at",), "timestamp"),
99
+ (("minimums", "hermes_version"), "string"),
100
+ (("cli", "distribution"), "string"),
101
+ (("cli", "version"), "string"),
102
+ (("cli", "install_argv"), "argv"),
103
+ (("cli", "source_revision"), "commit"),
104
+ (("hermes_plugin", "plugin_id"), "string"),
105
+ (("hermes_plugin", "revision"), "commit"),
106
+ (("hermes_plugin", "install_argv"), "argv"),
107
+ (("hermes_plugin", "doctor_argv"), "argv"),
108
+ (("introductory_climb", "reference"), "string"),
109
+ (("introductory_climb", "host_prompt"), "string"),
110
+ (("starter_skill", "name"), "string"),
111
+ (("starter_skill", "object_url"), "object URL"),
112
+ (("starter_skill", "file_digest"), "digest"),
113
+ (("starter_skill", "tree_digest"), "digest"),
114
+ (("starter_skill", "media_type"), "string"),
115
+ (("starter_skill", "size"), "byte count"),
116
+ )
117
+
118
+ #: The kinds whose name does not take "a". Spelled out rather than derived,
119
+ #: because the list is short and a vowel rule would be wrong for "URL".
120
+ _IRREGULAR_ARTICLES: Final[Mapping[str, str]] = {
121
+ "argv": "an argument array",
122
+ "object URL": "an object URL",
123
+ "byte count": "a positive byte count",
124
+ }
125
+
126
+
127
+ def verify_bootstrap_document(
128
+ core: ReleaseCore, raw: bytes, *, wheel: BuildProvenance, wheel_sha256: str
129
+ ) -> ReleaseVerification:
130
+ """Check one bootstrap document against the release it should wrap.
131
+
132
+ ``wheel`` is the provenance stamped into the CLI wheel this bootstrap
133
+ publishes. It is required rather than optional: the document names a source
134
+ commit, and the only thing that can confirm it is the artifact itself.
135
+
136
+ ``wheel_sha256`` is that same file's SHA-256, in lowercase hex without a
137
+ prefix, computed by the caller from the bytes it holds. The stamp cannot
138
+ carry it — decision 0026: an artifact never names its own identity — so the
139
+ one thing that can confirm the digest the document publishes is a fresh
140
+ hash of the file. Without this the gate would bind a wheel by name and by
141
+ commit while never checking the number a participant actually installs
142
+ against.
143
+ """
144
+ try:
145
+ document = json.loads(raw)
146
+ except ValueError as error:
147
+ return _one(
148
+ _failed(
149
+ "bootstrap_document",
150
+ BOOTSTRAP_RELEASE_INVALID,
151
+ f"the bootstrap document is not JSON: {error}",
152
+ )
153
+ )
154
+ if not isinstance(document, dict):
155
+ return _one(
156
+ _failed(
157
+ "bootstrap_document",
158
+ BOOTSTRAP_RELEASE_INVALID,
159
+ "the bootstrap document must be a JSON object.",
160
+ )
161
+ )
162
+
163
+ schema = _schema_version_check(document)
164
+ if schema.status == "failed":
165
+ return _one(schema)
166
+
167
+ contract = _importer_contract_check(document)
168
+ checks = [schema, contract]
169
+ if contract.status == "failed":
170
+ # Every comparison below reads a field the contract just found missing
171
+ # or mis-typed, so running them would only repeat one failure.
172
+ return ReleaseVerification(verified=False, checks=checks)
173
+
174
+ checks.extend(
175
+ [
176
+ _coordinate_check(
177
+ "bootstrap_cli_version",
178
+ document,
179
+ ("cli", "version"),
180
+ core.cli_version,
181
+ "the CLI version",
182
+ ),
183
+ _wheel_commit_check(document, wheel),
184
+ _wheel_digest_check(document, wheel_sha256),
185
+ _coordinate_check(
186
+ "bootstrap_hermes_minimum",
187
+ document,
188
+ ("minimums", "hermes_version"),
189
+ core.minimum_host_hermes_version,
190
+ "the minimum host Hermes version",
191
+ ),
192
+ _coordinate_check(
193
+ "bootstrap_intro_climb",
194
+ document,
195
+ ("introductory_climb", "reference"),
196
+ core.intro_climb_reference,
197
+ "the introductory Climb",
198
+ ),
199
+ _coordinate_check(
200
+ "bootstrap_starter_skill_object_url",
201
+ document,
202
+ ("starter_skill", "object_url"),
203
+ core.starter_skill_object_url,
204
+ "the starter Skill object URL",
205
+ ),
206
+ _coordinate_check(
207
+ "bootstrap_starter_skill_tree_digest",
208
+ document,
209
+ ("starter_skill", "tree_digest"),
210
+ core.starter_skill_digest,
211
+ "the starter Skill tree digest",
212
+ ),
213
+ _starter_skill_address_check(document),
214
+ _cli_install_argv_check(core, document),
215
+ _plugin_install_argv_check(document),
216
+ ]
217
+ )
218
+ return ReleaseVerification(
219
+ verified=not any(check.status == "failed" for check in checks),
220
+ checks=checks,
221
+ )
222
+
223
+
224
+ # ---------------------------------------------------------------------------
225
+ # The checks
226
+ # ---------------------------------------------------------------------------
227
+
228
+
229
+ def _schema_version_check(document: dict[str, JsonValue]) -> ReleaseCheck:
230
+ found = document.get("schema_version")
231
+ if found == BOOTSTRAP_SCHEMA_VERSION:
232
+ return _passed(
233
+ "bootstrap_schema_version",
234
+ f"the bootstrap document is a {BOOTSTRAP_SCHEMA_VERSION}.",
235
+ )
236
+ return _failed(
237
+ "bootstrap_schema_version",
238
+ BOOTSTRAP_RELEASE_INVALID,
239
+ f"the bootstrap document declares {found!r}; the website imports only "
240
+ f"{BOOTSTRAP_SCHEMA_VERSION}.",
241
+ )
242
+
243
+
244
+ def _importer_contract_check(document: dict[str, JsonValue]) -> ReleaseCheck:
245
+ """Report every field the website would refuse the document over."""
246
+ problems = [
247
+ f"{'.'.join(path)} must be {_article(kind)}"
248
+ for path, kind in _REQUIRED_FIELDS
249
+ if not _holds(_lookup(document, path), kind)
250
+ ]
251
+ if problems:
252
+ return _failed(
253
+ "bootstrap_importer_contract",
254
+ BOOTSTRAP_RELEASE_INVALID,
255
+ "the website would refuse this document: " + "; ".join(problems) + ".",
256
+ )
257
+ return _passed(
258
+ "bootstrap_importer_contract",
259
+ f"all {len(_REQUIRED_FIELDS)} fields the website requires are present "
260
+ "and well formed.",
261
+ )
262
+
263
+
264
+ def _wheel_commit_check(
265
+ document: dict[str, JsonValue], wheel: BuildProvenance
266
+ ) -> ReleaseCheck:
267
+ """The commit the wrapper publishes must be the wheel's own stamp.
268
+
269
+ The release document says nothing about which commit built which artifact
270
+ (decisions 0026), so this is the one coordinate here that is compared
271
+ against the artifact rather than against the release: the wheel was
272
+ stamped while it was built, and the wrapper repeats that stamp to
273
+ operators.
274
+ """
275
+ published = _lookup(document, ("cli", "source_revision"))
276
+ if published == wheel.source_commit:
277
+ return _passed(
278
+ "bootstrap_cli_source_revision",
279
+ f"the published CLI was built from {wheel.source_commit}, which is "
280
+ "what the wheel is stamped with.",
281
+ )
282
+ return _failed(
283
+ "bootstrap_cli_source_revision",
284
+ BOOTSTRAP_RELEASE_MISMATCH,
285
+ f"the bootstrap document says the published CLI was built from "
286
+ f"{published!r}, and the wheel is stamped {wheel.source_commit!r}.",
287
+ )
288
+
289
+
290
+ def _wheel_digest_check(
291
+ document: dict[str, JsonValue], wheel_sha256: str
292
+ ) -> ReleaseCheck:
293
+ """The digest the document publishes must be this wheel's own."""
294
+ published = _lookup(document, ("cli", "wheel_sha256"))
295
+ expected = f"sha256:{wheel_sha256}"
296
+ if published == expected:
297
+ return _passed(
298
+ "bootstrap_cli_wheel_sha256",
299
+ f"the published CLI wheel hashes to {expected}, which is what the "
300
+ "bootstrap document names.",
301
+ )
302
+ return _failed(
303
+ "bootstrap_cli_wheel_sha256",
304
+ BOOTSTRAP_RELEASE_MISMATCH,
305
+ f"the bootstrap document publishes the wheel digest {published!r}, and "
306
+ f"the wheel handed to this check hashes to {expected!r}.",
307
+ )
308
+
309
+
310
+ def _starter_skill_address_check(document: dict[str, JsonValue]) -> ReleaseCheck:
311
+ """The published address must be keyed by the bytes it returns.
312
+
313
+ The website files public objects under the digest of the file it serves,
314
+ and refuses an address keyed by anything else. A fetcher checks what
315
+ arrives against the address before it has built anything, so an address
316
+ keyed by the tree digest — or by nothing at all — would leave the first
317
+ check with nothing to compare against.
318
+ """
319
+ url = _lookup(document, ("starter_skill", "object_url"))
320
+ file_digest = _lookup(document, ("starter_skill", "file_digest"))
321
+ if not isinstance(url, str) or not isinstance(file_digest, str):
322
+ raise AssertionError("both fields are validated before they are read")
323
+
324
+ keyed = object_url_digest(url)
325
+ if keyed == file_digest:
326
+ return _passed(
327
+ "bootstrap_starter_skill_address",
328
+ f"the starter Skill is published at the digest of its own bytes, "
329
+ f"{file_digest}.",
330
+ )
331
+ return _failed(
332
+ "bootstrap_starter_skill_address",
333
+ BOOTSTRAP_RELEASE_MISMATCH,
334
+ f"the starter Skill address is keyed by {keyed} and the document says "
335
+ f"the bytes it returns are {file_digest}; a fetcher would check what "
336
+ "arrives against the wrong digest.",
337
+ )
338
+
339
+
340
+ def _coordinate_check(
341
+ identifier: str,
342
+ document: dict[str, JsonValue],
343
+ path: tuple[str, ...],
344
+ expected: str,
345
+ subject: str,
346
+ ) -> ReleaseCheck:
347
+ found = _lookup(document, path)
348
+ if found == expected:
349
+ return _passed(identifier, f"{subject} agrees: {expected}.")
350
+ return _failed(
351
+ identifier,
352
+ BOOTSTRAP_RELEASE_MISMATCH,
353
+ f"{subject} is {found!r} in the bootstrap document and {expected!r} in "
354
+ "the ReleaseCore.",
355
+ )
356
+
357
+
358
+ def _cli_install_argv_check(
359
+ core: ReleaseCore, document: dict[str, JsonValue]
360
+ ) -> ReleaseCheck:
361
+ """The published install command must install the published version, and
362
+ install it on the interpreter this same document requires.
363
+
364
+ Left to choose, the installer takes whatever Python the machine already
365
+ treats as its default — which can be a version Techtree does not support.
366
+ The install then succeeds and the first thing the operator sees is Doctor
367
+ saying the interpreter is wrong, after running the exact command this
368
+ project published (decision 0034). So the command pins the interpreter.
369
+
370
+ Which interpreter is not restated here. It is read from the document's own
371
+ requirements, because a second written-out copy of one number is how a
372
+ document ends up telling someone to install on an interpreter it also
373
+ calls unsupported. That makes the two halves one fact, and this check is
374
+ what keeps them one fact.
375
+ """
376
+ argv = _string_list(_lookup(document, ("cli", "install_argv")))
377
+ distribution = _lookup(document, ("cli", "distribution"))
378
+ interpreter = _lookup(document, ("minimums", "python"))
379
+ pin = f"{distribution}=={core.cli_version}"
380
+
381
+ if pin not in argv:
382
+ return _failed(
383
+ "bootstrap_cli_install_argv",
384
+ BOOTSTRAP_RELEASE_MISMATCH,
385
+ f"the published install command does not pin {pin}; it is {argv}.",
386
+ )
387
+ if not _pins_interpreter(argv, interpreter):
388
+ return _failed(
389
+ "bootstrap_cli_install_argv",
390
+ BOOTSTRAP_RELEASE_MISMATCH,
391
+ "the published install command does not pin the interpreter this "
392
+ f"document requires: minimums.python is {interpreter!r} and the "
393
+ f"command is {argv}.",
394
+ )
395
+ return _passed(
396
+ "bootstrap_cli_install_argv",
397
+ f"the published install command pins {pin} and installs it on the "
398
+ f"Python {interpreter} this document requires.",
399
+ )
400
+
401
+
402
+ def _pins_interpreter(argv: list[str], interpreter: JsonValue) -> bool:
403
+ """Return whether the command names that interpreter to the installer."""
404
+ if not isinstance(interpreter, str) or not interpreter.strip():
405
+ return False
406
+ wanted = (_INTERPRETER_FLAG, interpreter)
407
+ return any(
408
+ tuple(argv[position : position + 2]) == wanted
409
+ for position in range(len(argv) - 1)
410
+ )
411
+
412
+
413
+ def _plugin_install_argv_check(document: dict[str, JsonValue]) -> ReleaseCheck:
414
+ """The published plugin command must install the commit it names."""
415
+ revision = _lookup(document, ("hermes_plugin", "revision"))
416
+ argv = _string_list(_lookup(document, ("hermes_plugin", "install_argv")))
417
+ if revision in argv:
418
+ return _passed(
419
+ "bootstrap_plugin_install_argv",
420
+ f"the published plugin command installs the exact commit {revision}.",
421
+ )
422
+ return _failed(
423
+ "bootstrap_plugin_install_argv",
424
+ BOOTSTRAP_RELEASE_MISMATCH,
425
+ f"the published plugin command does not name the commit {revision} "
426
+ f"the document pins; it is {argv}.",
427
+ )
428
+
429
+
430
+ # ---------------------------------------------------------------------------
431
+ # Reading the document
432
+ # ---------------------------------------------------------------------------
433
+
434
+
435
+ def _lookup(document: dict[str, JsonValue], path: tuple[str, ...]) -> JsonValue:
436
+ """Return the value at a path, or None when any step is absent."""
437
+ current: JsonValue = document
438
+ for step in path:
439
+ if not isinstance(current, dict) or step not in current:
440
+ return None
441
+ current = current[step]
442
+ return current
443
+
444
+
445
+ def _holds(value: JsonValue, kind: str) -> bool:
446
+ """Return whether a value is of the kind the website requires."""
447
+ match kind:
448
+ case "string":
449
+ return isinstance(value, str) and bool(value.strip())
450
+ case "boolean":
451
+ return isinstance(value, bool)
452
+ case "timestamp":
453
+ return isinstance(value, str) and _is_instant(value)
454
+ case "commit":
455
+ return isinstance(value, str) and _COMMIT_RE.fullmatch(value) is not None
456
+ case "digest":
457
+ return isinstance(value, str) and _DIGEST_RE.fullmatch(value) is not None
458
+ case "object URL":
459
+ return (
460
+ isinstance(value, str) and _OBJECT_URL_RE.fullmatch(value) is not None
461
+ )
462
+ case "argv":
463
+ return _is_argv(value)
464
+ case "byte count":
465
+ return isinstance(value, int) and not isinstance(value, bool) and value > 0
466
+ raise AssertionError(f"unknown field kind {kind!r}")
467
+
468
+
469
+ def _is_instant(value: str) -> bool:
470
+ try:
471
+ datetime.fromisoformat(value)
472
+ except ValueError:
473
+ return False
474
+ return True
475
+
476
+
477
+ def _is_argv(value: JsonValue) -> bool:
478
+ """Return whether a value is an argument array rather than a command line.
479
+
480
+ A string here would be a shell command, and the whole point of publishing
481
+ argument arrays is that nothing the website serves is ever handed to a
482
+ shell (spec section 9.12).
483
+ """
484
+ if not isinstance(value, list) or not value:
485
+ return False
486
+ return all(isinstance(item, str) and bool(item.strip()) for item in value)
487
+
488
+
489
+ def _string_list(value: JsonValue) -> list[str]:
490
+ """Return an already-validated argument array as strings."""
491
+ if not isinstance(value, list):
492
+ raise AssertionError("argument arrays are validated before they are read")
493
+ return [item for item in value if isinstance(item, str)]
494
+
495
+
496
+ def _article(kind: str) -> str:
497
+ return _IRREGULAR_ARTICLES.get(kind, f"a {kind}")
498
+
499
+
500
+ def _passed(identifier: str, detail: str) -> ReleaseCheck:
501
+ return ReleaseCheck(id=identifier, status="passed", code="ok", detail=detail)
502
+
503
+
504
+ def _failed(identifier: str, code: str, detail: str) -> ReleaseCheck:
505
+ return ReleaseCheck(id=identifier, status="failed", code=code, detail=detail)
506
+
507
+
508
+ def _one(check: ReleaseCheck) -> ReleaseVerification:
509
+ return ReleaseVerification(verified=check.status != "failed", checks=[check])