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
techtree/cli/output.py ADDED
@@ -0,0 +1,280 @@
1
+ """How one envelope reaches stdout. Spec section 12.2.
2
+
3
+ There are exactly two renderings of a response and they never mix. A machine
4
+ gets one compact JSON object and a newline. A person gets messages, a data
5
+ summary, warnings, and numbered next steps. Which one happens is decided by the
6
+ context, once, so no command can half-render.
7
+
8
+ The JSON spelling is the canonical one: sorted keys, no insignificant
9
+ whitespace. Nothing here is hashed, but a stable byte order is what makes an
10
+ envelope diffable in a test and in a bug report.
11
+
12
+ Operational logs go to stderr and only to stderr. That separation is the whole
13
+ reason a host agent can pipe stdout into a JSON parser without filtering it
14
+ first.
15
+
16
+ ``shell_display`` exists so a next action can be *shown* as a command line. It
17
+ uses ``shlex.join`` and its output is display-only: the argv list is the
18
+ contract, the string is a courtesy. Nothing in Techtree ever executes a
19
+ displayed command string, which is exactly why next actions are arrays.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import shlex
25
+ import sys
26
+ from collections.abc import Callable
27
+ from typing import Any
28
+
29
+ from rich.console import Console
30
+ from rich.panel import Panel
31
+ from rich.table import Table
32
+ from rich.text import Text
33
+
34
+ from techtree.canonical import canonical_json_text
35
+ from techtree.cli.context import CliContext
36
+ from techtree.models.cli import (
37
+ CliEnvelope,
38
+ CliError,
39
+ CliMessage,
40
+ MessageLevel,
41
+ NextAction,
42
+ )
43
+
44
+ __all__ = [
45
+ "DataRenderer",
46
+ "emit_envelope",
47
+ "human_console",
48
+ "json_stdout",
49
+ "render_human",
50
+ "render_next_actions",
51
+ "render_pairs",
52
+ "shell_display",
53
+ "stderr_log",
54
+ "write_envelope",
55
+ ]
56
+
57
+ type DataRenderer = Callable[[Any, Console], None]
58
+ """Renders one command's payload for a person. Machine mode never calls it."""
59
+
60
+ #: How each message level is introduced in human output. Plain words rather
61
+ #: than symbols, so the text survives a terminal that cannot draw them.
62
+ _LEVEL_PREFIX: dict[MessageLevel, str] = {
63
+ MessageLevel.INFO: "",
64
+ MessageLevel.WARNING: "Warning: ",
65
+ MessageLevel.ERROR: "Error: ",
66
+ }
67
+
68
+ _LEVEL_STYLE: dict[MessageLevel, str] = {
69
+ MessageLevel.INFO: "",
70
+ MessageLevel.WARNING: "yellow",
71
+ MessageLevel.ERROR: "red",
72
+ }
73
+
74
+
75
+ def human_console(*, no_color: bool) -> Console:
76
+ """Return the console human output is written to.
77
+
78
+ Rich already emits no escape sequences when stdout is not a terminal, so a
79
+ piped human rendering is plain text without anyone asking for it.
80
+ """
81
+ return Console(
82
+ file=sys.stdout,
83
+ no_color=no_color,
84
+ highlight=False,
85
+ emoji=False,
86
+ markup=False,
87
+ )
88
+
89
+
90
+ def emit_envelope(
91
+ context: CliContext,
92
+ envelope: CliEnvelope[Any],
93
+ *,
94
+ render_data: DataRenderer | None = None,
95
+ ) -> None:
96
+ """Write one JSON object or render human output."""
97
+ write_envelope(
98
+ envelope,
99
+ json_output=context.json_output,
100
+ no_color=context.no_color,
101
+ render_data=render_data,
102
+ )
103
+
104
+
105
+ def write_envelope(
106
+ envelope: CliEnvelope[Any],
107
+ *,
108
+ json_output: bool,
109
+ no_color: bool,
110
+ render_data: DataRenderer | None = None,
111
+ ) -> None:
112
+ """Write one envelope without needing a fully built context.
113
+
114
+ The error boundary reaches this directly: a failure while the context is
115
+ still being built still owes the caller exactly one response.
116
+ """
117
+ if json_output:
118
+ json_stdout(envelope)
119
+ return
120
+ render_human(envelope, human_console(no_color=no_color), render_data=render_data)
121
+
122
+
123
+ def render_human(
124
+ envelope: CliEnvelope[Any],
125
+ console: Console,
126
+ *,
127
+ render_data: DataRenderer | None = None,
128
+ ) -> None:
129
+ """Render messages, typed data summaries, warnings, and next actions."""
130
+ for message in envelope.messages:
131
+ _render_message(message, console)
132
+
133
+ if envelope.data is not None and render_data is not None:
134
+ if envelope.messages:
135
+ console.print()
136
+ render_data(envelope.data, console)
137
+
138
+ if envelope.warnings:
139
+ console.print()
140
+ for warning in envelope.warnings:
141
+ _render_message(warning, console)
142
+
143
+ if envelope.error is not None:
144
+ console.print()
145
+ _render_error(envelope.error, console)
146
+
147
+ render_next_actions(envelope.next_actions, console)
148
+
149
+
150
+ def render_next_actions(actions: list[NextAction], console: Console) -> None:
151
+ """Render ordered next steps with display-only shell quoting."""
152
+ if not actions:
153
+ return
154
+
155
+ console.print()
156
+ # Decision 0024 section 7: a successful response ends with one immediate
157
+ # action, so the one-action case is headed the way that rule reads.
158
+ console.print("Next:" if len(actions) == 1 else "Next steps:")
159
+ table = Table(box=None, show_header=False, pad_edge=False, padding=(0, 1))
160
+ table.add_column("index", justify="right", no_wrap=True)
161
+ # Folded rather than truncated. A next step is meant to be typed, and a
162
+ # command with an ellipsis in the middle of a path cannot be: a
163
+ # materialized Skill's cache directory alone is seventy-one characters
164
+ # (decisions document 0010 item 5), so the one place a reader most needs
165
+ # the whole line is the place a narrow terminal would cut it.
166
+ table.add_column("step", overflow="fold")
167
+
168
+ # One step is not a list, so it is not numbered like one.
169
+ numbered = len(actions) > 1
170
+ for position, action in enumerate(actions, start=1):
171
+ table.add_row(f"{position}." if numbered else "", _step_text(action))
172
+
173
+ console.print(table)
174
+
175
+
176
+ def render_pairs(pairs: list[tuple[str, str]], console: Console) -> None:
177
+ """Render labelled facts as one two-column table.
178
+
179
+ Almost every command that shows a person what it found shows it this way:
180
+ a short label on the left and the value beside it. They were built one at a
181
+ time and drifted apart, and a reader moving between two commands should not
182
+ have to notice that they are looking at the same thing drawn differently.
183
+
184
+ The label is dimmed and the value is not, because the value is what the
185
+ reader came for and the label is only there to say which value it is.
186
+
187
+ The value column folds rather than truncates, for the reason given where
188
+ next actions do the same: a folded digest or path can still be copied out
189
+ of a narrow terminal and a shortened one cannot.
190
+ """
191
+ table = Table(box=None, show_header=False, pad_edge=False, padding=(0, 2))
192
+ table.add_column("label", no_wrap=True, style="dim")
193
+ table.add_column("value", overflow="fold")
194
+ for label, value in pairs:
195
+ table.add_row(label, value)
196
+ console.print(table)
197
+
198
+
199
+ def shell_display(argv: list[str]) -> str:
200
+ """Use shlex.join for display only."""
201
+ return shlex.join(argv)
202
+
203
+
204
+ def json_stdout(envelope: CliEnvelope[Any]) -> None:
205
+ """Write one compact JSON object and one newline."""
206
+ sys.stdout.write(canonical_json_text(envelope))
207
+ sys.stdout.write("\n")
208
+ sys.stdout.flush()
209
+
210
+
211
+ def stderr_log(message: str) -> None:
212
+ """Write operational logs to stderr."""
213
+ sys.stderr.write(f"{message}\n")
214
+ sys.stderr.flush()
215
+
216
+
217
+ def _render_message(message: CliMessage, console: Console) -> None:
218
+ prefix = _LEVEL_PREFIX[message.level]
219
+ console.print(f"{prefix}{message.text}", style=_LEVEL_STYLE[message.level] or None)
220
+
221
+
222
+ def _step_text(action: NextAction) -> Text:
223
+ """Return one next step with the line a person types set apart.
224
+
225
+ A step is up to three lines that used to look alike: what it is, the
226
+ command, and why. The command is the only one of them anybody retypes, so
227
+ it is the one the eye should land on, and the reason is the one a reader
228
+ who already knows why can pass over. Weight says that without moving
229
+ anything or changing a word, which is what was wanted here: the ordering
230
+ and the wording are settled elsewhere and this changes neither.
231
+
232
+ The styles are carried by a :class:`~rich.text.Text` rather than by a
233
+ marked-up string. Nothing this console prints is read for markup, so a
234
+ label or a path that happens to contain square brackets is drawn as the
235
+ characters it is made of and cannot choose a colour for itself.
236
+ """
237
+ step = Text(action.label)
238
+ if action.cli is not None:
239
+ step.append("\n")
240
+ step.append(shell_display(action.cli), style="bold")
241
+ if action.reason is not None:
242
+ step.append("\n")
243
+ step.append(action.reason, style="dim")
244
+ if action.requires_user_confirmation:
245
+ step.append("\nRequires confirmation by a person before it runs.")
246
+ return step
247
+
248
+
249
+ def _render_error(error: CliError, console: Console) -> None:
250
+ """Draw a failure so that it cannot be scrolled past, when somebody is there.
251
+
252
+ The message and the code were two ordinary lines among all the other
253
+ ordinary lines a command prints, which is what a reader skimming a long
254
+ response goes straight past. A frame around them says which part went
255
+ wrong before any of the words have been read.
256
+
257
+ The frame is drawn only for a terminal. Box-drawing characters are not
258
+ colour: they are ordinary text and they survive a pipe, so framing a
259
+ redirected response would push decoration into whatever reads it next.
260
+ Redirected output stays the two plain lines it has always been, and the
261
+ words are the same either way.
262
+
263
+ The frame is drawn around the words rather than across the terminal.
264
+ Nothing else in this product draws a box at all, so one that ran the full
265
+ width would be by far the loudest thing on the screen, and a missing run
266
+ identifier does not warrant that. Sized to its own contents it still marks
267
+ the failure at a glance without shouting over the response it belongs to.
268
+ """
269
+ message = Text(f"Error: {error.message}", style="red")
270
+ code = Text(f"Code: {error.code}")
271
+ if not console.is_terminal:
272
+ console.print(message)
273
+ console.print(code)
274
+ return
275
+
276
+ framed = Text()
277
+ framed.append_text(message)
278
+ framed.append("\n")
279
+ framed.append_text(code)
280
+ console.print(Panel.fit(framed, border_style="red"))
techtree/constants.py ADDED
@@ -0,0 +1,138 @@
1
+ """Central protocol constants. Spec section 10.4.
2
+
3
+ This module holds values only. It imports nothing from the rest of the package
4
+ and defines no behavior, so every other module may import it freely without
5
+ creating a cycle.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Final
11
+
12
+ #: Prefix that turns a bare SHA-256 hexadecimal string into a Techtree digest.
13
+ DIGEST_PREFIX: Final = "sha256:"
14
+
15
+ CLI_SCHEMA_VERSION: Final = "techtree.cli.v1"
16
+ CATALOG_SCHEMA_VERSION: Final = "techtree.catalog.v1alpha1"
17
+ CAMPAIGN_SCHEMA_VERSION: Final = "techtree.campaign.v1alpha1"
18
+ CLIMB_SCHEMA_VERSION: Final = "techtree.climb.v1alpha1"
19
+ DATA_POLICY_SCHEMA_VERSION: Final = "techtree.data-policy.v1alpha1"
20
+ EVALUATION_BACKEND_SCHEMA_VERSION: Final = "techtree.evaluation-backend.v1alpha1"
21
+ SKILL_SCHEMA_VERSION: Final = "techtree.skill.v1alpha1"
22
+ SUBMISSION_DRAFT_SCHEMA_VERSION: Final = "techtree.submission-draft.v1alpha1"
23
+ EXPERIMENT_SCHEMA_VERSION: Final = "techtree.experiment.v1alpha1"
24
+ TASKSET_LOCK_SCHEMA_VERSION: Final = "techtree.taskset-lock.v1alpha1"
25
+ TASKSET_VALIDATION_SCHEMA_VERSION: Final = "techtree.taskset-validation.v1alpha1"
26
+ #: Decisions document 0003 A1. The deterministic, normalized form of the raw
27
+ #: upstream validation output that a receipt points at.
28
+ VALIDATION_EVIDENCE_SCHEMA_VERSION: Final = "techtree.validation-evidence.v1alpha1"
29
+ #: Decisions document 0003 A1. Local operational provenance for one validation
30
+ #: execution. Never part of the Campaign graph.
31
+ VALIDATION_EXECUTION_SCHEMA_VERSION: Final = "techtree.validation-execution.v1alpha1"
32
+ EPISODE_RECEIPT_SCHEMA_VERSION: Final = "techtree.episode-receipt.v1alpha1"
33
+ UPLIFT_SCHEMA_VERSION: Final = "techtree.uplift-report.v1alpha1"
34
+ ENGINE_SCHEMA_VERSION: Final = "techtree.engine.v1alpha1"
35
+ #: Decisions document 0038. The two documents that cross the one boundary this
36
+ #: product opens: what a participant sends when they publish a run, and the
37
+ #: countersigned record the network returns.
38
+ PUBLICATION_SUBMISSION_SCHEMA_VERSION: Final = (
39
+ "techtree.publication-submission.v1alpha1"
40
+ )
41
+ PUBLICATION_RECEIPT_SCHEMA_VERSION: Final = "techtree.publication-receipt.v1alpha1"
42
+ #: Decisions 0038: a published entry is withdrawn, never deleted. The request
43
+ #: is signed by the same participant key that signed the run, and the network
44
+ #: answers with a countersigned record that the entry is now marked withdrawn.
45
+ PUBLICATION_WITHDRAWAL_SCHEMA_VERSION: Final = (
46
+ "techtree.publication-withdrawal.v1alpha1"
47
+ )
48
+ PUBLICATION_WITHDRAWAL_RECEIPT_SCHEMA_VERSION: Final = (
49
+ "techtree.publication-withdrawal-receipt.v1alpha1"
50
+ )
51
+ #: One line of a run's publication journal. Decisions 0038: publishing is
52
+ #: recorded beside a completed run without touching a byte the run wrote.
53
+ PUBLICATION_JOURNAL_SCHEMA_VERSION: Final = "techtree.publication-journal.v1alpha1"
54
+
55
+ #: Decisions document 0003 A9. Go/OCI-style ``<os>/<arch>`` strings are the
56
+ #: only host-platform spelling in the protocol. The tuple is ordered so that
57
+ #: generated descriptors and schemas list the vocabulary the same way every
58
+ #: time.
59
+ SUPPORTED_HOST_PLATFORMS: Final[tuple[str, ...]] = (
60
+ "darwin/amd64",
61
+ "darwin/arm64",
62
+ "linux/amd64",
63
+ "linux/arm64",
64
+ )
65
+
66
+ #: The subject container every generated Campaign pins, and the per-platform
67
+ #: manifests that index resolves to. Decisions document 0007 R5 makes this
68
+ #: release-blocking: a tag moves, so a Campaign naming one could not claim its
69
+ #: two variants ran the same subject, and a multi-platform index digest alone
70
+ #: does not say which bytes a given host ran.
71
+ #:
72
+ #: Hermes Agent is installed into the container at setup time from a PEP 723
73
+ #: script, so the image needs a shell, ``pip`` and nothing else; the agent's own
74
+ #: interpreter is provisioned by ``uv`` inside the container. That is why the
75
+ #: pin is a stock ``python:3.11-slim`` and not something Techtree publishes.
76
+ #:
77
+ #: Every digest below was read from the registry rather than written from
78
+ #: memory, on 2026-08-13::
79
+ #:
80
+ #: docker image inspect python:3.11-slim --format '{{index .RepoDigests 0}}'
81
+ #: docker manifest inspect python@sha256:90744cff...
82
+ #:
83
+ #: ``tests/preflight/test_subject_image_pin.py`` re-reads both and fails if the
84
+ #: registry ever stops agreeing with what is written here.
85
+ SUBJECT_IMAGE_REPOSITORY: Final = "python"
86
+ SUBJECT_IMAGE_TAG: Final = "3.11-slim"
87
+ SUBJECT_IMAGE_INDEX_DIGEST: Final = (
88
+ "sha256:90744cff8f32887f075c47d747a173ff333e9e98801667af93c357fa9f5e28ff"
89
+ )
90
+ SUBJECT_IMAGE: Final = f"{SUBJECT_IMAGE_REPOSITORY}@{SUBJECT_IMAGE_INDEX_DIGEST}"
91
+ SUBJECT_IMAGE_PLATFORM_DIGESTS: Final[dict[str, str]] = {
92
+ "linux/amd64": (
93
+ "sha256:78b39ef14d8e2b4d71f8dc304f1328c37df95fe0ef99477c2ae6bd3d03784553"
94
+ ),
95
+ "linux/arm64": (
96
+ "sha256:20eadabc42589e6543b24a64ab305b9895e9fcf6dbb2cadb14812f394ecdbadf"
97
+ ),
98
+ }
99
+
100
+ #: What the starter Skill is called in front of a person, and what the guided
101
+ #: first run files it under. Decisions document 0009 fixes the name and 0010
102
+ #: item 5 fixes the label; the two are deliberately different values, and
103
+ #: neither is ever derived from a path. A materialized Skill lives in a
104
+ #: directory named by the digest it was verified against, and a digest is
105
+ #: neither a name anybody would choose nor a label a candidate may carry.
106
+ STARTER_SKILL_NAME: Final = "hello-world-starter-v1"
107
+ STARTER_SKILL_CANDIDATE_LABEL: Final = "hello-world-v1"
108
+
109
+ #: Why the starter Skill is the shape it is. Decisions document 0010 item 2
110
+ #: amends 0007 R4: the Skill's own text stays silent about its gap, because a
111
+ #: Skill that announced one would be measuring something other than what it
112
+ #: says, and the release metadata discloses instead. This is that disclosure
113
+ #: on the command line, printed beside the Skill's name wherever it is handed
114
+ #: over — the same sentence the public Climb page and the calibration record
115
+ #: carry. It is release metadata about the artifact, never Skill content.
116
+ STARTER_SKILL_PURPOSE: Final = "intentionally incomplete introductory Skill"
117
+
118
+ DEFAULT_WORKER_HEARTBEAT_SECONDS: Final = 2
119
+ DEFAULT_STALE_HEARTBEAT_SECONDS: Final = 15
120
+
121
+ MAX_SKILL_FILE_BYTES: Final = 256 * 1024
122
+ MAX_SKILL_TOTAL_BYTES: Final = 2 * 1024 * 1024
123
+ MAX_SKILL_FILES: Final = 64
124
+
125
+ ALLOWED_SKILL_SUFFIXES: Final[frozenset[str]] = frozenset(
126
+ {
127
+ ".md",
128
+ ".txt",
129
+ ".json",
130
+ ".yaml",
131
+ ".yml",
132
+ }
133
+ )
134
+
135
+ #: Decisions document 0001. Never a branch, a tag, or an unpinned PyPI range.
136
+ #: Changing this value requires a dedicated dependency-bump change that reruns
137
+ #: the pinned-Verifiers preflight suite.
138
+ PINNED_VERIFIERS_REVISION: Final = "b2e4e8157783b2c0dffc7821044c87f29f1c3ccf"
techtree/crypto.py ADDED
@@ -0,0 +1,128 @@
1
+ """Ed25519 primitives. Spec sections 10.8 and 2.6.
2
+
3
+ This module is deliberately inert. It knows how to make a key, move key
4
+ material in and out of raw bytes, sign a digest string, and check a signature.
5
+ It knows nothing about where keys live, which key is "ours", or when signing
6
+ should happen.
7
+
8
+ Decisions document 0001 froze that boundary for WP0–WP5, when nothing signed
9
+ anything; decisions document 0005 amendment 4 turned signing on in WP7. The
10
+ boundary itself did not move. There is still no key store here on purpose:
11
+ :mod:`techtree.identity` is the one place that knows where a key lives, which
12
+ key is this machine's, and when something is signed with it.
13
+
14
+ What gets signed is the ASCII digest string — ``sha256:`` and 64 hexadecimal
15
+ characters — rather than the canonical bytes themselves. A verifier can then
16
+ check a signature while holding only the digest, and the thing being attested
17
+ to is exactly the value that appears in the protocol document.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import base64
23
+
24
+ from cryptography.exceptions import InvalidSignature
25
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import (
26
+ Ed25519PrivateKey,
27
+ Ed25519PublicKey,
28
+ )
29
+
30
+ from techtree.canonical import validate_digest
31
+ from techtree.errors import ValidationError
32
+ from techtree.models.base import Digest, SignatureEnvelope
33
+
34
+ __all__ = [
35
+ "ED25519_PRIVATE_KEY_BYTES",
36
+ "ED25519_PUBLIC_KEY_BYTES",
37
+ "ED25519_SIGNATURE_BYTES",
38
+ "generate_private_key",
39
+ "load_private_key",
40
+ "load_public_key",
41
+ "private_key_bytes",
42
+ "public_key_bytes",
43
+ "public_key_to_base64",
44
+ "sign_digest",
45
+ "verify_signature",
46
+ ]
47
+
48
+ ED25519_PRIVATE_KEY_BYTES = 32
49
+ ED25519_PUBLIC_KEY_BYTES = 32
50
+ ED25519_SIGNATURE_BYTES = 64
51
+
52
+
53
+ def generate_private_key() -> Ed25519PrivateKey:
54
+ """Create a new Ed25519 key."""
55
+ return Ed25519PrivateKey.generate()
56
+
57
+
58
+ def public_key_bytes(private_key: Ed25519PrivateKey) -> bytes:
59
+ """Return raw public-key bytes."""
60
+ return private_key.public_key().public_bytes_raw()
61
+
62
+
63
+ def private_key_bytes(private_key: Ed25519PrivateKey) -> bytes:
64
+ """Return raw private-key bytes."""
65
+ return private_key.private_bytes_raw()
66
+
67
+
68
+ def load_private_key(raw: bytes) -> Ed25519PrivateKey:
69
+ """Load and validate raw private key material."""
70
+ if len(raw) != ED25519_PRIVATE_KEY_BYTES:
71
+ raise ValidationError(
72
+ f"Ed25519 private keys are {ED25519_PRIVATE_KEY_BYTES} raw bytes",
73
+ details={"length": len(raw)},
74
+ )
75
+ return Ed25519PrivateKey.from_private_bytes(raw)
76
+
77
+
78
+ def load_public_key(raw: bytes) -> Ed25519PublicKey:
79
+ """Load and validate raw public key material."""
80
+ if len(raw) != ED25519_PUBLIC_KEY_BYTES:
81
+ raise ValidationError(
82
+ f"Ed25519 public keys are {ED25519_PUBLIC_KEY_BYTES} raw bytes",
83
+ details={"length": len(raw)},
84
+ )
85
+ return Ed25519PublicKey.from_public_bytes(raw)
86
+
87
+
88
+ def public_key_to_base64(public_key: Ed25519PublicKey) -> str:
89
+ """Return the base64 spelling protocol documents carry a public key in."""
90
+ return base64.b64encode(public_key.public_bytes_raw()).decode("ascii")
91
+
92
+
93
+ def sign_digest(
94
+ private_key: Ed25519PrivateKey,
95
+ digest: Digest,
96
+ *,
97
+ key_id: str,
98
+ ) -> SignatureEnvelope:
99
+ """Sign the ASCII digest string."""
100
+ message = validate_digest(digest).encode("ascii")
101
+ signature = private_key.sign(message)
102
+ return SignatureEnvelope(
103
+ algorithm="ed25519",
104
+ key_id=key_id,
105
+ signature=base64.b64encode(signature).decode("ascii"),
106
+ )
107
+
108
+
109
+ def verify_signature(
110
+ public_key: Ed25519PublicKey,
111
+ digest: Digest,
112
+ signature: SignatureEnvelope,
113
+ ) -> bool:
114
+ """Verify the digest signature.
115
+
116
+ Returns ``False`` for a signature that is well-formed but wrong — a wrong
117
+ key, a different digest, or corrupted signature bytes. A malformed digest
118
+ is a different kind of problem and raises instead.
119
+ """
120
+ message = validate_digest(digest).encode("ascii")
121
+ raw_signature = base64.b64decode(signature.signature, validate=True)
122
+ if len(raw_signature) != ED25519_SIGNATURE_BYTES:
123
+ return False
124
+ try:
125
+ public_key.verify(raw_signature, message)
126
+ except InvalidSignature:
127
+ return False
128
+ return True
@@ -0,0 +1 @@
1
+ """Environment diagnosis: what is present, what is missing, what blocks work."""