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,394 @@
1
+ """Validate and scan a candidate skill directory. Spec section 15.2.
2
+
3
+ This module answers one question completely: given a path a participant typed,
4
+ what exactly is going to be snapshotted, and is any of it something we must
5
+ refuse to copy?
6
+
7
+ It refuses rather than repairs. A symlink is not resolved, a hidden file is not
8
+ skipped, and an oversized file is not truncated. Every one of those would
9
+ produce a snapshot that differs from what the participant believes they
10
+ submitted, and the snapshot is the scientific input to an experiment.
11
+
12
+ Every refusal here is about a file's *shape*: how big it is, how many there
13
+ are, whether it is a regular file, whether it decodes as text. None of them
14
+ reads the words. Decision 0036 removed the rules that did — a Skill is prose
15
+ about a procedure, a procedure about security is prose about credentials, and
16
+ refusing one on a regex blocked legitimate work in a product whose premise is
17
+ that people bring their own Skills. What a Skill says is now the participant's
18
+ business.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import os
24
+ import stat
25
+ from dataclasses import dataclass
26
+ from pathlib import Path, PurePosixPath
27
+ from typing import Final
28
+
29
+ from techtree.canonical import sha256_digest_bytes
30
+ from techtree.errors import NotFoundError, ValidationError
31
+ from techtree.fs import realpath_within
32
+ from techtree.models.base import Digest
33
+ from techtree.models.skill import SKILL_ENTRY_FILE
34
+ from techtree.skills.policy import SkillPolicy
35
+
36
+ __all__ = [
37
+ "MEDIA_TYPES",
38
+ "ScannedFile",
39
+ "SkillScanResult",
40
+ "enumerate_files",
41
+ "media_type_for",
42
+ "resolve_skill_root",
43
+ "scan_skill",
44
+ "validate_file",
45
+ ]
46
+
47
+
48
+ # ---------------------------------------------------------------------------
49
+ # Structures
50
+ # ---------------------------------------------------------------------------
51
+
52
+
53
+ @dataclass
54
+ class ScannedFile:
55
+ """A validated file, and everything the snapshot needs to know about it."""
56
+
57
+ source_path: Path
58
+ relative_path: PurePosixPath
59
+ size: int
60
+ media_type: str
61
+ digest: Digest
62
+
63
+
64
+ @dataclass
65
+ class SkillScanResult:
66
+ """The complete, ordered description of what would be snapshotted."""
67
+
68
+ root: Path
69
+ files: list[ScannedFile]
70
+
71
+
72
+ # ---------------------------------------------------------------------------
73
+ # Media types
74
+ # ---------------------------------------------------------------------------
75
+
76
+ #: Suffix to media type. Fixed strings: they travel into the SkillArtifact and
77
+ #: therefore into a digest, so they may never be derived from the host's
78
+ #: ``mimetypes`` database, which differs between machines.
79
+ MEDIA_TYPES: Final[dict[str, str]] = {
80
+ ".md": "text/markdown",
81
+ ".txt": "text/plain",
82
+ ".json": "application/json",
83
+ ".yaml": "application/yaml",
84
+ ".yml": "application/yaml",
85
+ }
86
+
87
+
88
+ # ---------------------------------------------------------------------------
89
+ # Root resolution and enumeration
90
+ # ---------------------------------------------------------------------------
91
+
92
+
93
+ def resolve_skill_root(path: Path) -> Path:
94
+ """Accept SKILL.md or containing directory.
95
+
96
+ Symlinks are left exactly as they are: whether one is tolerable is the
97
+ policy's decision, and :func:`scan_skill` makes it.
98
+ """
99
+ if not path.exists() and not path.is_symlink():
100
+ raise NotFoundError(
101
+ f"no such skill path: {path}",
102
+ details={"path": str(path)},
103
+ )
104
+
105
+ if path.is_dir():
106
+ root = path
107
+ elif path.is_file():
108
+ if path.name != SKILL_ENTRY_FILE:
109
+ raise ValidationError(
110
+ "a skill is named by its directory or by its SKILL.md, "
111
+ f"not by one of its other files: {path.name}",
112
+ details={"path": str(path)},
113
+ )
114
+ root = path.parent
115
+ else:
116
+ raise ValidationError(
117
+ f"skill path is neither a directory nor a regular file: {path}",
118
+ details={"path": str(path)},
119
+ )
120
+
121
+ entrypoint = root / SKILL_ENTRY_FILE
122
+ if not entrypoint.is_file():
123
+ raise ValidationError(
124
+ f"skill directory has no {SKILL_ENTRY_FILE} entrypoint: {root}",
125
+ details={"root": str(root), "entrypoint": SKILL_ENTRY_FILE},
126
+ )
127
+ return root
128
+
129
+
130
+ def enumerate_files(root: Path) -> list[Path]:
131
+ """Enumerate without following symlinks.
132
+
133
+ Symlinks, sockets, and devices are returned rather than skipped, so that
134
+ validation reports them instead of silently leaving them out of the
135
+ snapshot. Directory symlinks are reported and not descended into.
136
+ """
137
+ found: list[Path] = []
138
+ _walk(root, found)
139
+ return sorted(found, key=lambda item: _relative_key(item, root))
140
+
141
+
142
+ def _walk(directory: Path, found: list[Path]) -> None:
143
+ with os.scandir(directory) as entries:
144
+ for entry in entries:
145
+ child = Path(entry.path)
146
+ if entry.is_symlink():
147
+ found.append(child)
148
+ elif entry.is_dir(follow_symlinks=False):
149
+ _walk(child, found)
150
+ else:
151
+ found.append(child)
152
+
153
+
154
+ def _relative_key(path: Path, root: Path) -> str:
155
+ try:
156
+ return path.relative_to(root).as_posix()
157
+ except ValueError:
158
+ return str(path)
159
+
160
+
161
+ def _relative_path(path: Path, root: Path) -> PurePosixPath:
162
+ try:
163
+ return PurePosixPath(path.relative_to(root).as_posix())
164
+ except ValueError as error:
165
+ raise ValidationError(
166
+ f"file is outside the skill root: {path}",
167
+ details={"path": str(path), "root": str(root)},
168
+ ) from error
169
+
170
+
171
+ # ---------------------------------------------------------------------------
172
+ # Per-file validation
173
+ # ---------------------------------------------------------------------------
174
+
175
+
176
+ def validate_file(
177
+ path: Path,
178
+ root: Path,
179
+ policy: SkillPolicy,
180
+ ) -> None:
181
+ """Validate containment, type, suffix, size, and hidden status."""
182
+ relative = _relative_path(path, root)
183
+
184
+ if not policy.allow_hidden_files:
185
+ hidden = next((part for part in relative.parts if part.startswith(".")), None)
186
+ if hidden is not None:
187
+ raise ValidationError(
188
+ f"skill contains a hidden path, which is never submitted: {relative}",
189
+ details={"path": relative.as_posix(), "hidden_component": hidden},
190
+ )
191
+
192
+ if path.is_symlink() and not policy.allow_symlinks:
193
+ raise ValidationError(
194
+ "skill contains a symlink, and a snapshot must mean the same thing "
195
+ f"on every machine: {relative}",
196
+ details={"path": relative.as_posix()},
197
+ )
198
+
199
+ status = _status(path, relative, policy)
200
+ if not stat.S_ISREG(status.st_mode):
201
+ raise ValidationError(
202
+ f"skill contains {_describe_type(status.st_mode)}, which cannot be "
203
+ f"snapshotted: {relative}",
204
+ details={
205
+ "path": relative.as_posix(),
206
+ "kind": _describe_type(status.st_mode),
207
+ },
208
+ )
209
+
210
+ suffix = path.suffix.lower()
211
+ if suffix not in policy.allowed_suffixes:
212
+ raise ValidationError(
213
+ f"skill file has an unsupported suffix: {relative}",
214
+ details={
215
+ "path": relative.as_posix(),
216
+ "suffix": suffix,
217
+ "allowed_suffixes": [*sorted(policy.allowed_suffixes)],
218
+ },
219
+ )
220
+
221
+ if status.st_size > policy.maximum_file_bytes:
222
+ raise ValidationError(
223
+ f"skill file is larger than {policy.maximum_file_bytes} bytes: {relative}",
224
+ details={
225
+ "path": relative.as_posix(),
226
+ "size": status.st_size,
227
+ "maximum_file_bytes": policy.maximum_file_bytes,
228
+ },
229
+ )
230
+
231
+ if not realpath_within(path, root):
232
+ raise ValidationError(
233
+ f"skill file resolves outside the skill root: {relative}",
234
+ details={"path": relative.as_posix(), "root": str(root)},
235
+ )
236
+
237
+
238
+ def _status(path: Path, relative: PurePosixPath, policy: SkillPolicy) -> os.stat_result:
239
+ """Stat a candidate file, reporting an unreadable one as a refusal.
240
+
241
+ A file the scanner cannot read — a broken link, a directory it has no
242
+ permission to enter — is not a defect in Techtree, so it is reported the
243
+ same way as anything else the participant needs to fix.
244
+ """
245
+ try:
246
+ return path.stat(follow_symlinks=policy.allow_symlinks)
247
+ except OSError as error:
248
+ raise ValidationError(
249
+ f"skill file cannot be read: {relative}",
250
+ details={"path": relative.as_posix()},
251
+ ) from error
252
+
253
+
254
+ def _describe_type(mode: int) -> str:
255
+ if stat.S_ISDIR(mode):
256
+ return "a directory"
257
+ if stat.S_ISFIFO(mode):
258
+ return "a FIFO"
259
+ if stat.S_ISSOCK(mode):
260
+ return "a socket"
261
+ if stat.S_ISBLK(mode) or stat.S_ISCHR(mode):
262
+ return "a device"
263
+ return "something that is not a regular file"
264
+
265
+
266
+ def media_type_for(path: Path) -> str:
267
+ """Map allowed suffix to stable media type."""
268
+ media_type = MEDIA_TYPES.get(path.suffix.lower())
269
+ if media_type is None:
270
+ raise ValidationError(
271
+ f"no media type is defined for this suffix: {path.name}",
272
+ details={"suffix": path.suffix.lower()},
273
+ )
274
+ return media_type
275
+
276
+
277
+ # ---------------------------------------------------------------------------
278
+ # Text decoding
279
+ # ---------------------------------------------------------------------------
280
+
281
+
282
+ def _decode_text(data: bytes, reported_path: str) -> str:
283
+ """Read a validated skill file as text, refusing anything unreadable."""
284
+ if b"\x00" in data:
285
+ raise ValidationError(
286
+ f"skill file is binary, and an instruction skill is text: {reported_path}",
287
+ details={"path": reported_path},
288
+ )
289
+ try:
290
+ return data.decode("utf-8")
291
+ except UnicodeDecodeError as error:
292
+ raise ValidationError(
293
+ f"skill file is not valid UTF-8 text: {reported_path}",
294
+ details={"path": reported_path},
295
+ ) from error
296
+
297
+
298
+ # ---------------------------------------------------------------------------
299
+ # Whole-skill scan
300
+ # ---------------------------------------------------------------------------
301
+
302
+
303
+ def scan_skill(
304
+ path: Path,
305
+ policy: SkillPolicy,
306
+ ) -> SkillScanResult:
307
+ """Perform complete validation and scanning.
308
+
309
+ Raises :class:`~techtree.errors.ValidationError` for anything the policy
310
+ forbids: a shape the snapshot cannot carry, a size over the limit, or a
311
+ file that is not text.
312
+ """
313
+ root = resolve_skill_root(path)
314
+ if root.is_symlink() and not policy.allow_symlinks:
315
+ raise ValidationError(
316
+ f"skill root is a symlink, which is never snapshotted: {root}",
317
+ details={"root": str(root)},
318
+ )
319
+
320
+ candidates = enumerate_files(root)
321
+ if len(candidates) > policy.maximum_files:
322
+ raise ValidationError(
323
+ f"skill contains {len(candidates)} files, "
324
+ f"more than the {policy.maximum_files} allowed",
325
+ details={"count": len(candidates), "maximum_files": policy.maximum_files},
326
+ )
327
+
328
+ files: list[ScannedFile] = []
329
+ total = 0
330
+
331
+ for candidate in candidates:
332
+ validate_file(candidate, root, policy)
333
+ relative = _relative_path(candidate, root)
334
+ data = candidate.read_bytes()
335
+ total += len(data)
336
+ if total > policy.maximum_total_bytes:
337
+ raise ValidationError(
338
+ "skill is larger than the "
339
+ f"{policy.maximum_total_bytes} byte total limit",
340
+ details={
341
+ "total_bytes": total,
342
+ "maximum_total_bytes": policy.maximum_total_bytes,
343
+ },
344
+ )
345
+ _decode_text(data, relative.as_posix())
346
+ files.append(
347
+ ScannedFile(
348
+ source_path=candidate,
349
+ relative_path=relative,
350
+ size=len(data),
351
+ media_type=media_type_for(candidate),
352
+ digest=sha256_digest_bytes(data),
353
+ )
354
+ )
355
+
356
+ _require_entrypoint(files, root, policy)
357
+ _reject_case_collisions(files)
358
+
359
+ return SkillScanResult(
360
+ root=root,
361
+ files=sorted(files, key=lambda item: item.relative_path.as_posix()),
362
+ )
363
+
364
+
365
+ def _require_entrypoint(
366
+ files: list[ScannedFile], root: Path, policy: SkillPolicy
367
+ ) -> None:
368
+ entrypoint = PurePosixPath(policy.required_entrypoint)
369
+ if not any(item.relative_path == entrypoint for item in files):
370
+ raise ValidationError(
371
+ f"skill directory has no {policy.required_entrypoint} entrypoint: {root}",
372
+ details={"root": str(root), "entrypoint": policy.required_entrypoint},
373
+ )
374
+
375
+
376
+ def _reject_case_collisions(files: list[ScannedFile]) -> None:
377
+ """Refuse paths that differ only by case.
378
+
379
+ Such a pair is two files here and one file on a case-insensitive
380
+ filesystem, so the snapshot would not survive a round trip through one
381
+ machine to another.
382
+ """
383
+ seen: dict[str, str] = {}
384
+ for item in files:
385
+ posix = item.relative_path.as_posix()
386
+ folded = posix.casefold()
387
+ existing = seen.get(folded)
388
+ if existing is not None:
389
+ raise ValidationError(
390
+ "skill contains paths that differ only by case, which collide "
391
+ f"on a case-insensitive filesystem: {existing} and {posix}",
392
+ details={"paths": [existing, posix]},
393
+ )
394
+ seen[folded] = posix