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.
- techtree/__init__.py +35 -0
- techtree/__main__.py +14 -0
- techtree/canonical.py +239 -0
- techtree/catalog/__init__.py +25 -0
- techtree/catalog/repository.py +400 -0
- techtree/catalog/service.py +419 -0
- techtree/cli/__init__.py +1 -0
- techtree/cli/app.py +416 -0
- techtree/cli/commands/__init__.py +1 -0
- techtree/cli/commands/climb.py +1223 -0
- techtree/cli/commands/doctor.py +147 -0
- techtree/cli/commands/engine.py +207 -0
- techtree/cli/commands/proof.py +556 -0
- techtree/cli/commands/publish.py +447 -0
- techtree/cli/commands/release.py +303 -0
- techtree/cli/commands/run.py +1067 -0
- techtree/cli/commands/setup.py +181 -0
- techtree/cli/commands/skill.py +221 -0
- techtree/cli/commands/uplift.py +698 -0
- techtree/cli/commands/withdraw.py +212 -0
- techtree/cli/confirm.py +47 -0
- techtree/cli/context.py +96 -0
- techtree/cli/invoke.py +220 -0
- techtree/cli/output.py +280 -0
- techtree/constants.py +138 -0
- techtree/crypto.py +128 -0
- techtree/doctor/__init__.py +1 -0
- techtree/doctor/checks.py +675 -0
- techtree/doctor/execution_checks.py +435 -0
- techtree/doctor/service.py +326 -0
- techtree/drafts/__init__.py +32 -0
- techtree/drafts/source.py +146 -0
- techtree/drafts/store.py +992 -0
- techtree/engines/__init__.py +1 -0
- techtree/engines/bundle.py +251 -0
- techtree/engines/installer.py +679 -0
- techtree/engines/registry.py +235 -0
- techtree/engines/runner.py +170 -0
- techtree/errors.py +262 -0
- techtree/fs.py +234 -0
- techtree/harness.py +108 -0
- techtree/identity/__init__.py +41 -0
- techtree/identity/models.py +113 -0
- techtree/identity/service.py +199 -0
- techtree/identity/store.py +263 -0
- techtree/ids.py +85 -0
- techtree/manifests/__init__.py +39 -0
- techtree/manifests/builder.py +433 -0
- techtree/manifests/compare.py +376 -0
- techtree/models/__init__.py +282 -0
- techtree/models/base.py +201 -0
- techtree/models/campaign.py +484 -0
- techtree/models/catalog.py +227 -0
- techtree/models/cli.py +151 -0
- techtree/models/climb.py +254 -0
- techtree/models/data_policy.py +130 -0
- techtree/models/engine.py +156 -0
- techtree/models/episode_receipt.py +130 -0
- techtree/models/evaluation_backend.py +113 -0
- techtree/models/experiment.py +154 -0
- techtree/models/run.py +214 -0
- techtree/models/skill.py +156 -0
- techtree/models/uplift_report.py +158 -0
- techtree/models/validation.py +299 -0
- techtree/paths.py +116 -0
- techtree/presentation/__init__.py +31 -0
- techtree/presentation/build.py +1242 -0
- techtree/presentation/compact.py +246 -0
- techtree/presentation/evidence.py +169 -0
- techtree/presentation/models.py +358 -0
- techtree/presentation/rich.py +312 -0
- techtree/presentation/sanitize.py +156 -0
- techtree/publication/__init__.py +44 -0
- techtree/publication/address.py +180 -0
- techtree/publication/coordinates.py +26 -0
- techtree/publication/journal.py +212 -0
- techtree/publication/keccak.py +183 -0
- techtree/publication/models.py +209 -0
- techtree/publication/offer.py +35 -0
- techtree/publication/service.py +618 -0
- techtree/publication/transport.py +296 -0
- techtree/publication/verify.py +242 -0
- techtree/publication/withdraw.py +156 -0
- techtree/py.typed +0 -0
- techtree/receipts/__init__.py +52 -0
- techtree/receipts/bundle.py +578 -0
- techtree/receipts/compare.py +1065 -0
- techtree/receipts/episode.py +672 -0
- techtree/receipts/execution.py +630 -0
- techtree/receipts/observed.py +474 -0
- techtree/receipts/set.py +336 -0
- techtree/receipts/uplift.py +655 -0
- techtree/receipts/verify.py +1055 -0
- techtree/release/__init__.py +9 -0
- techtree/release/bootstrap.py +509 -0
- techtree/release/checks.py +376 -0
- techtree/release/document.py +125 -0
- techtree/release/generate.py +221 -0
- techtree/release/models.py +293 -0
- techtree/release/provenance.py +109 -0
- techtree/resources/catalog/campaigns/hello-world-climb.json +1 -0
- techtree/resources/catalog/catalog.json +32 -0
- techtree/resources/catalog/climbs/hello-world-climb.json +1 -0
- techtree/resources/catalog/data-policies/hello-world-climb.json +1 -0
- techtree/resources/catalog/taskset-validations/hello-world-climb.json +1 -0
- techtree/resources/catalog/validation-evidence/hello-world-climb.json +1 -0
- techtree/resources/engines/default/engine.json +20 -0
- techtree/resources/engines/default/packages/procedure-transfer-v1/procedure_transfer_v1/__init__.py +7 -0
- techtree/resources/engines/default/packages/procedure-transfer-v1/procedure_transfer_v1/algorithm.py +136 -0
- techtree/resources/engines/default/packages/procedure-transfer-v1/procedure_transfer_v1/dataset.py +156 -0
- techtree/resources/engines/default/packages/procedure-transfer-v1/procedure_transfer_v1/env.py +48 -0
- techtree/resources/engines/default/packages/procedure-transfer-v1/procedure_transfer_v1/taskset.py +163 -0
- techtree/resources/engines/default/packages/procedure-transfer-v1/pyproject.toml +13 -0
- techtree/resources/engines/default/pyproject.toml +23 -0
- techtree/resources/engines/default/tools/inspect_taskset.py +124 -0
- techtree/resources/engines/default/tools/normalize_eval_output.py +470 -0
- techtree/resources/engines/default/tools/normalize_validation.py +222 -0
- techtree/resources/engines/default/uv.lock +1758 -0
- techtree/resources/harness/hermes-agent-0.19.0.json +69 -0
- techtree/resources/release/build-provenance.json +4 -0
- techtree/resources/release/release-core.json +24 -0
- techtree/runs/__init__.py +31 -0
- techtree/runs/artifacts.py +750 -0
- techtree/runs/child_registry.py +228 -0
- techtree/runs/events.py +478 -0
- techtree/runs/executor.py +140 -0
- techtree/runs/fake.py +741 -0
- techtree/runs/launcher.py +253 -0
- techtree/runs/machine.py +489 -0
- techtree/runs/real.py +789 -0
- techtree/runs/service.py +616 -0
- techtree/runs/store.py +555 -0
- techtree/runs/validation.py +259 -0
- techtree/runs/variants.py +684 -0
- techtree/settings.py +143 -0
- techtree/skills/__init__.py +14 -0
- techtree/skills/archive.py +282 -0
- techtree/skills/policy.py +62 -0
- techtree/skills/scanner.py +394 -0
- techtree/skills/service.py +752 -0
- techtree/skills/starter.py +434 -0
- techtree/tasksets/__init__.py +1 -0
- techtree/tasksets/membership.py +269 -0
- techtree/tasksets/provider.py +207 -0
- techtree/tasksets/resolver.py +311 -0
- techtree/tasksets/service.py +484 -0
- techtree/tasksets/verifiers_cli.py +538 -0
- techtree/uplift/__init__.py +20 -0
- techtree/uplift/context.py +544 -0
- techtree/uplift/derive.py +203 -0
- techtree/uplift/public_tasks.py +151 -0
- techtree/uplift/service.py +719 -0
- techtree/uplift/source.py +160 -0
- techtree/verifiers/__init__.py +31 -0
- techtree/verifiers/budget.py +219 -0
- techtree/verifiers/child.py +633 -0
- techtree/verifiers/compiler.py +432 -0
- techtree/verifiers/config.py +365 -0
- techtree/verifiers/credentials.py +321 -0
- techtree/verifiers/image.py +126 -0
- techtree/verifiers/models.py +527 -0
- techtree/verifiers/outputs.py +368 -0
- techtree/verifiers/progress.py +192 -0
- techtree/verifiers/supervisor.py +341 -0
- techtree/verifiers/verify.py +782 -0
- techtree/version.py +39 -0
- techtree/worker/__init__.py +18 -0
- techtree/worker/execute.py +487 -0
- techtree/worker/main.py +57 -0
- techtree-0.1.0.dist-info/METADATA +344 -0
- techtree-0.1.0.dist-info/RECORD +174 -0
- techtree-0.1.0.dist-info/WHEEL +4 -0
- techtree-0.1.0.dist-info/entry_points.txt +3 -0
- 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."""
|