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,299 @@
1
+ """Taskset locking and mechanical validation. Spec 11.9, decisions 0003 A1.
2
+
3
+ Three objects, with a deliberate split between them.
4
+
5
+ ``TasksetLock``
6
+ What the taskset resolved to: engine, package, ordered task hashes, and the
7
+ membership digest computed from them.
8
+
9
+ ``TasksetValidationReceipt``
10
+ The scientific answer to one narrow question — is this taskset mechanically
11
+ valid and internally consistent? It carries no identifier, no timestamp, no
12
+ duration, no path, and no raw log. Decisions document 0003 makes the
13
+ receipt's own content digest its identity, which is what allows the
14
+ publisher's receipt and a participant's locally recomputed receipt to be
15
+ compared by equality instead of by narrative. A display identifier can be
16
+ derived with :func:`validation_display_id`; it is never stored.
17
+
18
+ ``ValidationExecutionRecord``
19
+ Everything the receipt refuses to carry: when it ran, on which host, with
20
+ which command, and which raw artifacts it left behind. It is local
21
+ operational provenance, never part of the Campaign graph, never referenced
22
+ by a public Climb, and never byte-compared by ``make generated-check``.
23
+
24
+ Between the raw upstream output and the receipt sits ``ValidationEvidence``:
25
+ the normalized, deterministic form of what the validator reported, with
26
+ elapsed times, temporary paths, run identifiers, and host detail removed. That
27
+ is the artifact a receipt points at and the one that ships publicly.
28
+
29
+ The receipt does not answer whether the environment is discriminative, robust
30
+ to reward hacking, suitable for RL, or economically useful. Those belong to a
31
+ future ``EnvironmentQualificationReport``.
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ from typing import Final, Literal, Self
37
+
38
+ from pydantic import Field, model_validator
39
+
40
+ from techtree.models.base import (
41
+ ArtifactRef,
42
+ Digest,
43
+ NonEmptyString,
44
+ ProtocolModel,
45
+ StateModel,
46
+ UtcDateTime,
47
+ )
48
+ from techtree.models.campaign import TasksetRef
49
+
50
+ __all__ = [
51
+ "REQUIRED_VALIDATION_CHECKS",
52
+ "TasksetLock",
53
+ "TasksetValidationReceipt",
54
+ "UpstreamValidationSummary",
55
+ "ValidationCheck",
56
+ "ValidationEvidence",
57
+ "ValidationEvidenceSummary",
58
+ "ValidationEvidenceTask",
59
+ "ValidationExecutionRecord",
60
+ "ValidationMethod",
61
+ "ValidationTaskOutcome",
62
+ "validation_display_id",
63
+ ]
64
+
65
+ #: Every check a receipt must report on. A receipt that silently omits one is
66
+ #: not a weaker receipt, it is an unreadable one: a reader cannot tell whether
67
+ #: the check passed or was never attempted.
68
+ REQUIRED_VALIDATION_CHECKS: Final[tuple[str, ...]] = (
69
+ "upstream_gold",
70
+ "upstream_setup",
71
+ "membership_repeatability",
72
+ "task_hash_uniqueness",
73
+ "committed_membership_match",
74
+ "expected_task_count",
75
+ )
76
+
77
+ #: How many hexadecimal characters of the receipt digest a display identifier
78
+ #: shows. Enough to be unambiguous in a terminal, short enough to read aloud.
79
+ _DISPLAY_ID_HEX_LENGTH: Final = 24
80
+
81
+
82
+ class TasksetLock(ProtocolModel):
83
+ """What a taskset reference resolved to, pinned."""
84
+
85
+ schema_version: Literal["techtree.taskset-lock.v1alpha1"]
86
+ taskset_ref: TasksetRef
87
+ engine_digest: Digest
88
+ resolved_package_digest: Digest
89
+ ordered_task_hashes: list[Digest]
90
+ membership_digest: Digest
91
+ task_count: int = Field(ge=1)
92
+
93
+ @model_validator(mode="after")
94
+ def _check_task_list(self) -> Self:
95
+ """Reject a hash list that disagrees with the count or repeats a task."""
96
+ if len(self.ordered_task_hashes) != self.task_count:
97
+ raise ValueError(
98
+ f"lock records {len(self.ordered_task_hashes)} task hashes but "
99
+ f"a task_count of {self.task_count}"
100
+ )
101
+ if len(set(self.ordered_task_hashes)) != len(self.ordered_task_hashes):
102
+ raise ValueError("locked task hashes must be unique")
103
+ return self
104
+
105
+
106
+ class ValidationCheck(ProtocolModel):
107
+ """One mechanical check and what it found."""
108
+
109
+ id: NonEmptyString
110
+ status: Literal["passed", "failed", "warning", "not_run"]
111
+ detail: NonEmptyString
112
+
113
+
114
+ class UpstreamValidationSummary(ProtocolModel):
115
+ """What the upstream validator reported, in aggregate."""
116
+
117
+ mode: Literal["all", "gold", "setup"]
118
+ total: int = Field(ge=0)
119
+ recorded: int = Field(ge=0)
120
+ valid: int = Field(ge=0)
121
+ invalid: int = Field(ge=0)
122
+ error: int = Field(ge=0)
123
+ timeout: int = Field(ge=0)
124
+ missing: int = Field(ge=0)
125
+ valid_rate: float | None = Field(default=None, ge=0.0, le=1.0)
126
+
127
+ @model_validator(mode="after")
128
+ def _check_counts_add_up(self) -> Self:
129
+ """Reject a summary whose parts do not account for its total."""
130
+ counted = self.valid + self.invalid + self.error + self.timeout + self.missing
131
+ if counted != self.total:
132
+ raise ValueError(
133
+ f"summary accounts for {counted} tasks but reports a total of "
134
+ f"{self.total}"
135
+ )
136
+ if self.recorded > self.total:
137
+ raise ValueError("recorded cannot exceed total")
138
+ return self
139
+
140
+
141
+ class ValidationMethod(ProtocolModel):
142
+ """How the validation was performed, in reproducible terms."""
143
+
144
+ kind: Literal["verifiers_validate"]
145
+ mode: Literal["all"]
146
+ runtime: Literal["subprocess"]
147
+ validator_revision: NonEmptyString
148
+
149
+
150
+ class ValidationTaskOutcome(ProtocolModel):
151
+ """One validator verdict for one task."""
152
+
153
+ valid: bool
154
+ reason: NonEmptyString
155
+
156
+
157
+ class ValidationEvidenceTask(ProtocolModel):
158
+ """The normalized per-task record the validator produced."""
159
+
160
+ position: int = Field(ge=0)
161
+ task_hash: Digest
162
+ gold: ValidationTaskOutcome
163
+ setup: ValidationTaskOutcome
164
+
165
+
166
+ class ValidationEvidenceSummary(ProtocolModel):
167
+ """Aggregate counts over the normalized task records."""
168
+
169
+ total: int = Field(ge=0)
170
+ valid: int = Field(ge=0)
171
+ invalid: int = Field(ge=0)
172
+ error: int = Field(ge=0)
173
+ timeout: int = Field(ge=0)
174
+ missing: int = Field(ge=0)
175
+
176
+ @model_validator(mode="after")
177
+ def _check_counts_add_up(self) -> Self:
178
+ """Reject a summary whose parts do not account for its total."""
179
+ counted = self.valid + self.invalid + self.error + self.timeout + self.missing
180
+ if counted != self.total:
181
+ raise ValueError(
182
+ f"evidence accounts for {counted} tasks but reports a total of "
183
+ f"{self.total}"
184
+ )
185
+ return self
186
+
187
+
188
+ class ValidationEvidence(ProtocolModel):
189
+ """Deterministic normalized validator output. Decisions 0003 A1.
190
+
191
+ Normalization has already removed elapsed times, wall-clock timestamps,
192
+ temporary paths, run identifiers, process identifiers, hostnames, absolute
193
+ output directories, log prefixes, and tracebacks from successful runs.
194
+ What remains is the part two honest parties must agree on.
195
+ """
196
+
197
+ schema_version: Literal["techtree.validation-evidence.v1alpha1"]
198
+ taskset_lock_digest: Digest
199
+ method: ValidationMethod
200
+ tasks: list[ValidationEvidenceTask]
201
+ summary: ValidationEvidenceSummary
202
+
203
+ @model_validator(mode="after")
204
+ def _check_tasks_are_a_dense_ordered_run(self) -> Self:
205
+ """Reject gaps, repeats, or an order that depends on who wrote the file."""
206
+ positions = [task.position for task in self.tasks]
207
+ if positions != list(range(len(self.tasks))):
208
+ raise ValueError(
209
+ "evidence tasks must be sorted by position and cover 0..n-1 "
210
+ "without gaps"
211
+ )
212
+ hashes = [task.task_hash for task in self.tasks]
213
+ if len(set(hashes)) != len(hashes):
214
+ raise ValueError("evidence task hashes must be unique")
215
+ if self.summary.total != len(self.tasks):
216
+ raise ValueError(
217
+ f"evidence lists {len(self.tasks)} tasks but its summary reports "
218
+ f"{self.summary.total}"
219
+ )
220
+ return self
221
+
222
+
223
+ class TasksetValidationReceipt(ProtocolModel):
224
+ """Is this taskset mechanically valid and internally consistent?
225
+
226
+ Decisions document 0003 A1 removed the identifier, creation time, raw
227
+ artifact list, durations, absolute paths, and tracebacks that the original
228
+ shape carried. Every one of those varies between two correct runs of the
229
+ same validation, which meant the publisher's receipt and a participant's
230
+ receipt could never be equal even when they agreed completely. They now
231
+ are, and that equality is the check.
232
+ """
233
+
234
+ schema_version: Literal["techtree.taskset-validation.v1alpha1"]
235
+ taskset_lock_digest: Digest
236
+ engine_digest: Digest
237
+ method: ValidationMethod
238
+ status: Literal["valid", "invalid", "errored"]
239
+ upstream_summary: UpstreamValidationSummary
240
+ checks: list[ValidationCheck]
241
+ normalized_evidence: ArtifactRef | None
242
+
243
+ @model_validator(mode="after")
244
+ def _check_required_checks_are_reported(self) -> Self:
245
+ """Reject a receipt that omits or repeats a required check."""
246
+ reported = [check.id for check in self.checks]
247
+ if len(set(reported)) != len(reported):
248
+ raise ValueError("a receipt reports each check exactly once")
249
+ missing = [name for name in REQUIRED_VALIDATION_CHECKS if name not in reported]
250
+ if missing:
251
+ raise ValueError(
252
+ "receipt is missing required checks: " + ", ".join(missing)
253
+ )
254
+ if self.status == "valid" and any(
255
+ check.status == "failed" for check in self.checks
256
+ ):
257
+ raise ValueError("a receipt with a failed check is not valid")
258
+ return self
259
+
260
+
261
+ class ValidationExecutionRecord(StateModel):
262
+ """Local operational provenance for one validation execution.
263
+
264
+ Decisions document 0003 A1. This is the mutable, host-specific companion to
265
+ the receipt. It is never part of the Campaign graph, never referenced by a
266
+ public Climb, never the publisher commitment, and never byte-compared by
267
+ generated-fixture checks.
268
+ """
269
+
270
+ schema_version: Literal["techtree.validation-execution.v1alpha1"]
271
+ id: NonEmptyString
272
+ receipt_digest: Digest
273
+ started_at: UtcDateTime
274
+ finished_at: UtcDateTime
275
+ command: list[NonEmptyString]
276
+ command_digest: Digest
277
+ host_platform: NonEmptyString
278
+ worker_pid: int | None
279
+ raw_artifacts: list[ArtifactRef]
280
+
281
+ @model_validator(mode="after")
282
+ def _check_execution_window(self) -> Self:
283
+ """Reject a record that finished before it started."""
284
+ if self.finished_at < self.started_at:
285
+ raise ValueError("finished_at cannot precede started_at")
286
+ if not self.command:
287
+ raise ValueError("a validation execution records the command it ran")
288
+ return self
289
+
290
+
291
+ def validation_display_id(receipt_digest: Digest) -> str:
292
+ """Return the human-facing identifier derived from a receipt digest.
293
+
294
+ The value is derived for display only. Storing it inside the receipt would
295
+ put a second, redundant identity in an object whose whole point is that its
296
+ content is its identity.
297
+ """
298
+ _, _, hexadecimal = receipt_digest.partition(":")
299
+ return f"validation_{hexadecimal[:_DISPLAY_ID_HEX_LENGTH]}"
techtree/paths.py ADDED
@@ -0,0 +1,116 @@
1
+ """Where Techtree keeps its local state. Spec sections 10.10 and 28.
2
+
3
+ One root directory holds everything: the settings file, the download cache,
4
+ submission drafts, runs, and installed managed engines. Resolving that root
5
+ through ``platformdirs`` means the location follows each platform's own
6
+ convention instead of hard-coding a Unix dotfile path.
7
+
8
+ Nothing here touches the filesystem at import time. Directories are created
9
+ only when :func:`ensure_path_layout` is called, which keeps ``import techtree``
10
+ free of side effects.
11
+
12
+ ``identities_dir`` is resolved here and created elsewhere. The local executor
13
+ identity (spec section 7.5) is made by ``techtree setup``, or by a run that
14
+ reaches the point of signing its receipts, and
15
+ :class:`~techtree.identity.store.IdentityStore` creates the directory then.
16
+ Creating it as part of the layout would leave every machine looking as though
17
+ it held a key.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from dataclasses import dataclass
23
+ from pathlib import Path
24
+
25
+ from platformdirs import user_data_path
26
+
27
+ from techtree.canonical import validate_digest
28
+ from techtree.fs import ensure_private_directory
29
+ from techtree.ids import validate_id
30
+ from techtree.models.base import Digest
31
+
32
+ __all__ = [
33
+ "APPLICATION_NAME",
34
+ "TechtreePaths",
35
+ "default_paths",
36
+ "ensure_path_layout",
37
+ "paths_from_root",
38
+ ]
39
+
40
+ APPLICATION_NAME = "techtree"
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class TechtreePaths:
45
+ """Every directory and file Techtree owns, derived from one root."""
46
+
47
+ root: Path
48
+ config_file: Path
49
+ cache_dir: Path
50
+ drafts_dir: Path
51
+ runs_dir: Path
52
+ engines_dir: Path
53
+ identities_dir: Path
54
+
55
+ def draft_dir(self, draft_id: str) -> Path:
56
+ """Return the directory holding one submission draft."""
57
+ return self.drafts_dir / validate_id(draft_id, "draft")
58
+
59
+ def run_dir(self, run_id: str) -> Path:
60
+ """Return the directory holding one run."""
61
+ return self.runs_dir / validate_id(run_id, "run")
62
+
63
+ def skills_cache_dir(self) -> Path:
64
+ """Return the directory holding Skills Techtree materialized itself."""
65
+ return self.cache_dir / "skills"
66
+
67
+ def skill_cache_dir(self, digest: Digest) -> Path:
68
+ """Return the cache directory holding one Skill, named by its digest.
69
+
70
+ Content-addressed by the Skill's own root digest and spelled the way
71
+ an engine install is, so the same Skill is materialized once per home
72
+ and a directory's name is a claim its contents can be checked against.
73
+ """
74
+ return self.skills_cache_dir() / validate_digest(digest).replace(":", "-", 1)
75
+
76
+ def engine_dir(self, digest: Digest) -> Path:
77
+ """Return the install directory for one managed engine bundle.
78
+
79
+ The digest's colon becomes a dash: ``sha256-<hex>``. A colon is legal
80
+ on POSIX but not on Windows, and an engine install is content-addressed
81
+ on every platform.
82
+ """
83
+ return self.engines_dir / validate_digest(digest).replace(":", "-", 1)
84
+
85
+
86
+ def paths_from_root(root: Path) -> TechtreePaths:
87
+ """Construct deterministic test paths."""
88
+ resolved = Path(root)
89
+ return TechtreePaths(
90
+ root=resolved,
91
+ config_file=resolved / "config.toml",
92
+ cache_dir=resolved / "cache",
93
+ drafts_dir=resolved / "drafts",
94
+ runs_dir=resolved / "runs",
95
+ engines_dir=resolved / "engines",
96
+ identities_dir=resolved / "identities",
97
+ )
98
+
99
+
100
+ def default_paths() -> TechtreePaths:
101
+ """Resolve platform paths through platformdirs."""
102
+ return paths_from_root(
103
+ user_data_path(appname=APPLICATION_NAME, appauthor=False, roaming=False)
104
+ )
105
+
106
+
107
+ def ensure_path_layout(paths: TechtreePaths) -> None:
108
+ """Create top-level private directories."""
109
+ for directory in (
110
+ paths.root,
111
+ paths.cache_dir,
112
+ paths.drafts_dir,
113
+ paths.runs_dir,
114
+ paths.engines_dir,
115
+ ):
116
+ ensure_private_directory(directory)
@@ -0,0 +1,31 @@
1
+ """Showing a result without becoming part of it. Spec sections 7.13-7.17.
2
+
3
+ Spec section 3.5 draws the line this package lives on: rich terminal output is
4
+ a view, not scientific evidence. No colour, markup, prose, or channel-specific
5
+ formatting enters an ``EpisodeReceipt`` or an ``UpliftReport``, and nothing in
6
+ this package can change a number, a status, or a digest — every one of them is
7
+ copied out of a signed report and formatted.
8
+
9
+ That is why the payload is channel-neutral. One builder produces one
10
+ :class:`~techtree.presentation.models.UpliftPresentationPayload` from the
11
+ signed report; a terminal renderer, a phone-sized Markdown renderer, and the
12
+ founder's own rich-output Skill all consume the same object. A second builder
13
+ per channel would be a second place a result could be described differently.
14
+
15
+ The division of labour:
16
+
17
+ ``models``
18
+ The neutral payload, and the pieces a renderer draws.
19
+ ``build``
20
+ Turning one signed report into one payload.
21
+ ``rich``
22
+ The accessible terminal rendering.
23
+ ``compact``
24
+ The bounded, ANSI-free rendering a gateway or a phone can carry.
25
+ ``sanitize``
26
+ What may appear at all, enforced rather than intended.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ __all__: list[str] = []