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,235 @@
1
+ """Which engines exist, where they live, and which one is active.
2
+
3
+ Spec section 20.4.
4
+
5
+ An engine is identified by the digest of its static bundle, and that digest is
6
+ also where it lives: ``<techtree home>/engines/sha256-<hex>/``. Two consequences
7
+ follow, and both are the point.
8
+
9
+ Nothing here decides anything about *content*. The registry answers questions
10
+ about the filesystem and about settings; whether the environment inside an
11
+ engine actually works is the installer's question, asked by running the engine.
12
+
13
+ An engine counts as installed only when its ``installed.json`` is present,
14
+ parses, and names the directory it sits in. That file is written last, after
15
+ verification, so a directory left behind by an interrupted install is reported
16
+ as absent rather than as broken — and the next install replaces it.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from pathlib import Path
22
+ from typing import Final
23
+
24
+ from techtree.canonical import validate_digest
25
+ from techtree.engines.bundle import (
26
+ ENGINE_TOOLS,
27
+ INSTALLATION_FILENAME,
28
+ default_engine_digest,
29
+ )
30
+ from techtree.errors import EngineError, NotFoundError, ValidationError
31
+ from techtree.fs import atomic_write_json
32
+ from techtree.models.base import Digest
33
+ from techtree.models.engine import EngineInstallation, EngineStatus
34
+ from techtree.paths import TechtreePaths
35
+ from techtree.settings import Settings, load_settings, save_settings
36
+
37
+ __all__ = ["VENV_DIRECTORY", "EngineRegistry", "digest_from_directory_name"]
38
+
39
+ #: uv's project environment, relative to the engine root.
40
+ VENV_DIRECTORY: Final = ".venv"
41
+ _BIN_DIRECTORY: Final = "bin"
42
+ _TOOLS_DIRECTORY: Final = "tools"
43
+
44
+
45
+ class EngineRegistry:
46
+ """Locates engines and records which one is active."""
47
+
48
+ def __init__(self, paths: TechtreePaths, settings: Settings) -> None:
49
+ self._paths = paths
50
+ self._settings = settings
51
+
52
+ # -- what exists ------------------------------------------------------
53
+
54
+ def known(self) -> list[Digest]:
55
+ """Return the packaged digest and every installed digest."""
56
+ digests = {default_engine_digest(), *self.installed()}
57
+ return sorted(digests)
58
+
59
+ def installed(self) -> list[Digest]:
60
+ """Return the digests of complete installations."""
61
+ if not self._paths.engines_dir.is_dir():
62
+ return []
63
+
64
+ found: list[Digest] = []
65
+ for directory in sorted(self._paths.engines_dir.iterdir()):
66
+ if not directory.is_dir():
67
+ continue
68
+ digest = digest_from_directory_name(directory.name)
69
+ if digest is None:
70
+ continue
71
+ if self.installation(digest) is not None:
72
+ found.append(digest)
73
+ return found
74
+
75
+ def installation(self, digest: Digest) -> EngineInstallation | None:
76
+ """Return the recorded installation, or None when there is not one.
77
+
78
+ A record that does not name this directory is not this engine's record,
79
+ so it is treated as absent rather than trusted.
80
+ """
81
+ marker = self.path(digest) / INSTALLATION_FILENAME
82
+ try:
83
+ raw = marker.read_bytes()
84
+ except (FileNotFoundError, NotADirectoryError):
85
+ return None
86
+
87
+ try:
88
+ installation = EngineInstallation.model_validate_json(raw)
89
+ except ValueError:
90
+ return None
91
+
92
+ if installation.digest != digest:
93
+ return None
94
+ return installation
95
+
96
+ def record(self, installation: EngineInstallation) -> None:
97
+ """Write the installation marker that completes an install."""
98
+ atomic_write_json(
99
+ self.path(installation.digest) / INSTALLATION_FILENAME, installation
100
+ )
101
+
102
+ # -- where things are -------------------------------------------------
103
+
104
+ def path(self, digest: Digest) -> Path:
105
+ """Return the installation directory for one engine."""
106
+ return self._paths.engine_dir(validate_digest(digest))
107
+
108
+ def python(self, digest: Digest) -> Path:
109
+ """Return the engine's managed interpreter."""
110
+ return self.path(digest) / VENV_DIRECTORY / _BIN_DIRECTORY / "python"
111
+
112
+ def executable(self, digest: Digest, name: str) -> Path:
113
+ """Return one console script inside the engine environment.
114
+
115
+ The pinned Verifiers build installs its scripts under generic names —
116
+ ``validate``, ``eval`` — so they are always addressed by absolute path
117
+ inside the engine and never resolved through ``PATH``
118
+ (``docs/verifiers-pin.md``, finding C3).
119
+ """
120
+ return self.path(digest) / VENV_DIRECTORY / _BIN_DIRECTORY / _plain(name)
121
+
122
+ def tool_path(self, digest: Digest, tool: str) -> Path:
123
+ """Return one engine helper shipped inside the bundle.
124
+
125
+ Decisions document 0003 A3. Only the helpers the bundle declares can be
126
+ addressed, so a caller cannot turn this into "run any file you like
127
+ with the engine interpreter".
128
+ """
129
+ if tool not in ENGINE_TOOLS:
130
+ raise NotFoundError(
131
+ f"the engine bundle ships no tool named {tool!r}",
132
+ code="engine_tool_unknown",
133
+ details={"tool": tool, "available": list(ENGINE_TOOLS)},
134
+ )
135
+ return self.path(digest) / _TOOLS_DIRECTORY / tool
136
+
137
+ # -- status and activation --------------------------------------------
138
+
139
+ def status(self, digest: Digest) -> EngineStatus:
140
+ """Return what is currently true about one engine."""
141
+ installation = self.installation(digest)
142
+ active = self.active_digest() == digest
143
+
144
+ if installation is None:
145
+ return EngineStatus(
146
+ digest=digest,
147
+ installed=False,
148
+ active=False,
149
+ verified=False,
150
+ path=str(self.path(digest)),
151
+ python_executable=None,
152
+ detail=_absent_detail(digest, active=active),
153
+ )
154
+
155
+ return EngineStatus(
156
+ digest=digest,
157
+ installed=True,
158
+ active=active,
159
+ verified=installation.verified,
160
+ path=str(self.path(digest)),
161
+ python_executable=installation.python_executable,
162
+ detail=_present_detail(installation.verified, active=active),
163
+ )
164
+
165
+ def active_digest(self) -> Digest | None:
166
+ """Return the digest of the active engine, if one is recorded."""
167
+ return self._settings.active_engine_digest
168
+
169
+ def set_active(self, digest: Digest) -> Settings:
170
+ """Record one verified engine as active.
171
+
172
+ Activation is a claim the CLI makes to every later command, so it is
173
+ refused unless the engine is installed and its installation was
174
+ verified. Verification itself belongs to the installer, which runs the
175
+ engine; this reads the result it recorded.
176
+ """
177
+ installation = self.installation(validate_digest(digest))
178
+ if installation is None:
179
+ raise EngineError(
180
+ f"engine {digest} is not installed, so it cannot be made active",
181
+ code="engine_not_installed",
182
+ details={"digest": digest, "path": str(self.path(digest))},
183
+ )
184
+ if not installation.verified:
185
+ raise EngineError(
186
+ f"engine {digest} was never verified, so it cannot be made active",
187
+ code="engine_not_verified",
188
+ details={"digest": digest},
189
+ )
190
+
191
+ # Settings are re-read rather than written from this object's copy, so
192
+ # concurrently changed preferences are not reverted by an activation.
193
+ settings = load_settings(self._paths).model_copy(
194
+ update={"active_engine_digest": digest}
195
+ )
196
+ save_settings(self._paths, settings)
197
+ self._settings = settings
198
+ return settings
199
+
200
+
201
+ def _plain(name: str) -> str:
202
+ """Reject anything that is not a bare file name."""
203
+ if not name or "/" in name or name in {".", ".."}:
204
+ raise EngineError(
205
+ f"{name!r} is not an engine executable name",
206
+ code="engine_executable_invalid",
207
+ details={"name": name},
208
+ )
209
+ return name
210
+
211
+
212
+ def digest_from_directory_name(name: str) -> Digest | None:
213
+ """Return the digest a directory name encodes, or None if it encodes none."""
214
+ algorithm, separator, hexadecimal = name.partition("-")
215
+ if separator != "-" or algorithm != "sha256":
216
+ return None
217
+ candidate = f"{algorithm}:{hexadecimal}"
218
+ try:
219
+ return validate_digest(candidate)
220
+ except ValidationError:
221
+ return None
222
+
223
+
224
+ def _absent_detail(digest: Digest, *, active: bool) -> str:
225
+ if active:
226
+ return (
227
+ f"engine {digest} is recorded as active but is not installed; "
228
+ "install it again"
229
+ )
230
+ return "not installed"
231
+
232
+
233
+ def _present_detail(verified: bool, *, active: bool) -> str:
234
+ state = "installed and verified" if verified else "installed but not verified"
235
+ return f"{state}, {'active' if active else 'not the active engine'}"
@@ -0,0 +1,170 @@
1
+ """Running one command inside the managed engine. Spec section 20.5.
2
+
3
+ Two rules shape every invocation.
4
+
5
+ *Absolute paths, never ``PATH``.* The pinned Verifiers build installs console
6
+ scripts under names as generic as ``validate`` and ``eval``
7
+ (``docs/verifiers-pin.md``, finding C3). Resolving those through the caller's
8
+ ``PATH`` would let anything on the machine answer for the engine, so the engine
9
+ addresses its own executables by absolute path and the environment it runs them
10
+ in carries a ``PATH`` for the operating system's benefit, not for lookup.
11
+
12
+ *A minimal environment.* Model-free validation needs no provider credential,
13
+ and a subprocess that never receives one cannot leak one. The child gets
14
+ ``PATH``, ``HOME``, and ``TMPDIR``, plus whatever the caller passes explicitly.
15
+ Everything else in the parent environment — API keys, tokens, cloud
16
+ credentials, the caller's Python configuration — is dropped.
17
+
18
+ The result of a run is data, not a decision. ``validate`` exits ``0`` even when
19
+ every task fails (``docs/verifiers-pin.md``, finding C2), so nothing here maps
20
+ an exit code onto a verdict; callers read the summary the engine wrote.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import os
26
+ import subprocess
27
+ import time
28
+ from collections.abc import Mapping, Sequence
29
+ from dataclasses import dataclass
30
+ from pathlib import Path
31
+ from typing import Final
32
+
33
+ from techtree.engines.registry import EngineRegistry
34
+ from techtree.errors import EngineError
35
+ from techtree.models.base import Digest
36
+
37
+ __all__ = ["INHERITED_ENVIRONMENT", "EngineProcessResult", "EngineRunner"]
38
+
39
+ #: The only variables an engine process inherits from its parent. ``PATH`` so
40
+ #: the child can find ordinary system tools, ``HOME`` because caches and
41
+ #: configuration hang off it, ``TMPDIR`` so scratch files land where the host
42
+ #: expects them.
43
+ INHERITED_ENVIRONMENT: Final[tuple[str, ...]] = ("PATH", "HOME", "TMPDIR")
44
+
45
+ #: Prefix of the variables a caller may add. Anything else has to be passed
46
+ #: deliberately by name, which keeps "just forward the environment" from being
47
+ #: the easy option.
48
+ _TECHTREE_PREFIX: Final = "TECHTREE_"
49
+
50
+
51
+ @dataclass(frozen=True)
52
+ class EngineProcessResult:
53
+ """What one engine command did."""
54
+
55
+ argv: tuple[str, ...]
56
+ exit_code: int
57
+ stdout: str
58
+ stderr: str
59
+ duration_seconds: float
60
+
61
+
62
+ class EngineRunner:
63
+ """Runs commands inside one installed engine."""
64
+
65
+ def __init__(self, registry: EngineRegistry, digest: Digest) -> None:
66
+ self._registry = registry
67
+ self._digest = digest
68
+
69
+ def run(
70
+ self,
71
+ executable: str,
72
+ args: Sequence[str],
73
+ *,
74
+ timeout: float,
75
+ env: Mapping[str, str] | None = None,
76
+ cwd: Path | None = None,
77
+ ) -> EngineProcessResult:
78
+ """Run one console script from the engine environment."""
79
+ return self._execute(
80
+ [str(self._registry.executable(self._digest, executable)), *args],
81
+ timeout=timeout,
82
+ env=env,
83
+ cwd=cwd,
84
+ )
85
+
86
+ def run_python_script(
87
+ self,
88
+ script: Path,
89
+ args: Sequence[str],
90
+ *,
91
+ timeout: float,
92
+ env: Mapping[str, str] | None = None,
93
+ ) -> EngineProcessResult:
94
+ """Run one Python file with the engine's own interpreter."""
95
+ return self._execute(
96
+ [str(self._registry.python(self._digest)), str(script), *args],
97
+ timeout=timeout,
98
+ env=env,
99
+ )
100
+
101
+ def _execute(
102
+ self,
103
+ argv: Sequence[str],
104
+ *,
105
+ timeout: float,
106
+ env: Mapping[str, str] | None,
107
+ cwd: Path | None = None,
108
+ ) -> EngineProcessResult:
109
+ program = Path(argv[0])
110
+ if not program.is_file():
111
+ raise EngineError(
112
+ f"engine {self._digest} has no executable at {program}; "
113
+ "install the engine again",
114
+ code="engine_executable_missing",
115
+ details={"digest": self._digest, "path": str(program)},
116
+ )
117
+
118
+ started = time.monotonic()
119
+ try:
120
+ completed = subprocess.run(
121
+ list(argv),
122
+ capture_output=True,
123
+ text=True,
124
+ check=False,
125
+ timeout=timeout,
126
+ stdin=subprocess.DEVNULL,
127
+ env=engine_environment(env),
128
+ cwd=None if cwd is None else str(cwd),
129
+ )
130
+ except subprocess.TimeoutExpired as error:
131
+ raise EngineError(
132
+ f"engine command did not finish within {timeout:.0f}s: {program.name}",
133
+ code="engine_command_timeout",
134
+ details={"digest": self._digest, "timeout_seconds": timeout},
135
+ retryable=True,
136
+ ) from error
137
+ except OSError as error:
138
+ raise EngineError(
139
+ f"engine command could not be started: {error.strerror or error}",
140
+ code="engine_command_unusable",
141
+ details={"digest": self._digest, "path": str(program)},
142
+ ) from error
143
+
144
+ return EngineProcessResult(
145
+ argv=tuple(argv),
146
+ exit_code=completed.returncode,
147
+ stdout=completed.stdout,
148
+ stderr=completed.stderr,
149
+ duration_seconds=time.monotonic() - started,
150
+ )
151
+
152
+
153
+ def engine_environment(extra: Mapping[str, str] | None = None) -> dict[str, str]:
154
+ """Build the minimal environment an engine process runs in."""
155
+ environment = {
156
+ name: os.environ[name] for name in INHERITED_ENVIRONMENT if name in os.environ
157
+ }
158
+
159
+ for name, value in (extra or {}).items():
160
+ if not name.startswith(_TECHTREE_PREFIX) and name not in INHERITED_ENVIRONMENT:
161
+ raise EngineError(
162
+ f"{name} cannot be passed to an engine process; only "
163
+ f"{_TECHTREE_PREFIX}* variables and "
164
+ f"{', '.join(INHERITED_ENVIRONMENT)} are allowed",
165
+ code="engine_environment_rejected",
166
+ details={"variable": name},
167
+ )
168
+ environment[name] = value
169
+
170
+ return environment
techtree/errors.py ADDED
@@ -0,0 +1,262 @@
1
+ """Typed Techtree errors and the exit codes they map to. Spec section 10.5.
2
+
3
+ Every failure a user can provoke is one of the classes below. Each class fixes
4
+ a machine-stable ``code``, a documented process exit code, and whether retrying
5
+ the same command could plausibly succeed. Call sites override those defaults
6
+ only when they have something more specific to say.
7
+
8
+ Two rules shape this module:
9
+
10
+ * ``details`` is machine-facing and travels into JSON output, so it carries
11
+ identifiers, counts, and paths. Nothing filters it: decision 0036 removed
12
+ every secret-shaped-string detector from this project, so what a call site
13
+ puts in ``details`` is what a caller reads, including when the call site is
14
+ forwarding a subprocess's own words.
15
+ * Human-facing text is actionable. Tracebacks are debugging aids for stderr,
16
+ not part of the contract, which is why
17
+ :func:`stable_exception_message` exists for the unexpected-exception path.
18
+
19
+ An error can also carry the ``NextAction`` list the CLI should offer, so that
20
+ the code which knows *why* something failed is the code that says what to do
21
+ about it. The shapes those actions and the projected ``CliError`` are spelled
22
+ in belong to :mod:`techtree.models.cli`. That module is imported only for
23
+ typing, and inside :func:`error_to_cli_error`, because the model package
24
+ imports this one: errors sit underneath models, and speaking a model's
25
+ vocabulary must not invert that.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import re
31
+ from collections.abc import Mapping, Sequence
32
+ from typing import TYPE_CHECKING, ClassVar, Final
33
+
34
+ if TYPE_CHECKING:
35
+ from techtree.models.base import JsonValue
36
+ from techtree.models.cli import CliError, NextAction
37
+
38
+ __all__ = [
39
+ "AuthenticationError",
40
+ "CancellationError",
41
+ "ConflictError",
42
+ "EngineError",
43
+ "NotFoundError",
44
+ "PolicyError",
45
+ "PrerequisiteError",
46
+ "RunError",
47
+ "TechtreeError",
48
+ "UsageError",
49
+ "ValidationError",
50
+ "VerificationError",
51
+ "error_to_cli_error",
52
+ "exit_code_for",
53
+ "stable_exception_message",
54
+ ]
55
+
56
+
57
+ # ---------------------------------------------------------------------------
58
+ # Exit codes
59
+ #
60
+ # These are part of the CLI contract: a host agent branches on them without
61
+ # parsing output. Values are append-only; never reuse a retired number.
62
+ # ---------------------------------------------------------------------------
63
+
64
+ EXIT_OK: Final = 0
65
+ EXIT_ERROR: Final = 1
66
+ EXIT_USAGE: Final = 2
67
+ EXIT_VALIDATION: Final = 3
68
+ EXIT_PREREQUISITE: Final = 4
69
+ EXIT_NOT_FOUND: Final = 5
70
+ EXIT_CONFLICT: Final = 6
71
+ EXIT_AUTHENTICATION: Final = 7
72
+ EXIT_POLICY: Final = 8
73
+ EXIT_ENGINE: Final = 9
74
+ EXIT_RUN: Final = 10
75
+ EXIT_VERIFICATION: Final = 11
76
+ #: The shell convention for "terminated by SIGINT", which is what a cancelled
77
+ #: run is from the caller's point of view.
78
+ EXIT_CANCELLED: Final = 130
79
+
80
+
81
+ # ---------------------------------------------------------------------------
82
+ # Hierarchy
83
+ # ---------------------------------------------------------------------------
84
+
85
+
86
+ class TechtreeError(Exception):
87
+ """Base class for every failure Techtree reports on purpose."""
88
+
89
+ default_code: ClassVar[str] = "techtree_error"
90
+ default_exit_code: ClassVar[int] = EXIT_ERROR
91
+ default_retryable: ClassVar[bool] = False
92
+
93
+ def __init__(
94
+ self,
95
+ message: str,
96
+ *,
97
+ code: str | None = None,
98
+ exit_code: int | None = None,
99
+ retryable: bool | None = None,
100
+ details: Mapping[str, JsonValue] | None = None,
101
+ next_actions: Sequence[NextAction] | None = None,
102
+ ) -> None:
103
+ super().__init__(message)
104
+ self.message = message
105
+ self.code = self.default_code if code is None else code
106
+ self.exit_code = self.default_exit_code if exit_code is None else exit_code
107
+ self.retryable = self.default_retryable if retryable is None else retryable
108
+ self.details: dict[str, JsonValue] = dict(details or {})
109
+ #: What the caller could do about this, in the CLI's own vocabulary.
110
+ #: Raising code fills this in when it knows; the CLI never invents it.
111
+ self.next_actions: list[NextAction] = list(next_actions or ())
112
+
113
+ def __str__(self) -> str:
114
+ return self.message
115
+
116
+
117
+ class UsageError(TechtreeError):
118
+ """The command, its options, or their combination is not valid."""
119
+
120
+ default_code: ClassVar[str] = "usage_error"
121
+ default_exit_code: ClassVar[int] = EXIT_USAGE
122
+
123
+
124
+ class ValidationError(TechtreeError):
125
+ """Input data or a stored document failed protocol validation."""
126
+
127
+ default_code: ClassVar[str] = "validation_error"
128
+ default_exit_code: ClassVar[int] = EXIT_VALIDATION
129
+
130
+
131
+ class PrerequisiteError(TechtreeError):
132
+ """Something that had to be done first has not been done."""
133
+
134
+ default_code: ClassVar[str] = "prerequisite_error"
135
+ default_exit_code: ClassVar[int] = EXIT_PREREQUISITE
136
+
137
+
138
+ class NotFoundError(TechtreeError):
139
+ """A named object does not exist."""
140
+
141
+ default_code: ClassVar[str] = "not_found"
142
+ default_exit_code: ClassVar[int] = EXIT_NOT_FOUND
143
+
144
+
145
+ class ConflictError(TechtreeError):
146
+ """The requested change collides with existing immutable state."""
147
+
148
+ default_code: ClassVar[str] = "conflict"
149
+ default_exit_code: ClassVar[int] = EXIT_CONFLICT
150
+
151
+
152
+ class AuthenticationError(TechtreeError):
153
+ """A credential is missing, rejected, or expired."""
154
+
155
+ default_code: ClassVar[str] = "authentication_error"
156
+ default_exit_code: ClassVar[int] = EXIT_AUTHENTICATION
157
+
158
+
159
+ class EngineError(TechtreeError):
160
+ """The managed engine could not be installed, resolved, or invoked."""
161
+
162
+ default_code: ClassVar[str] = "engine_error"
163
+ default_exit_code: ClassVar[int] = EXIT_ENGINE
164
+
165
+
166
+ class RunError(TechtreeError):
167
+ """A run failed, or a run operation is illegal in the current phase."""
168
+
169
+ default_code: ClassVar[str] = "run_error"
170
+ default_exit_code: ClassVar[int] = EXIT_RUN
171
+
172
+
173
+ class VerificationError(TechtreeError):
174
+ """A digest, signature, or membership commitment did not verify."""
175
+
176
+ default_code: ClassVar[str] = "verification_error"
177
+ default_exit_code: ClassVar[int] = EXIT_VERIFICATION
178
+
179
+
180
+ class CancellationError(TechtreeError):
181
+ """Work stopped because it was cancelled."""
182
+
183
+ default_code: ClassVar[str] = "cancelled"
184
+ default_exit_code: ClassVar[int] = EXIT_CANCELLED
185
+
186
+
187
+ class PolicyError(TechtreeError):
188
+ """A data-rights or publication policy forbids the request.
189
+
190
+ Data-policy contradictions raise this and nothing else, so a caller can
191
+ distinguish "your inputs disagree about rights" from "your inputs are
192
+ malformed".
193
+ """
194
+
195
+ default_code: ClassVar[str] = "policy_error"
196
+ default_exit_code: ClassVar[int] = EXIT_POLICY
197
+
198
+
199
+ # ---------------------------------------------------------------------------
200
+ # Message stability
201
+ # ---------------------------------------------------------------------------
202
+
203
+ #: A memory address as CPython writes one into a repr. It changes on every
204
+ #: process, so an error quoting one would not compare equal to itself between
205
+ #: runs. Normalising it is about stability, not about secrecy: decision 0036
206
+ #: is explicit that this project detects no secret-shaped strings anywhere.
207
+ _MEMORY_ADDRESS = re.compile(r"\b0x[0-9a-fA-F]{6,}\b")
208
+ _WHITESPACE_RUN = re.compile(r"\s+")
209
+
210
+
211
+ def stable_exception_message(error: Exception) -> str:
212
+ """Return one exception's message as a single stable line.
213
+
214
+ Line structure is flattened so a message reads as one line wherever it is
215
+ shown, and memory addresses are normalised so the same failure produces
216
+ the same words twice. Nothing else is altered: whatever the exception
217
+ said, including anything a subprocess said through it, is what comes out.
218
+ """
219
+ text = _WHITESPACE_RUN.sub(" ", str(error)).strip()
220
+ if not text:
221
+ return type(error).__name__
222
+ return _MEMORY_ADDRESS.sub("0x<address>", text)
223
+
224
+
225
+ # ---------------------------------------------------------------------------
226
+ # CLI projection
227
+ # ---------------------------------------------------------------------------
228
+
229
+
230
+ def error_to_cli_error(error: TechtreeError) -> CliError:
231
+ """Convert an internal typed error to CLI error data.
232
+
233
+ The message is flattened by :func:`stable_exception_message`; ``details``
234
+ travels exactly as the raising call site built it.
235
+
236
+ ``next_actions`` is not part of ``CliError``; it belongs to the envelope,
237
+ and the CLI reads it from the error directly.
238
+ """
239
+ from techtree.models.cli import CliError
240
+
241
+ return CliError(
242
+ code=error.code,
243
+ message=stable_exception_message(error),
244
+ retryable=error.retryable,
245
+ details=dict(error.details),
246
+ )
247
+
248
+
249
+ # ---------------------------------------------------------------------------
250
+ # Exit codes
251
+ # ---------------------------------------------------------------------------
252
+
253
+
254
+ def exit_code_for(error: Exception) -> int:
255
+ """Return documented CLI exit code.
256
+
257
+ Anything that is not a typed Techtree error is an internal defect, and
258
+ every internal defect exits ``1``.
259
+ """
260
+ if isinstance(error, TechtreeError):
261
+ return error.exit_code
262
+ return EXIT_ERROR