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,1223 @@
1
+ """``techtree climb list``, ``show``, and ``prepare``. Spec 12.6 and PR6 §6.9.
2
+
3
+ No command here decides anything about a Climb. The catalog service resolves
4
+ the graph and answers whether this machine could run it; the preparation
5
+ service turns a directory into an immutable draft. These functions turn those
6
+ answers into one envelope, some warnings, and at most three next steps.
7
+
8
+ Four translations are worth naming.
9
+
10
+ A compatibility issue becomes a next action only when something runnable would
11
+ address it. An absent engine has an install command; an unsupported machine has
12
+ nothing Techtree could offer to run, so it is stated and no action is invented.
13
+
14
+ A development Climb is announced as a warning in both output modes rather than
15
+ only in the human rendering. A host agent reading JSON is exactly the caller
16
+ most likely to treat a fixture result as evidence, so the caveat travels with
17
+ the data.
18
+
19
+ ``show`` returns a payload rather than a bare summary. Four facts a reader
20
+ needs before entering a Climb — which model answers, where it runs, which
21
+ reward decides the comparison, and who owns a submitted skill — have no field
22
+ on :class:`~techtree.models.catalog.ClimbSummary`, and a host agent should not
23
+ have to read them out of a rendered table.
24
+
25
+ ``prepare`` writes the draft and stops. The start action it offers names the
26
+ draft and nothing else, and is marked as requiring a person's confirmation,
27
+ because starting a run commits to both rights and work.
28
+
29
+ ``start`` is where that commitment is collected. Decisions document 0019
30
+ section 2 makes it one gesture rather than two handles: the five things a
31
+ person has to weigh — how much work this is, the most the Campaign declares it
32
+ may cost, that the Skill is the only scientific change, where the model calls
33
+ go, and what an upload would and would not carry — are printed, the rights
34
+ summary is printed
35
+ under them, and the
36
+ answer is a plain ``y``. An operator who cannot be asked passes ``--yes``
37
+ instead, which is an explicit act by a person configuring a machine and never a
38
+ shortcut a model may take on somebody's behalf.
39
+
40
+ The same review can also be answered somewhere else. When the plugin starts a
41
+ draft, Hermes has already shown the review and taken the person's confirmation
42
+ through its own dispatch gate, and this command is only the thing that writes
43
+ the record; ``--reviewed-on host-agent`` is how that is said, so the run
44
+ records the surface the answer was really given on rather than the surface the
45
+ writing happened on. Either way the run records that the review was shown and
46
+ accepted, and its ``run.approved`` event records who gave the answer.
47
+
48
+ The command returns as soon as the worker is launched. The run continues after
49
+ this process exits, which is the whole point, and the response says where to
50
+ look rather than waiting to find out.
51
+ """
52
+
53
+ from __future__ import annotations
54
+
55
+ from dataclasses import dataclass
56
+ from enum import StrEnum
57
+ from pathlib import Path
58
+ from typing import Annotated, Final, Literal
59
+
60
+ import typer
61
+ from pydantic import PositiveFloat
62
+ from rich.console import Console
63
+ from rich.table import Table
64
+
65
+ from techtree.catalog.repository import EmbeddedCatalogRepository, climb_reference
66
+ from techtree.catalog.service import (
67
+ CatalogService,
68
+ InstalledEngineStatus,
69
+ current_host_info,
70
+ )
71
+ from techtree.cli.commands.run import build_run_service
72
+ from techtree.cli.confirm import confirmed
73
+ from techtree.cli.context import CliContext, cli_context
74
+ from techtree.cli.invoke import CommandResult, invoke_command
75
+ from techtree.cli.output import human_console, render_pairs
76
+ from techtree.drafts.source import CampaignSource
77
+ from techtree.drafts.store import DraftStore, utc_now
78
+ from techtree.errors import (
79
+ NotFoundError,
80
+ PolicyError,
81
+ PrerequisiteError,
82
+ UsageError,
83
+ )
84
+ from techtree.models.base import (
85
+ Digest,
86
+ NonEmptyString,
87
+ ProtocolModel,
88
+ )
89
+ from techtree.models.campaign import CampaignSpec, ModelSpec, RuntimeSpec
90
+ from techtree.models.catalog import (
91
+ ClimbSummary,
92
+ CompatibilityResult,
93
+ EngineCompatibilityStatus,
94
+ )
95
+ from techtree.models.cli import CliMessage, MessageLevel, NextAction
96
+ from techtree.models.climb import ResolvedClimb
97
+ from techtree.models.run import (
98
+ PolicyAcknowledgement,
99
+ RunPhase,
100
+ RunRequest,
101
+ RunStatus,
102
+ )
103
+ from techtree.models.skill import PolicyAcceptanceRequirement, SubmissionDraft
104
+ from techtree.runs.service import POLICY_ACCEPTANCE_REQUIRED, ApprovalActor
105
+ from techtree.skills.service import PreparedDraft, SkillPreparationService
106
+
107
+ __all__ = [
108
+ "LIST_COMMAND",
109
+ "PREPARE_COMMAND",
110
+ "REVIEW_SURFACE_NOT_APPROVED",
111
+ "SHOW_COMMAND",
112
+ "START_COMMAND",
113
+ "ClimbPreparePayload",
114
+ "ClimbShowPayload",
115
+ "ClimbStartPayload",
116
+ "PreparedComparison",
117
+ "ReviewSurface",
118
+ "RunApproval",
119
+ "abbreviated_digest",
120
+ "approve_run",
121
+ "build_catalog_service",
122
+ "build_preparation_service",
123
+ "list_climbs_command",
124
+ "phrase",
125
+ "prepare_climb_command",
126
+ "review_lines",
127
+ "show_climb_command",
128
+ "start_climb_command",
129
+ ]
130
+
131
+ LIST_COMMAND: Final = "climb list"
132
+ SHOW_COMMAND: Final = "climb show"
133
+ PREPARE_COMMAND: Final = "climb prepare"
134
+ START_COMMAND: Final = "climb start"
135
+
136
+ #: What a reader is told when the build ships no Climbs at all. The packaged
137
+ #: catalog is generated, so an empty one means this build was assembled without
138
+ #: running the generator rather than that there is nothing to run.
139
+ _NO_CLIMBS = "This build does not include any Climbs yet."
140
+
141
+
142
+ #: What a start says when a surface was declared but nothing was approved.
143
+ REVIEW_SURFACE_NOT_APPROVED: Final = "review_surface_not_approved"
144
+
145
+
146
+ class ReviewSurface(StrEnum):
147
+ """Where the person who approved this run answered.
148
+
149
+ Decisions document 0019 section 2 keeps two approval surfaces: this command
150
+ line, and the host agent's own confirmation UI. The process that writes the
151
+ run's record is not always the process that asked the question — when the
152
+ plugin starts a draft, Hermes asked and the CLI writes — so which surface
153
+ it was is declared rather than inferred from the fact that a flag was used.
154
+ """
155
+
156
+ CLI = "cli"
157
+ HOST_AGENT = "host-agent"
158
+
159
+
160
+ class ClimbShowPayload(ProtocolModel):
161
+ """What ``climb show`` returns: the summary, plus the Campaign facts.
162
+
163
+ The five extra fields are read straight off the resolved graph. They are
164
+ carried here rather than added to ``ClimbSummary`` because the summary is a
165
+ published protocol object with an exported schema, and this is one
166
+ command's response shape.
167
+
168
+ Decisions document 0007 R3 asks that a machine reader get both complete
169
+ digests. The Campaign's is on the summary, where it has always been and
170
+ where ``climb list`` also carries it; the DataPolicy's is here, because
171
+ the summary describes the rights in words but never named the document
172
+ they come from. Neither is ever abbreviated in the JSON — the shortening
173
+ is a courtesy to a terminal, and a caller comparing digests needs all of
174
+ both.
175
+ """
176
+
177
+ climb: ClimbSummary
178
+ data_policy_digest: Digest
179
+ subject_model: ModelSpec
180
+ subject_runtime: RuntimeSpec
181
+ primary_reward: NonEmptyString
182
+ candidate_skill_ownership: Literal["participant", "account", "shared"]
183
+
184
+
185
+ class PreparedComparison(ProtocolModel):
186
+ """The controlled-comparison result, as a reader needs to check it."""
187
+
188
+ controlled: bool
189
+ differences: list[NonEmptyString]
190
+ allowed_differences: list[NonEmptyString]
191
+
192
+
193
+ class ClimbPreparePayload(ProtocolModel):
194
+ """What ``climb prepare`` returns: the draft, and what it commits to."""
195
+
196
+ draft_id: NonEmptyString
197
+ draft_digest: Digest
198
+ climb_reference: NonEmptyString
199
+ climb_digest: Digest
200
+ campaign_spec_digest: Digest
201
+ data_policy_digest: Digest
202
+ candidate_label: NonEmptyString
203
+ skill_root_digest: Digest
204
+ included_files: list[NonEmptyString]
205
+ baseline_skill_count: int
206
+ candidate_skill_count: int
207
+ estimated_episodes: int
208
+ # Read off the Campaign this draft was prepared against, so a caller that
209
+ # renders a review shows the maximum this run is held to and never a figure
210
+ # from somewhere else. ``None`` is a Campaign that declares no maximum, and
211
+ # says so: there is then no figure to hold it to. Decision 0019 section 2
212
+ # puts the budget in the plugin's review the way it is already in the
213
+ # terminal's, and a review that has to invent the number is a review that
214
+ # would be wrong for the next Campaign.
215
+ campaign_maximum_usd: PositiveFloat | None
216
+ candidate_ownership: Literal["participant", "account", "shared"]
217
+ candidate_public_release: Literal[
218
+ "required_for_climb", "allowed", "prohibited", "consent_required"
219
+ ]
220
+ raw_episode_server_upload: Literal["allowed", "prohibited", "consent_required"]
221
+ raw_episode_training_use: Literal["allowed", "prohibited", "consent_required"]
222
+ proof_grade: Literal["development_only", "P1"]
223
+ policy_acceptance: PolicyAcceptanceRequirement
224
+ comparison: PreparedComparison
225
+ warnings: list[NonEmptyString]
226
+
227
+
228
+ class ClimbStartPayload(ProtocolModel):
229
+ """What ``climb start`` returns, as soon as the worker is running."""
230
+
231
+ run_id: NonEmptyString
232
+ draft_id: NonEmptyString
233
+ draft_digest: Digest
234
+ phase: RunPhase
235
+ worker_pid: int | None
236
+ campaign_spec_digest: Digest
237
+ data_policy_digest: Digest
238
+ policy_acknowledgement_method: Literal[
239
+ "explicit_cli_review",
240
+ "host_agent_confirmation",
241
+ ]
242
+ approved_by: ApprovalActor
243
+ #: Whether this run used the fake executor, and so called no model at all.
244
+ #: It is not the Climb's proof grade: a Climb whose results may never be
245
+ #: published is still run for real, against a real model, at real cost.
246
+ fake_executor: bool
247
+
248
+
249
+ def build_catalog_service(context: CliContext) -> CatalogService:
250
+ """Construct the service every command reads the catalog through."""
251
+ return CatalogService(
252
+ EmbeddedCatalogRepository.packaged(),
253
+ current_host_info(),
254
+ InstalledEngineStatus(context.paths),
255
+ )
256
+
257
+
258
+ def build_preparation_service(context: CliContext) -> SkillPreparationService:
259
+ """Construct the service ``prepare`` builds a draft through."""
260
+ return SkillPreparationService(
261
+ paths=context.paths,
262
+ catalog=build_catalog_service(context),
263
+ draft_store=DraftStore(context.paths),
264
+ )
265
+
266
+
267
+ def list_climbs_command(ctx: typer.Context) -> None:
268
+ """List public wrappers with resolved Campaign compatibility."""
269
+ context = cli_context(ctx)
270
+
271
+ def action() -> CommandResult[list[ClimbSummary]]:
272
+ summaries = build_catalog_service(context).list_climbs()
273
+
274
+ if not summaries:
275
+ return CommandResult(
276
+ data=summaries,
277
+ messages=[
278
+ CliMessage(
279
+ level=MessageLevel.INFO,
280
+ code="no_climbs_available",
281
+ text=_NO_CLIMBS,
282
+ )
283
+ ],
284
+ next_actions=[_check_environment()],
285
+ )
286
+
287
+ return CommandResult(
288
+ data=summaries,
289
+ messages=[
290
+ CliMessage(
291
+ level=MessageLevel.INFO,
292
+ code="climbs_available",
293
+ text=_available_summary(len(summaries)),
294
+ )
295
+ ],
296
+ warnings=_development_warnings(summaries),
297
+ next_actions=[_show_climb(summaries[0].reference)],
298
+ )
299
+
300
+ invoke_command(context, LIST_COMMAND, action, render_data=_render_list)
301
+
302
+
303
+ def show_climb_command(
304
+ ctx: typer.Context,
305
+ reference: Annotated[
306
+ str,
307
+ typer.Argument(
308
+ metavar="REFERENCE",
309
+ help="A Climb slug, slug@version, or public identifier.",
310
+ ),
311
+ ],
312
+ ) -> None:
313
+ """Show public policy, Campaign summary, data rights, and compatibility."""
314
+ context = cli_context(ctx)
315
+
316
+ def action() -> CommandResult[ClimbShowPayload]:
317
+ service = build_catalog_service(context)
318
+ try:
319
+ resolved = service.get_climb(reference)
320
+ except NotFoundError as error:
321
+ # The repository knows which Climbs exist; what to do about a name
322
+ # that is not one of them is the CLI's call. A catalog that is
323
+ # itself broken is a different failure and keeps its own repair.
324
+ if error.code == "climb_not_found":
325
+ error.next_actions = _unknown_climb_actions(error)
326
+ raise
327
+ summary = service.climb_summary(resolved)
328
+
329
+ return CommandResult(
330
+ data=_show_payload(resolved, summary),
331
+ warnings=_development_warnings([summary])
332
+ + [
333
+ CliMessage(
334
+ level=MessageLevel.WARNING,
335
+ code=issue.code,
336
+ text=issue.message,
337
+ )
338
+ for issue in summary.compatibility.issues
339
+ ],
340
+ next_actions=_show_next_actions(summary.compatibility),
341
+ )
342
+
343
+ invoke_command(context, SHOW_COMMAND, action, render_data=_render_show)
344
+
345
+
346
+ def prepare_climb_command(
347
+ ctx: typer.Context,
348
+ reference: Annotated[
349
+ str,
350
+ typer.Argument(
351
+ metavar="REFERENCE",
352
+ help="A Climb slug, slug@version, or public identifier.",
353
+ ),
354
+ ],
355
+ skill: Annotated[
356
+ Path,
357
+ typer.Option(
358
+ "--skill",
359
+ metavar="PATH",
360
+ help="The candidate skill directory, or its SKILL.md.",
361
+ ),
362
+ ],
363
+ label: Annotated[
364
+ str | None,
365
+ typer.Option(
366
+ "--label",
367
+ metavar="LABEL",
368
+ help="What to call this candidate. Defaults to the directory name.",
369
+ ),
370
+ ] = None,
371
+ ) -> None:
372
+ """Resolve the Climb graph and prepare one candidate skill draft."""
373
+ context = cli_context(ctx)
374
+
375
+ def action() -> CommandResult[ClimbPreparePayload]:
376
+ service = build_preparation_service(context)
377
+ try:
378
+ prepared = service.prepare(
379
+ climb_reference=reference,
380
+ skill_path=skill,
381
+ candidate_label=label,
382
+ )
383
+ except NotFoundError as error:
384
+ if error.code == "climb_not_found":
385
+ error.next_actions = _unknown_climb_actions(error)
386
+ raise
387
+ except PrerequisiteError as error:
388
+ # An absent engine has a command that fixes it. An unsupported
389
+ # machine does not, and inventing one would waste the caller's
390
+ # time; the reason is already in the error message.
391
+ blocking = error.details.get("blocking_issues")
392
+ if isinstance(blocking, list) and "engine_not_installed" in blocking:
393
+ error.next_actions = [_install_engine()]
394
+ raise
395
+
396
+ payload = _prepare_payload(reference, prepared)
397
+ return CommandResult(
398
+ data=payload,
399
+ messages=[
400
+ CliMessage(
401
+ level=MessageLevel.INFO,
402
+ code="draft_prepared",
403
+ text=(
404
+ f"Prepared {payload.candidate_label} for "
405
+ f"{payload.climb_reference}. Nothing has run yet."
406
+ ),
407
+ )
408
+ ],
409
+ warnings=[
410
+ CliMessage(
411
+ level=MessageLevel.WARNING,
412
+ code="draft_warning",
413
+ text=warning,
414
+ )
415
+ for warning in payload.warnings
416
+ ],
417
+ next_actions=[_start_draft(payload)],
418
+ )
419
+
420
+ invoke_command(context, PREPARE_COMMAND, action, render_data=_render_prepare)
421
+
422
+
423
+ def start_climb_command(
424
+ ctx: typer.Context,
425
+ draft_id: Annotated[
426
+ str,
427
+ typer.Argument(
428
+ metavar="DRAFT_ID",
429
+ help="The prepared draft to start.",
430
+ ),
431
+ ],
432
+ yes: Annotated[
433
+ bool,
434
+ typer.Option(
435
+ "--yes",
436
+ help=(
437
+ "Approve this run without being asked. For an operator running "
438
+ "Techtree where nobody can answer a prompt; it is never a "
439
+ "shortcut for an agent to take on a person's behalf."
440
+ ),
441
+ ),
442
+ ] = False,
443
+ reviewed_on: Annotated[
444
+ ReviewSurface,
445
+ typer.Option(
446
+ "--reviewed-on",
447
+ help=(
448
+ "Where the person who approved this run answered. Pass "
449
+ "host-agent when the review was shown in a conversation and "
450
+ "confirmed there before the run was dispatched, so the run "
451
+ "records the surface the answer was actually given on. Like "
452
+ "--yes, and for the same reason, it states what a person "
453
+ "already did and is never a shortcut a model may take."
454
+ ),
455
+ ),
456
+ ] = ReviewSurface.CLI,
457
+ ) -> None:
458
+ """Review a prepared draft, approve it, and start a detached run."""
459
+ context = cli_context(ctx)
460
+
461
+ def action() -> CommandResult[ClimbStartPayload]:
462
+ service = build_run_service(context)
463
+ store = DraftStore(context.paths)
464
+ draft = store.get(draft_id)
465
+ source = store.get_source(draft_id)
466
+ approval = approve_run(
467
+ context,
468
+ draft=draft,
469
+ campaign=source.campaign,
470
+ assume_yes=yes,
471
+ reviewed_on=reviewed_on,
472
+ )
473
+
474
+ status = service.start(
475
+ draft_id=draft_id,
476
+ policy_acknowledgement=approval.acknowledgement,
477
+ approved_by=approval.actor,
478
+ )
479
+ request = service.request(status.state.run_id)
480
+ payload = _start_payload(draft, status, approval, request)
481
+
482
+ return CommandResult(
483
+ data=payload,
484
+ messages=[
485
+ CliMessage(
486
+ level=MessageLevel.INFO,
487
+ code="run_started",
488
+ text=(
489
+ f"Run {payload.run_id} is going. It continues whether "
490
+ "or not this command is still open."
491
+ ),
492
+ )
493
+ ],
494
+ warnings=_start_warnings(payload, source=source),
495
+ next_actions=[_watch_run(payload.run_id)],
496
+ )
497
+
498
+ invoke_command(context, START_COMMAND, action, render_data=_render_start)
499
+
500
+
501
+ @dataclass(frozen=True)
502
+ class RunApproval:
503
+ """The answer a start was given, and who gave it."""
504
+
505
+ acknowledgement: PolicyAcknowledgement
506
+ actor: ApprovalActor
507
+
508
+
509
+ #: The one scientific claim the whole comparison rests on, said in the words a
510
+ #: reader can check it in. Decisions document 0019 section 3, statement 2.
511
+ ONLY_CHANGE_LINE: Final = "The Skill is the only scientific change."
512
+
513
+ #: What starting a run sends, and what a later choice would send. Decision 0013
514
+ #: section 1.4 fixes both halves of the privacy claim; they are two lines
515
+ #: because a reader meets them as two facts, and the model-calls line above is
516
+ #: what stops this one from being read as "nothing leaves this machine".
517
+ #:
518
+ #: This line used to say that Techtree does not upload the participant's
519
+ #: episodes, traces, receipts, proof bundles or Skill proposals, which was true
520
+ #: because there was nowhere to send them. Decisions 0038 built ``techtree
521
+ #: publish``, so the line says the two things that are true now: starting a run
522
+ #: sends none of it, because publishing is a separate act on a finished run;
523
+ #: and the episodes never travel even then, because they are not in the proof
524
+ #: directory at all.
525
+ #:
526
+ #: It is deliberately not phrased as "nothing is uploaded". A sweeping negative
527
+ #: is the sentence decision 0013 spent its length warning about, and stating
528
+ #: what publishing actually carries tells a reader more than denying that
529
+ #: anything does.
530
+ PUBLICATION_STEP_LINE: Final = (
531
+ "Publishing is a separate step, taken after a run finishes and only if you "
532
+ "choose to: what travels then is the run's proof — the signed report and "
533
+ "its receipts — and never the episodes."
534
+ )
535
+
536
+ #: What a DataPolicy's publication terms mean in this build, shown wherever
537
+ #: those terms are shown.
538
+ #:
539
+ #: A Climb's DataPolicy describes a result that has been published: it says
540
+ #: that entering requires releasing the candidate Skill and that the uplift
541
+ #: report is public. Read on its own, next to the raw-episode terms that
542
+ #: prohibit upload outright, that reads as a plan to publish somebody's Skill
543
+ #: and their numbers — and two readers stopped and refused to start a run over
544
+ #: exactly that.
545
+ #:
546
+ #: The answer used to be that nothing in this build could publish anything,
547
+ #: which was true while there was no command that could. Decisions 0038 built
548
+ #: one. What is still true, and is what those two readers actually needed, is
549
+ #: that publishing is a separate act on a finished run: starting one publishes
550
+ #: nothing at all, and a person who never runs ``techtree publish`` never sends
551
+ #: anything.
552
+ #:
553
+ #: The last clause is not decoration. Decision 0013 section 1.4: a sentence
554
+ #: about what stays here is read as a claim that nothing goes anywhere, and
555
+ #: model calls do.
556
+ PUBLICATION_TERMS_LINE: Final = (
557
+ "These are the terms this Climb sets for a published result. Nothing is "
558
+ "published unless you publish a finished run yourself, and what travels "
559
+ "then is the run's proof — the signed report and its receipts — and never "
560
+ "the episodes. Your Skill and your episodes stay on this machine, and "
561
+ "model calls still go to the model provider you configured."
562
+ )
563
+
564
+
565
+ def review_lines(*, draft: SubmissionDraft, campaign: CampaignSpec) -> list[str]:
566
+ """Return the five things a person weighs before a run starts.
567
+
568
+ Decisions document 0019 section 2 fixes the list and the order: how much
569
+ work this is, the most the Campaign declares it may cost, what is being
570
+ changed, where the model calls go, and what an upload would carry. Every
571
+ value
572
+ is read off the draft or the Campaign it was prepared against, so the
573
+ review describes this run and cannot describe a different one.
574
+
575
+ The cost line says what is actually done about the spend. Since decisions
576
+ document 0029 there is a real check before a run starts: the most the
577
+ comparison can cost under the Campaign's enforced per-episode limits is
578
+ computed, and a Campaign that could amount to more than its declared
579
+ maximum is refused instead of started. What there still is not is a meter —
580
+ nothing counts the spend while a run is under way and nothing ends a run
581
+ part-way through over it — so the line says what the check is, and
582
+ decision 0025 still forbids any wording that would leave a reader expecting
583
+ a running total or a mid-run cut-off.
584
+ """
585
+ return [
586
+ f"This runs {draft.estimated_episodes} episodes: the same tasks once "
587
+ "for each side of the comparison.",
588
+ _cost_line(campaign),
589
+ ONLY_CHANGE_LINE,
590
+ f"Model calls go to {campaign.subject.model.provider}, under that "
591
+ "provider's policies.",
592
+ PUBLICATION_STEP_LINE,
593
+ ]
594
+
595
+
596
+ def _cost_line(campaign: CampaignSpec) -> str:
597
+ """Say what is checked about the spend before the run starts, and what is not.
598
+
599
+ The declared maximum stays a US-dollar figure, because that is what the
600
+ Campaign declares. What the sentence around it may not do is read as though
601
+ everybody gets a bill: the run spends model tokens on inference, and only a
602
+ provider that charges for tokens turns that into money.
603
+ """
604
+ ceiling = campaign.budgets.maximum_usd
605
+ if ceiling is None:
606
+ return (
607
+ "This run spends model tokens on inference. This Campaign declares "
608
+ "no maximum, so there is no figure for "
609
+ "Techtree to hold it to. Each episode still has enforced turn, "
610
+ "token, and time limits. Nothing keeps a running total while the "
611
+ "run is under way and nothing ends it part-way through: a provider "
612
+ "that charges for tokens bills the episodes above to your own "
613
+ "account, and a model you run yourself sends no bill."
614
+ )
615
+ return (
616
+ "This run spends model tokens on inference. Before anything starts, "
617
+ "Techtree checks that this Campaign's enforced "
618
+ f"per-episode limits cannot add up past the ${ceiling:.2f} maximum it "
619
+ "declares, and refuses to run it if they could. Each "
620
+ "episode has enforced turn, token, and time limits. Nothing keeps a "
621
+ "running total while the run is under way and nothing ends it part-way "
622
+ "through: a provider that charges for tokens bills the episodes above "
623
+ "to your own account, and a model you run yourself sends no bill."
624
+ )
625
+
626
+
627
+ def approve_run(
628
+ context: CliContext,
629
+ *,
630
+ draft: SubmissionDraft,
631
+ campaign: CampaignSpec,
632
+ assume_yes: bool,
633
+ reviewed_on: ReviewSurface = ReviewSurface.CLI,
634
+ ) -> RunApproval:
635
+ """Show the review, collect the answer, or refuse to start.
636
+
637
+ Somebody who passed ``--yes`` has answered already, and ``--reviewed-on``
638
+ says where. Otherwise a person is shown the review and the rights summary
639
+ and answers here; where nobody can be asked, the command stops and names
640
+ the flag rather than inventing an approval nobody gave.
641
+ """
642
+ if assume_yes:
643
+ if reviewed_on is ReviewSurface.HOST_AGENT:
644
+ return _approved(draft, "host_agent_confirmation", "human_via_hermes")
645
+ return _approved(draft, "explicit_cli_review", "operator_via_flag")
646
+
647
+ if reviewed_on is not ReviewSurface.CLI:
648
+ # The answer is about to be given here, so a run that recorded it as
649
+ # given somewhere else would name a surface nobody used.
650
+ raise UsageError(
651
+ "--reviewed-on says where an approval was already given, so it "
652
+ "goes with --yes; without it the review is shown here and answered "
653
+ "here",
654
+ code=REVIEW_SURFACE_NOT_APPROVED,
655
+ details={"draft_id": draft.id, "reviewed_on": reviewed_on.value},
656
+ )
657
+
658
+ if context.no_input:
659
+ raise PolicyError(
660
+ "starting this draft accepts its data policy and spends the run it "
661
+ "describes, so somebody has to approve it. Nothing here can be "
662
+ "asked, so say so with --yes",
663
+ code=POLICY_ACCEPTANCE_REQUIRED,
664
+ details={
665
+ "draft_id": draft.id,
666
+ "data_policy_digest": draft.policy_acceptance.data_policy_digest,
667
+ },
668
+ )
669
+
670
+ console = human_console(no_color=context.no_color)
671
+ for line in review_lines(draft=draft, campaign=campaign):
672
+ console.print(line)
673
+ console.print()
674
+ console.print(draft.policy_acceptance.summary)
675
+ console.print(PUBLICATION_TERMS_LINE)
676
+ console.print()
677
+ if not confirmed("Start this run?"):
678
+ raise PolicyError(
679
+ "the run was not approved, so nothing was started",
680
+ code=POLICY_ACCEPTANCE_REQUIRED,
681
+ details={"draft_id": draft.id},
682
+ )
683
+ return _approved(draft, "explicit_cli_review", "human_via_cli")
684
+
685
+
686
+ def _approved(
687
+ draft: SubmissionDraft,
688
+ method: Literal["explicit_cli_review", "host_agent_confirmation"],
689
+ actor: ApprovalActor,
690
+ ) -> RunApproval:
691
+ """Return the acknowledgement and the actor one approval produced."""
692
+ return RunApproval(
693
+ acknowledgement=PolicyAcknowledgement(
694
+ data_policy_digest=draft.policy_acceptance.data_policy_digest,
695
+ method=method,
696
+ acknowledged_at=utc_now(),
697
+ ),
698
+ actor=actor,
699
+ )
700
+
701
+
702
+ def _start_payload(
703
+ draft: SubmissionDraft,
704
+ status: RunStatus,
705
+ approval: RunApproval,
706
+ request: RunRequest,
707
+ ) -> ClimbStartPayload:
708
+ """Project the run that was just created, reading its own record for what it is.
709
+
710
+ ``fake_executor`` is the run's executor and nothing else, read from the
711
+ request the start just wrote — the same source ``run status`` answers from,
712
+ so the two can never disagree about the same run.
713
+ """
714
+ return ClimbStartPayload(
715
+ run_id=status.state.run_id,
716
+ draft_id=draft.id,
717
+ draft_digest=request.draft_digest,
718
+ phase=status.state.phase,
719
+ worker_pid=status.state.worker_pid,
720
+ campaign_spec_digest=draft.campaign_spec_digest,
721
+ data_policy_digest=draft.data_policy_digest,
722
+ policy_acknowledgement_method=approval.acknowledgement.method,
723
+ approved_by=approval.actor,
724
+ fake_executor=request.executor_kind == "fake",
725
+ )
726
+
727
+
728
+ # ---------------------------------------------------------------------------
729
+ # Messages, warnings, and next actions
730
+ # ---------------------------------------------------------------------------
731
+
732
+
733
+ def _available_summary(count: int) -> str:
734
+ if count == 1:
735
+ return "One Climb is available in this build."
736
+ return f"{count} Climbs are available in this build."
737
+
738
+
739
+ def _development_warnings(summaries: list[ClimbSummary]) -> list[CliMessage]:
740
+ """Warn once per development Climb that its results prove nothing."""
741
+ return [
742
+ CliMessage(
743
+ level=MessageLevel.WARNING,
744
+ code="development_climb",
745
+ text=(
746
+ f"{summary.reference} is a development Climb. Its results are "
747
+ "for trying the flow out and are not comparable evidence."
748
+ ),
749
+ )
750
+ for summary in summaries
751
+ if summary.status == "development"
752
+ ]
753
+
754
+
755
+ def _show_next_actions(compatibility: CompatibilityResult) -> list[NextAction]:
756
+ """Offer the one step that moves this Climb forward on this machine."""
757
+ if not compatibility.host_supported:
758
+ # Nothing Techtree can run fixes the wrong machine, so nothing is
759
+ # offered. The reason is already in the compatibility warning.
760
+ return []
761
+ if compatibility.engine_status is EngineCompatibilityStatus.NOT_INSTALLED:
762
+ return [_install_engine()]
763
+ if compatibility.engine_status is EngineCompatibilityStatus.INSTALLED_UNVERIFIED:
764
+ return [_verify_engine()]
765
+ return [_get_starter_skill()]
766
+
767
+
768
+ def _unknown_climb_actions(error: NotFoundError) -> list[NextAction]:
769
+ """Offer the listing when there is one, and Doctor when there is not."""
770
+ available = error.details.get("available")
771
+ if isinstance(available, list) and available:
772
+ return [_browse_climbs()]
773
+ return [_check_environment()]
774
+
775
+
776
+ def _browse_climbs() -> NextAction:
777
+ return NextAction(
778
+ id="list_climbs",
779
+ label="See which Climbs this build ships",
780
+ reason="A Climb is named by its slug, or by slug and version.",
781
+ cli=["techtree", "climb", "list"],
782
+ hermes_tool=None,
783
+ hermes_args=None,
784
+ requires_user_confirmation=False,
785
+ )
786
+
787
+
788
+ def _show_climb(reference: str) -> NextAction:
789
+ return NextAction(
790
+ id="show_climb",
791
+ label=f"Look at {reference} in detail",
792
+ reason="Shows what it measures, the data rights it carries, and "
793
+ "whether this machine can run it.",
794
+ cli=["techtree", "climb", "show", reference],
795
+ hermes_tool=None,
796
+ hermes_args=None,
797
+ requires_user_confirmation=False,
798
+ )
799
+
800
+
801
+ def _install_engine() -> NextAction:
802
+ return NextAction(
803
+ id="install_engine",
804
+ label="Install the evaluation engine",
805
+ reason="Preparing a submission for this Climb needs it.",
806
+ cli=["techtree", "engine", "install"],
807
+ hermes_tool=None,
808
+ hermes_args=None,
809
+ requires_user_confirmation=False,
810
+ )
811
+
812
+
813
+ def _verify_engine() -> NextAction:
814
+ return NextAction(
815
+ id="verify_engine",
816
+ label="Check that the installed evaluation engine is intact",
817
+ reason="A result is only worth as much as the engine that produced it.",
818
+ cli=["techtree", "engine", "verify"],
819
+ hermes_tool=None,
820
+ hermes_args=None,
821
+ requires_user_confirmation=False,
822
+ )
823
+
824
+
825
+ def _get_starter_skill() -> NextAction:
826
+ return NextAction(
827
+ id="get_starter_skill",
828
+ label="Get the pinned starter Skill",
829
+ reason=(
830
+ "The starter Skill is the candidate used for the introductory "
831
+ "Climb, and its next step is the exact prepare command."
832
+ ),
833
+ cli=["techtree", "skill", "starter"],
834
+ hermes_tool=None,
835
+ hermes_args=None,
836
+ requires_user_confirmation=False,
837
+ )
838
+
839
+
840
+ def _start_draft(payload: ClimbPreparePayload) -> NextAction:
841
+ """Offer the start, and say what answering it commits to.
842
+
843
+ The action names the draft and nothing else. What the run would do is shown
844
+ when the start is run, and answering it is what accepts the rights policy,
845
+ so this is marked as needing a person rather than carrying anything a
846
+ caller could pass instead of one.
847
+ """
848
+ return NextAction(
849
+ id="start_climb",
850
+ label=f"Start {payload.candidate_label} on {payload.climb_reference}",
851
+ reason=(
852
+ f"Runs {payload.estimated_episodes} episodes. It shows you the "
853
+ "spending limit the Campaign declares and what this changes, and "
854
+ "starts only if you say yes."
855
+ ),
856
+ cli=["techtree", "climb", "start", payload.draft_id],
857
+ hermes_tool=None,
858
+ hermes_args=None,
859
+ requires_user_confirmation=True,
860
+ )
861
+
862
+
863
+ def _start_warnings(
864
+ payload: ClimbStartPayload, *, source: CampaignSource
865
+ ) -> list[CliMessage]:
866
+ """Say plainly, in both output modes, what this run is going to produce.
867
+
868
+ Two separate facts, each read off the run rather than stated here. Whether
869
+ a model is called at all is the executor the run's own request records.
870
+ Whether the report may be published is the Climb's proof grade. They are
871
+ independent: the Climb this build ships is a real evaluation that is paid
872
+ for and is still not publication eligible, and a single sentence that
873
+ assumed one from the other is how this surface came to tell people no
874
+ model would be called on the screen where they had just agreed to pay for
875
+ the calls.
876
+ """
877
+ warnings: list[CliMessage] = []
878
+
879
+ if payload.fake_executor:
880
+ warnings.append(
881
+ CliMessage(
882
+ level=MessageLevel.WARNING,
883
+ code="fake_executor_run",
884
+ text=(
885
+ "No agent is evaluated and no model is called on this run. "
886
+ "The numbers in the report it produces are invented."
887
+ ),
888
+ )
889
+ )
890
+ else:
891
+ warnings.append(
892
+ CliMessage(
893
+ level=MessageLevel.WARNING,
894
+ code="paid_evaluation_run",
895
+ text=(
896
+ "This run evaluates the agent for real and spends model "
897
+ "tokens on inference with "
898
+ f"{source.campaign.subject.model.provider}. If that "
899
+ "provider charges for tokens, what you pay is whatever it "
900
+ "charges; a model you run yourself sends no bill."
901
+ ),
902
+ )
903
+ )
904
+
905
+ if source.climb is not None and (
906
+ source.climb.publication.proof_grade == "development_only"
907
+ ):
908
+ warnings.append(
909
+ CliMessage(
910
+ level=MessageLevel.WARNING,
911
+ code="not_publication_eligible",
912
+ text=(
913
+ f"{climb_reference(source.climb)} is a development Climb. "
914
+ "Its report is not publication eligible, and its result is "
915
+ "not comparable evidence."
916
+ ),
917
+ )
918
+ )
919
+
920
+ return warnings
921
+
922
+
923
+ def _watch_run(run_id: str) -> NextAction:
924
+ return NextAction(
925
+ id="run_status",
926
+ label="Check how the run is going",
927
+ reason="The run continues after this command returns.",
928
+ cli=["techtree", "run", "status", run_id],
929
+ hermes_tool=None,
930
+ hermes_args=None,
931
+ requires_user_confirmation=False,
932
+ )
933
+
934
+
935
+ def _check_environment() -> NextAction:
936
+ return NextAction(
937
+ id="check_environment",
938
+ label="Check that this machine is ready",
939
+ reason="Doctor reports what is installed, what is missing, and what "
940
+ "would block a run.",
941
+ cli=["techtree", "doctor"],
942
+ hermes_tool=None,
943
+ hermes_args=None,
944
+ requires_user_confirmation=False,
945
+ )
946
+
947
+
948
+ # ---------------------------------------------------------------------------
949
+ # Human rendering
950
+ # ---------------------------------------------------------------------------
951
+
952
+
953
+ def _render_list(data: object, console: Console) -> None:
954
+ """Print one row per Climb, or nothing when there are none."""
955
+ if not isinstance(data, list) or not data:
956
+ return
957
+
958
+ table = Table(box=None, pad_edge=False, padding=(0, 2))
959
+ table.add_column("Climb", no_wrap=True)
960
+ table.add_column("Title", overflow="fold")
961
+ table.add_column("Status", no_wrap=True)
962
+ table.add_column("Tasks", justify="right", no_wrap=True)
963
+ table.add_column("Runs here", no_wrap=True)
964
+
965
+ for summary in data:
966
+ table.add_row(
967
+ summary.reference,
968
+ summary.title,
969
+ summary.status,
970
+ str(summary.task_count),
971
+ "yes" if summary.compatibility.compatible else "no",
972
+ )
973
+
974
+ console.print(table)
975
+
976
+
977
+ def _render_show(data: object, console: Console) -> None:
978
+ """Print everything a person needs before entering a Climb."""
979
+ if not isinstance(data, ClimbShowPayload):
980
+ return
981
+ summary = data.climb
982
+ runtime = data.subject_runtime
983
+ platforms = ", ".join(runtime.supported_platforms)
984
+
985
+ console.print(summary.title)
986
+ console.print(summary.summary)
987
+ console.print()
988
+
989
+ render_pairs(
990
+ [
991
+ ("Climb", summary.reference),
992
+ ("Status", summary.status),
993
+ ("Purpose", phrase(summary.purpose)),
994
+ ("Taskset", f"{summary.taskset_id} ({summary.task_count} tasks)"),
995
+ (
996
+ "Subject harness",
997
+ f"{summary.subject_harness} {summary.subject_harness_version}",
998
+ ),
999
+ (
1000
+ "Subject model",
1001
+ f"{data.subject_model.provider}/{data.subject_model.model_id}",
1002
+ ),
1003
+ ("Subject runtime", f"{runtime.type} {runtime.image} ({platforms})"),
1004
+ ("Primary reward", data.primary_reward),
1005
+ ("Candidate ownership", data.candidate_skill_ownership),
1006
+ ("Evaluated by", summary.evaluation_backend.value),
1007
+ ("Allowed change", phrase(summary.mutation_kind)),
1008
+ ("Proof grade", phrase(summary.proof_grade)),
1009
+ ],
1010
+ console,
1011
+ )
1012
+
1013
+ console.print()
1014
+ console.print("Data rights")
1015
+ render_pairs(
1016
+ [
1017
+ ("Candidate skills", summary.candidate_skill_visibility),
1018
+ (
1019
+ "Public release",
1020
+ phrase(summary.data_policy.candidate_skill_public_release),
1021
+ ),
1022
+ (
1023
+ "Raw episode upload",
1024
+ phrase(summary.data_policy.raw_episode_server_upload),
1025
+ ),
1026
+ ("Training use", phrase(summary.data_policy.raw_episode_training_use)),
1027
+ ("Uplift report", phrase(summary.data_policy.uplift_report_visibility)),
1028
+ ],
1029
+ console,
1030
+ )
1031
+ console.print(PUBLICATION_TERMS_LINE)
1032
+
1033
+ console.print()
1034
+ console.print("This machine")
1035
+ render_pairs(
1036
+ [
1037
+ ("Host platform", summary.compatibility.host_platform),
1038
+ ("Engine", phrase(summary.compatibility.engine_status.value)),
1039
+ ("Runs here", "yes" if summary.compatibility.compatible else "no"),
1040
+ ],
1041
+ console,
1042
+ )
1043
+
1044
+ console.print()
1045
+ console.print("Technical IDs")
1046
+ render_pairs(
1047
+ [
1048
+ ("Campaign digest", abbreviated_digest(summary.campaign_spec_digest)),
1049
+ ("Data policy digest", abbreviated_digest(data.data_policy_digest)),
1050
+ ],
1051
+ console,
1052
+ )
1053
+ console.print(
1054
+ " Shortened to fit. Run this command with --json for the complete digests."
1055
+ )
1056
+
1057
+
1058
+ def _render_prepare(data: object, console: Console) -> None:
1059
+ """Print everything spec PR6 §6.9 requires before a person confirms."""
1060
+ if not isinstance(data, ClimbPreparePayload):
1061
+ return
1062
+
1063
+ render_pairs(
1064
+ [
1065
+ ("Draft", data.draft_id),
1066
+ ("Climb", data.climb_reference),
1067
+ ("Climb digest", data.climb_digest),
1068
+ ("Campaign digest", data.campaign_spec_digest),
1069
+ ("Data policy digest", data.data_policy_digest),
1070
+ ("Candidate", data.candidate_label),
1071
+ ("Skill content digest", data.skill_root_digest),
1072
+ ],
1073
+ console,
1074
+ )
1075
+
1076
+ console.print()
1077
+ console.print(f"Included files ({len(data.included_files)})")
1078
+ for path in data.included_files:
1079
+ console.print(f" {path}")
1080
+
1081
+ console.print()
1082
+ console.print("The comparison")
1083
+ render_pairs(
1084
+ [
1085
+ ("Allowed difference", ", ".join(data.comparison.allowed_differences)),
1086
+ ("Found difference", ", ".join(data.comparison.differences)),
1087
+ ("Baseline skills", str(data.baseline_skill_count)),
1088
+ ("Candidate skills", str(data.candidate_skill_count)),
1089
+ ("Controlled", "yes" if data.comparison.controlled else "no"),
1090
+ ("Estimated episodes", str(data.estimated_episodes)),
1091
+ ("Proof grade", phrase(data.proof_grade)),
1092
+ ],
1093
+ console,
1094
+ )
1095
+
1096
+ console.print()
1097
+ console.print("Data rights")
1098
+ render_pairs(
1099
+ [
1100
+ ("Candidate ownership", data.candidate_ownership),
1101
+ ("Public release", phrase(data.candidate_public_release)),
1102
+ ("Raw episode upload", phrase(data.raw_episode_server_upload)),
1103
+ ("Training use", phrase(data.raw_episode_training_use)),
1104
+ (
1105
+ "Acceptance",
1106
+ "required before starting"
1107
+ if data.policy_acceptance.required
1108
+ else "not required",
1109
+ ),
1110
+ ],
1111
+ console,
1112
+ )
1113
+ console.print(data.policy_acceptance.summary)
1114
+ console.print(PUBLICATION_TERMS_LINE)
1115
+
1116
+
1117
+ def _render_start(data: object, console: Console) -> None:
1118
+ """Print what was started and where it can be followed."""
1119
+ if not isinstance(data, ClimbStartPayload):
1120
+ return
1121
+
1122
+ render_pairs(
1123
+ [
1124
+ ("Run", data.run_id),
1125
+ ("Draft", data.draft_id),
1126
+ ("Phase", data.phase.value),
1127
+ ("Worker", "not started" if data.worker_pid is None else "running"),
1128
+ ("Campaign digest", data.campaign_spec_digest),
1129
+ ("Data policy digest", data.data_policy_digest),
1130
+ ("Approved", phrase(data.policy_acknowledgement_method)),
1131
+ ("Approved by", phrase(data.approved_by)),
1132
+ ],
1133
+ console,
1134
+ )
1135
+
1136
+
1137
+ #: How much of a digest a person is shown when the point is recognition
1138
+ #: rather than comparison. Twelve hexadecimal characters distinguish every
1139
+ #: object a build could plausibly hold, and the full value is one --json away.
1140
+ ABBREVIATED_DIGEST_CHARACTERS: Final = 12
1141
+
1142
+
1143
+ def abbreviated_digest(digest: str) -> str:
1144
+ """Shorten one digest for a terminal, visibly.
1145
+
1146
+ Decisions document 0007 R3 puts abbreviated digests in ``climb show``'s
1147
+ human output and complete ones in its JSON. The ellipsis is what keeps
1148
+ that honest: a shortened digest that looked whole would be copied into a
1149
+ comparison and quietly fail it.
1150
+ """
1151
+ algorithm, _, hexadecimal = digest.partition(":")
1152
+ return f"{algorithm}:{hexadecimal[:ABBREVIATED_DIGEST_CHARACTERS]}…"
1153
+
1154
+
1155
+ def phrase(value: str) -> str:
1156
+ """Render a protocol value as words rather than as an identifier.
1157
+
1158
+ The machine payload keeps the exact spelling; a person reading a terminal
1159
+ is better served by "required for climb" than by the same string with an
1160
+ underscore in it.
1161
+ """
1162
+ return value.replace("_", " ")
1163
+
1164
+
1165
+ # ---------------------------------------------------------------------------
1166
+ # Payloads
1167
+ # ---------------------------------------------------------------------------
1168
+
1169
+
1170
+ def _show_payload(resolved: ResolvedClimb, summary: ClimbSummary) -> ClimbShowPayload:
1171
+ """Return the summary plus the Campaign facts it has no field for."""
1172
+ subject = resolved.campaign.subject
1173
+ return ClimbShowPayload(
1174
+ climb=summary,
1175
+ data_policy_digest=resolved.data_policy_digest,
1176
+ subject_model=subject.model,
1177
+ subject_runtime=subject.runtime,
1178
+ primary_reward=resolved.campaign.scoring.primary_reward,
1179
+ candidate_skill_ownership=resolved.data_policy.candidate_skill.ownership,
1180
+ )
1181
+
1182
+
1183
+ def _prepare_payload(reference: str, prepared: PreparedDraft) -> ClimbPreparePayload:
1184
+ """Project a prepared draft into the response a caller acts on."""
1185
+ draft = prepared.draft
1186
+ source = prepared.source
1187
+ # ``climb prepare`` only ever prepares against a public Climb; the local
1188
+ # Climb-free flow is ``uplift prepare`` and returns its own payload.
1189
+ assert source.climb is not None and source.climb_digest is not None
1190
+ data_policy = source.data_policy
1191
+ comparison = prepared.manifest_comparison
1192
+
1193
+ return ClimbPreparePayload(
1194
+ draft_id=draft.id,
1195
+ draft_digest=prepared.draft_digest,
1196
+ climb_reference=climb_reference(source.climb),
1197
+ climb_digest=source.climb_digest,
1198
+ campaign_spec_digest=draft.campaign_spec_digest,
1199
+ data_policy_digest=draft.data_policy_digest,
1200
+ candidate_label=draft.skill_artifact.name,
1201
+ skill_root_digest=draft.skill_artifact.root_digest,
1202
+ included_files=list(draft.included_files),
1203
+ # Read off the Campaign rather than assumed. Decisions document 0019
1204
+ # section 1: a baseline is a role, and how many Skills it carries is
1205
+ # something the Campaign says, not something the count of a public
1206
+ # submission happens to be today.
1207
+ baseline_skill_count=len(source.campaign.subject.harness.skills),
1208
+ candidate_skill_count=1,
1209
+ estimated_episodes=draft.estimated_episodes,
1210
+ campaign_maximum_usd=source.campaign.budgets.maximum_usd,
1211
+ candidate_ownership=data_policy.candidate_skill.ownership,
1212
+ candidate_public_release=data_policy.candidate_skill.public_release,
1213
+ raw_episode_server_upload=data_policy.raw_episodes.server_upload,
1214
+ raw_episode_training_use=data_policy.raw_episodes.training_use,
1215
+ proof_grade=source.climb.publication.proof_grade,
1216
+ policy_acceptance=draft.policy_acceptance,
1217
+ comparison=PreparedComparison(
1218
+ controlled=comparison.controlled,
1219
+ differences=[difference.pointer for difference in comparison.differences],
1220
+ allowed_differences=list(comparison.allowed_differences),
1221
+ ),
1222
+ warnings=list(draft.warnings),
1223
+ )