froid-loop 0.11.1__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 (116) hide show
  1. froid_loop/__init__.py +11 -0
  2. froid_loop/__main__.py +12 -0
  3. froid_loop/adapters/__init__.py +3 -0
  4. froid_loop/adapters/base.py +254 -0
  5. froid_loop/adapters/entrypoints.py +63 -0
  6. froid_loop/adapters/env_fault.py +290 -0
  7. froid_loop/adapters/generic.py +2013 -0
  8. froid_loop/adapters/mock.py +49 -0
  9. froid_loop/adapters/multiplexer.py +914 -0
  10. froid_loop/adapters/opencode_http.py +1687 -0
  11. froid_loop/adapters/profile.py +650 -0
  12. froid_loop/adapters/psmux_backend.py +1428 -0
  13. froid_loop/adapters/registry.py +322 -0
  14. froid_loop/adapters/tmux_backend.py +35 -0
  15. froid_loop/adapters/tmux_base.py +630 -0
  16. froid_loop/checks.py +187 -0
  17. froid_loop/cli.py +5041 -0
  18. froid_loop/data/__init__.py +0 -0
  19. froid_loop/data/froid_loop_hook.py +228 -0
  20. froid_loop/data/froid_loop_probe_hook.py +88 -0
  21. froid_loop/data/plugins/example/plugin.toml +21 -0
  22. froid_loop/data/plugins/tea/plugin.toml +184 -0
  23. froid_loop/data/plugins/tea/tea_plugin.py +258 -0
  24. froid_loop/data/plugins/unity/plugin.toml +140 -0
  25. froid_loop/data/plugins/unity/unity_assets/FroidLoop.Unity.Editor.asmdef +16 -0
  26. froid_loop/data/plugins/unity/unity_assets/FroidLoop.Unity.Editor.asmdef.meta +7 -0
  27. froid_loop/data/plugins/unity/unity_assets/SceneAutoSaveGuard.cs +221 -0
  28. froid_loop/data/plugins/unity/unity_assets/SceneAutoSaveGuard.cs.meta +11 -0
  29. froid_loop/data/plugins/unity/unity_assets/_folders/Editor.meta +8 -0
  30. froid_loop/data/plugins/unity/unity_assets/_folders/FroidLoop.meta +8 -0
  31. froid_loop/data/plugins/unity/unity_cleanup.py +125 -0
  32. froid_loop/data/plugins/unity/unity_dialog_probe.py +239 -0
  33. froid_loop/data/plugins/unity/unity_facts.md +17 -0
  34. froid_loop/data/plugins/unity/unity_plugin.py +415 -0
  35. froid_loop/data/plugins/unity/unity_quiesce.py +234 -0
  36. froid_loop/data/plugins/unity/unity_ready.py +230 -0
  37. froid_loop/data/plugins/unity/unity_seed_assets.py +298 -0
  38. froid_loop/data/plugins/unity/unity_setup.py +551 -0
  39. froid_loop/data/plugins/unity/unity_teardown.py +362 -0
  40. froid_loop/data/profiles/antigravity.toml +52 -0
  41. froid_loop/data/profiles/claude.toml +85 -0
  42. froid_loop/data/profiles/codex.toml +22 -0
  43. froid_loop/data/profiles/copilot.toml +52 -0
  44. froid_loop/data/profiles/gemini.toml +26 -0
  45. froid_loop/data/profiles/opencode.toml +54 -0
  46. froid_loop/data/settings/core.toml +458 -0
  47. froid_loop/data/skills/README.md +93 -0
  48. froid_loop/data/skills/froid-loop-resolve/SKILL.md +288 -0
  49. froid_loop/data/skills/froid-loop-setup/SKILL.md +161 -0
  50. froid_loop/data/skills/froid-loop-setup/assets/module-help.csv +3 -0
  51. froid_loop/data/skills/froid-loop-setup/assets/module.yaml +19 -0
  52. froid_loop/data/skills/froid-loop-sweep/SKILL.md +100 -0
  53. froid_loop/data/skills/froid-loop-sweep/automation-mode.md +127 -0
  54. froid_loop/data/skills/froid-loop-sweep/deferred-work-format.md +302 -0
  55. froid_loop/data/skills/froid-loop-sweep/migration-mode.md +86 -0
  56. froid_loop/decisions.py +202 -0
  57. froid_loop/deferredwork.py +2282 -0
  58. froid_loop/devcontract.py +892 -0
  59. froid_loop/diagnostics.py +1104 -0
  60. froid_loop/documents.py +532 -0
  61. froid_loop/engine.py +7732 -0
  62. froid_loop/envvars.py +111 -0
  63. froid_loop/escalation.py +225 -0
  64. froid_loop/events.py +266 -0
  65. froid_loop/fences.py +103 -0
  66. froid_loop/froidconfig.py +226 -0
  67. froid_loop/frontmatter.py +526 -0
  68. froid_loop/gates.py +133 -0
  69. froid_loop/install.py +2936 -0
  70. froid_loop/journal.py +178 -0
  71. froid_loop/machine.py +148 -0
  72. froid_loop/model.py +898 -0
  73. froid_loop/operatoractions.py +474 -0
  74. froid_loop/platform_util.py +1490 -0
  75. froid_loop/plugins/__init__.py +64 -0
  76. froid_loop/plugins/bus.py +259 -0
  77. froid_loop/plugins/context.py +319 -0
  78. froid_loop/plugins/loader.py +145 -0
  79. froid_loop/plugins/manifest.py +279 -0
  80. froid_loop/plugins/model.py +296 -0
  81. froid_loop/plugins/registry.py +245 -0
  82. froid_loop/plugins/trust.py +75 -0
  83. froid_loop/policy.py +1569 -0
  84. froid_loop/probe.py +1044 -0
  85. froid_loop/process_host.py +408 -0
  86. froid_loop/recovery_flow.py +1561 -0
  87. froid_loop/resolve.py +283 -0
  88. froid_loop/runs.py +4715 -0
  89. froid_loop/runsetup.py +1293 -0
  90. froid_loop/sanitize.py +593 -0
  91. froid_loop/settings_schema.py +276 -0
  92. froid_loop/signals.py +160 -0
  93. froid_loop/sprintstatus.py +609 -0
  94. froid_loop/statemachine.py +57 -0
  95. froid_loop/stories.py +615 -0
  96. froid_loop/stories_engine.py +796 -0
  97. froid_loop/sweep.py +1892 -0
  98. froid_loop/tokens.py +196 -0
  99. froid_loop/tui/__init__.py +11 -0
  100. froid_loop/tui/app.py +1584 -0
  101. froid_loop/tui/data.py +840 -0
  102. froid_loop/tui/launch.py +1003 -0
  103. froid_loop/tui/screens/__init__.py +1 -0
  104. froid_loop/tui/screens/dashboard.py +1071 -0
  105. froid_loop/tui/screens/modals.py +943 -0
  106. froid_loop/tui/screens/settings_screen.py +477 -0
  107. froid_loop/tui/settings.py +135 -0
  108. froid_loop/tui/widgets.py +981 -0
  109. froid_loop/verify.py +4545 -0
  110. froid_loop/workspace.py +320 -0
  111. froid_loop/worktree_flow.py +2301 -0
  112. froid_loop-0.11.1.dist-info/METADATA +728 -0
  113. froid_loop-0.11.1.dist-info/RECORD +116 -0
  114. froid_loop-0.11.1.dist-info/WHEEL +4 -0
  115. froid_loop-0.11.1.dist-info/entry_points.txt +2 -0
  116. froid_loop-0.11.1.dist-info/licenses/LICENSE +30 -0
@@ -0,0 +1,650 @@
1
+ """Declarative CLI profiles for the generic tmux adapter.
2
+
3
+ A profile captures everything that differs between coding CLIs that share the
4
+ tmux-injection + hook-signal transport: binary name, how the canonical
5
+ "/skill args" prompt is rendered, bypass flags, hook registration (a config
6
+ dialect + an event-name map), and which usage parser reads the transcript.
7
+
8
+ Built-in profiles ship as packaged TOML (froid_loop/data/profiles/*.toml) and
9
+ project-local TOML files in <project>/.froid-loop/profiles/*.toml overlay them
10
+ (same name overrides, new names extend) — adding a CLI that clones an
11
+ existing hook dialect needs no Python.
12
+
13
+ An out-of-tree package advertises additional profiles under the
14
+ ``froid_loop.profiles`` entry-point group (:func:`load_profiles` scans it): the
15
+ companion to the ``froid_loop.adapters`` registry (:mod:`~.registry`), so a
16
+ co-installed adapter package ships both its class and the profile that selects
17
+ it with zero project config. Precedence is packaged < entry-point < project (a
18
+ project TOML always wins). A broken entry point degrades to a recorded reason
19
+ (:func:`external_profile_errors`), never a crash — one per failing distribution,
20
+ kept even when two of them advertise the same name (:mod:`~.entrypoints`).
21
+
22
+ Which adapter *class* drives a profile is the ``adapter`` field, resolved against
23
+ the :mod:`~.registry` — it is read here but intentionally **not** checked against
24
+ the set of registered kinds at parse time (an unknown kind is caught at
25
+ construction and by ``validate``, against the live registry, never a hardcoded
26
+ set). Its *shape* is still enforced here, like every other field.
27
+
28
+ Both routes into the profile map — the TOML parser and the entry-point scan —
29
+ converge on :func:`_validate_profile`, so a Python package cannot install a
30
+ profile state a TOML author would have been refused.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import importlib.metadata
36
+ import tomllib
37
+ from dataclasses import dataclass, field, replace
38
+ from importlib import resources
39
+ from importlib.resources.abc import Traversable
40
+ from pathlib import Path
41
+
42
+ import regex
43
+
44
+ from ..platform_util import (
45
+ has_parent_ref,
46
+ is_absolute_path,
47
+ names_tree_root,
48
+ names_win32_alias,
49
+ )
50
+ from .entrypoints import record_load_error
51
+
52
+ USAGE_PARSERS = {"claude-jsonl", "codex-rollout", "gemini-chat", "copilot-events", "none"}
53
+ HOOK_DIALECTS = {
54
+ "claude-settings-json",
55
+ "codex-hooks-json",
56
+ "gemini-settings-json",
57
+ "copilot-settings-json",
58
+ "antigravity-hooks-json",
59
+ # hookless: the adapter observes completion itself (HTTP/SSE transport) —
60
+ # no hook config is ever written, so config_path/events must stay empty.
61
+ "none",
62
+ }
63
+ CANONICAL_EVENTS = {"SessionStart", "Stop", "SessionEnd", "PreCompact"}
64
+ USER_PROFILES_REL = Path(".froid-loop") / "profiles"
65
+
66
+ # legacy adapter names from older policy.toml files, plus friendly short names
67
+ ALIASES = {"claude-code-tmux": "claude", "opencode": "opencode-http"}
68
+
69
+
70
+ class ProfileError(Exception):
71
+ pass
72
+
73
+
74
+ # Every fault a raw coercion over a `tomllib` value can raise — the set is CLOSED,
75
+ # not a list of the ones seen so far, which is what makes funnelling on it a funnel
76
+ # rather than another round of enumeration. `tomllib` yields exactly nine types
77
+ # (str, int, float, bool, datetime, date, time, list, dict) and all nine were run
78
+ # through this module's coercions: `int()`/`float()` answer TypeError for the five
79
+ # non-numerics, ValueError for a non-numeric str and for `nan`, and **OverflowError**
80
+ # for `inf`/`-inf` (int) and for an int too large to be a float — `tomllib` accepts
81
+ # arbitrary-precision integers, so that second one is reachable from a config file.
82
+ # Iteration and `.items()` add TypeError and AttributeError and nothing new.
83
+ # `plugins/manifest.py` funnels on the same set for the same reason.
84
+ CONVERSION_FAULTS = (AttributeError, OverflowError, TypeError, ValueError)
85
+
86
+
87
+ @dataclass(frozen=True)
88
+ class HookSpec:
89
+ dialect: str
90
+ config_path: str # project-relative, e.g. ".claude/settings.json"
91
+ events: dict[str, str] # native event name -> canonical event name
92
+
93
+
94
+ @dataclass(frozen=True)
95
+ class CLIProfile:
96
+ name: str
97
+ binary: str
98
+ hooks: HookSpec
99
+ # Which adapter *class* drives this CLI — a key resolved against the adapter
100
+ # registry (adapters/registry.py), not a hardcoded enum. "generic" = the
101
+ # bundled tmux-injection + hook-signal adapter; "opencode-http" = the bundled
102
+ # HTTP/SSE adapter; an out-of-tree package registers its own. Membership is NOT
103
+ # checked at parse time: an unknown kind fails loud at construction and as a
104
+ # `validate` finding, both against the live registry. A hookless HTTP profile
105
+ # (hooks.dialect = "none") MUST set this to its HTTP adapter kind — the
106
+ # transport (hookless) and the driving class are now decoupled axes.
107
+ #
108
+ # That MUST binds the two routes differently, which is why the default below
109
+ # is not the whole story. A TOML profile that OMITS the key is read as
110
+ # predating the field and keeps the old dialect-based dispatch
111
+ # (`_legacy_adapter_default`), so a hookless file still lands on the HTTP
112
+ # kind. A provider that constructs this dataclass directly takes the default
113
+ # verbatim — nothing infers a kind for it — so an entry-point profile really
114
+ # must set the field, and a hookless one that leaves it at "generic" resolves
115
+ # to a mux-driving kind that will never see a Stop hook.
116
+ adapter: str = "generic"
117
+ # project-relative tree this CLI reads skills from, e.g. ".claude/skills"
118
+ # (claude) or ".agents/skills" (codex/gemini); `froid-loop init` installs the
119
+ # bundled froid-loop-* skills here.
120
+ skill_tree: str = ".claude/skills"
121
+ prompt_template: str = "{prompt}"
122
+ launch_args: tuple[str, ...] = ()
123
+ bypass_args: tuple[str, ...] = ()
124
+ model_flag: str = "--model"
125
+ env: dict[str, str] = field(default_factory=dict)
126
+ usage_parser: str = "none"
127
+ # seconds to keep polling the transcript for token usage after the session
128
+ # ends. 0 = read once (the totals are already there). CLIs that flush their
129
+ # token totals only on shutdown (Copilot writes modelMetrics in the trailing
130
+ # session.shutdown line, ~1s after the turn-end hook) need a small grace so
131
+ # read_usage doesn't sample the transcript before the totals land.
132
+ usage_grace_s: float = 0.0
133
+ # per-adapter floor for Stop-without-result nudges; None = use the global
134
+ # limits.stop_without_result_nudges. CLIs that fire a turn-end hook PER
135
+ # response turn (Copilot's agentStop) end a parallel-subagent phase across
136
+ # several turns, so the global default of 1 declares them stalled too early.
137
+ stop_without_result_nudges: int | None = None
138
+ # Some CLIs (Copilot) fire the turn-end hook for EVERY subagent turn too, with
139
+ # an empty transcriptPath and a tool-use session id (toolu_…) — not the main
140
+ # session's turn-end. When true, a Stop carrying no transcript_path is treated
141
+ # as a subagent stop and ignored, so the main session's real turn-end drives
142
+ # completion (and supplies the transcript for usage tallying). Without this a
143
+ # subagent's premature Stop reads as a result-less completion -> false stall.
144
+ subagent_stop_without_transcript: bool = False
145
+ first_run_note: str = ""
146
+ # project-relative gitignored configs (MCP/CLI settings) this CLI needs but
147
+ # that a `git worktree add` checkout omits; provision_worktree copies them in
148
+ # from the main repo so isolated dev/review sessions can reach the MCP server.
149
+ seed_files: tuple[str, ...] = ()
150
+ # Patterns matched line-by-line against the ANSI-stripped tail of a
151
+ # non-completed session's log to classify a provider/transport *environment
152
+ # fault* (#194) — an "API Error … Connection refused" or a "usage limit
153
+ # reached" the CLI printed while idling out the session clock. Which log is
154
+ # scanned is per-adapter, named by EnvFaultMixin.ENV_FAULT_LOG_SUFFIX: the
155
+ # tmux adapters' pane capture logs/<task_id>.log, or logs/<task_id>.server.out
156
+ # for opencode-http (whose .log is a model-written transcript). Compiled and
157
+ # validated at parse time (an invalid regex is a profile error); empty = inert.
158
+ #
159
+ # A pattern is only sound against a log the model cannot write to. A pane
160
+ # capture is not one: it carries the model's own output, so a bare `quota`
161
+ # or `429` matches a story that merely *implements* rate limiting, and an
162
+ # error-shaped token joined to a cause on the SAME line is precisely what a
163
+ # story writing ABOUT the error emits — the doctrine that used to live here,
164
+ # withdrawn because it classified 44 of this repo's own tracked lines (#507).
165
+ # So a pane-capture profile's pattern must reproduce one COMPLETE error
166
+ # sentence its CLI was captured printing, and must not match its own line in
167
+ # the profile file — hence the shipped single-character classes (`t[o]`,
168
+ # `respons[e]`), which change what the source line matches and nothing else.
169
+ # Both halves are pinned in tests/test_env_fault_patterns.py, the second
170
+ # across every tracked file. Override/extend via a project profile in
171
+ # .froid-loop/profiles/.
172
+ env_fault_patterns: tuple[str, ...] = ()
173
+ # Did this profile ship INSIDE the package (froid_loop/data/profiles/*.toml)?
174
+ # Provenance, not configuration: it is stamped by `load_profiles` at the one
175
+ # place that knows which directory a file came from, and no TOML key sets it —
176
+ # a project overlay declaring `packaged = true` is read as an unknown key, not
177
+ # as a promotion. Default False, so the untrusted answer is the one you get by
178
+ # forgetting: an entry-point profile constructing this dataclass directly is
179
+ # not packaged either, because "installed alongside us" is not the same claim
180
+ # as "shipped by us".
181
+ #
182
+ # The one consumer is `validate`'s liveness probe (#294), which EXECUTES the
183
+ # binary. `binary` is project-controlled end to end — policy.toml picks the
184
+ # profile and .froid-loop/profiles/*.toml supplies its fields, both arriving
185
+ # with a clone — so the probe needs a trust boundary that no spelling of
186
+ # `binary` can talk its way through: gating on the string (rejecting "./tool")
187
+ # still runs a bare "pwn" whenever a checkout-local directory is on PATH.
188
+ # Provenance is that boundary; it answers "who wrote this", which is the
189
+ # question actually being asked.
190
+ packaged: bool = False
191
+
192
+ @property
193
+ def hookless(self) -> bool:
194
+ """True for profiles whose adapter observes completion itself (HTTP/SSE)
195
+ instead of via hook scripts — no hook config exists to register, merge,
196
+ validate, or git-exclude."""
197
+ return self.hooks.dialect == "none"
198
+
199
+ def render_prompt(self, prompt: str) -> str:
200
+ """Render the engine's canonical "/skill args" prompt for this CLI.
201
+
202
+ Placeholders: {prompt} = the canonical string, {skill} = the leading
203
+ slash-command name without "/", {args} = everything after it.
204
+ """
205
+ skill, args = "", prompt
206
+ if prompt.startswith("/"):
207
+ head, _, rest = prompt[1:].partition(" ")
208
+ skill, args = head, rest.strip()
209
+ return self.prompt_template.format(prompt=prompt, skill=skill, args=args)
210
+
211
+
212
+ def _validate_profile(profile: CLIProfile, source: str) -> None:
213
+ """Enforce every value-level invariant a ``CLIProfile`` must satisfy, whatever
214
+ built it.
215
+
216
+ Two routes reach the profile map and only one has a parser in front of it:
217
+ :func:`_parse_profile` coerces a TOML document, while a ``froid_loop.profiles``
218
+ entry point hands over an already-constructed instance. Holding the invariants
219
+ in one function is what stops those routes drifting — the failure it closes is
220
+ a Python package installing a state the TOML parser would have refused, and
221
+ the sharpest instance is an ``env_fault_patterns`` entry that is not a valid
222
+ regex: unchecked, it trades a compile error at LOAD time for one at MATCH
223
+ time, inside a session's env-fault classification, where the caller degrades
224
+ rather than raises and the pattern silently never fires.
225
+
226
+ Scope is semantic, not type-level. A package that constructs a ``CLIProfile``
227
+ with the wrong runtime type in a field (a list where a ``str`` belongs) is a
228
+ bug this deliberately does not chase: reaching here already ran that package's
229
+ code in-process, so this is not a security boundary and hardening it as one
230
+ would invite it being trusted as one. What it does catch is the well-typed and
231
+ wrong profile — exactly what a TOML author is told about at parse time."""
232
+
233
+ def fail(msg: str) -> ProfileError:
234
+ return ProfileError(f"profile {source}: {msg}")
235
+
236
+ if not profile.name.strip() or not profile.binary.strip():
237
+ raise fail("'name' and 'binary' are required")
238
+
239
+ hooks = profile.hooks
240
+ if hooks.dialect not in HOOK_DIALECTS:
241
+ raise fail(f"hooks.dialect must be one of {sorted(HOOK_DIALECTS)}: got {hooks.dialect!r}")
242
+ if hooks.dialect == "none":
243
+ # hookless: nothing is ever registered, so a config_path or events map
244
+ # is a contradiction — reject rather than silently ignore.
245
+ if hooks.config_path or hooks.events:
246
+ raise fail('hookless profiles (dialect = "none") must not set hooks.config_path/events')
247
+ else:
248
+ if (
249
+ names_tree_root(hooks.config_path)
250
+ or is_absolute_path(hooks.config_path)
251
+ or has_parent_ref(hooks.config_path)
252
+ ):
253
+ # `names_tree_root("")` is True, so this arm also carries the
254
+ # "a real dialect must name a config_path at all" case.
255
+ raise fail("hooks.config_path must be a project-relative path")
256
+ if names_win32_alias(hooks.config_path):
257
+ raise fail(
258
+ "hooks.config_path must not name a Windows device or end a component "
259
+ "in a period or space"
260
+ )
261
+ if not hooks.events:
262
+ raise fail("hooks.events must map native event names to canonical ones")
263
+ bad = sorted(set(hooks.events.values()) - CANONICAL_EVENTS)
264
+ if bad:
265
+ raise fail(
266
+ f"hooks.events values must be canonical {sorted(CANONICAL_EVENTS)}: got {bad}"
267
+ )
268
+
269
+ # Shape only — membership against the registered kinds is deliberately NOT
270
+ # checked here (see the module docstring): that set is open-ended and lives in
271
+ # adapters/registry.py, which importing from here would make a cycle.
272
+ # `.strip()` because the TOML route strips before it gets here: testing the raw
273
+ # value would refuse `adapter = " "` from a file while admitting it from an
274
+ # entry-point provider, which is exactly the two-routes divergence this
275
+ # function exists to prevent.
276
+ if not profile.adapter.strip():
277
+ raise fail("adapter must be a non-empty string naming an adapter kind")
278
+
279
+ # The emptiness tests above use `.strip()` because the TOML route CANONICALIZES
280
+ # before it gets here — `_parse_profile` strips exactly these three fields — so
281
+ # testing the raw value would refuse `adapter = " "` from a file while
282
+ # admitting it from a provider. But validating a stripped COPY while the frozen
283
+ # original is what gets installed leaves the other half of that same divergence
284
+ # open: `" acme "` is content `_parse_profile` cannot produce, and every
285
+ # consumer keys on the exact string. The profile lands under a map key `--cli
286
+ # acme` never finds, a `binary` `shutil.which` never resolves, and an `adapter`
287
+ # `get_adapter_kind` reports as an unknown kind — while the provider that
288
+ # shipped it is recorded as perfectly fine.
289
+ #
290
+ # Refused, not normalized: this function validates and does not rewrite, and a
291
+ # frozen dataclass rebuilt here would leave the caller holding the original
292
+ # anyway. Refusing is also the louder half — the provider is dropped WITH a
293
+ # reason naming the field, which is what the recorded-degrade contract owes an
294
+ # operator. Ordered after the emptiness tests so `" "` still reads as empty
295
+ # rather than as non-canonical.
296
+ for label, value in (
297
+ ("name", profile.name),
298
+ ("binary", profile.binary),
299
+ ("adapter", profile.adapter),
300
+ ):
301
+ if value != value.strip():
302
+ raise fail(
303
+ f"{label} must not carry leading/trailing whitespace: {value!r} "
304
+ "(the TOML route strips it; a provider must hand over the "
305
+ "canonical value so both routes install the same profile)"
306
+ )
307
+
308
+ # The one coherence rule between the two axes, and the only place both routes
309
+ # pass through. `generic` is the bundled tmux adapter: it injects into a window
310
+ # and completes on a Stop hook (or the window dying), so pairing it with
311
+ # dialect = "none" — which means nothing ever registers that hook — describes a
312
+ # session that can only wait out `session_timeout_min` against an interactive
313
+ # CLI that never exits. Both routes could reach it: a TOML file naming the pair
314
+ # outright, and an entry-point provider that builds a hookless `HookSpec` while
315
+ # leaving `adapter` at its dataclass default. (The absent-key TOML case cannot:
316
+ # `_legacy_adapter_default` sends a hookless file to the HTTP kind.)
317
+ #
318
+ # This is the membership check's opposite, not an instance of it: naming ONE
319
+ # bundled kind is a fact about that adapter's completion contract, which this
320
+ # package owns, and the same latitude `validate`'s httpx check takes. Hookless
321
+ # on any OTHER kind stays legal — that decoupling is what the registry is for,
322
+ # and an out-of-tree kind's completion contract is its own to state.
323
+ from .registry import GENERIC
324
+
325
+ if profile.hookless and profile.adapter.strip() == GENERIC:
326
+ raise fail(
327
+ f'hookless profiles (dialect = "none") cannot select the {GENERIC!r} adapter: '
328
+ "it completes on a Stop hook a hookless profile never registers, so the "
329
+ "session would wait out session_timeout_min. Name the adapter kind that "
330
+ "drives this CLI over its own transport."
331
+ )
332
+
333
+ if profile.usage_parser not in USAGE_PARSERS:
334
+ raise fail(
335
+ f"usage_parser must be one of {sorted(USAGE_PARSERS)}: got {profile.usage_parser!r}"
336
+ )
337
+
338
+ if profile.usage_grace_s < 0:
339
+ raise fail(f"usage_grace_s must be >= 0: got {profile.usage_grace_s}")
340
+
341
+ nudges = profile.stop_without_result_nudges
342
+ if nudges is not None and nudges < 0:
343
+ raise fail(f"stop_without_result_nudges must be >= 0: got {nudges}")
344
+
345
+ if (
346
+ names_tree_root(profile.skill_tree)
347
+ or is_absolute_path(profile.skill_tree)
348
+ or has_parent_ref(profile.skill_tree)
349
+ ):
350
+ raise fail("skill_tree must be a project-relative path")
351
+
352
+ if names_win32_alias(profile.skill_tree):
353
+ raise fail(
354
+ "skill_tree must not name a Windows device or end a component in a period or space"
355
+ )
356
+
357
+ # `names_tree_root` subsumes the emptiness check it replaced. These entries feed
358
+ # provision_worktree's seed loop, where any spelling of the root ("", ".", "./",
359
+ # ".\") resolves src to the repo root and dst to the worktree — both pass the
360
+ # loop's containment checks, so the whole repo is copied in.
361
+ for seed in profile.seed_files:
362
+ if names_tree_root(seed) or is_absolute_path(seed) or has_parent_ref(seed):
363
+ raise fail(f"seed_files entries must be project-relative paths: got {seed!r}")
364
+ if names_win32_alias(seed):
365
+ raise fail(
366
+ "seed_files entries must not name a Windows device or end a component "
367
+ f"in a period or space: got {seed!r}"
368
+ )
369
+
370
+ for pattern in profile.env_fault_patterns:
371
+ try:
372
+ regex.compile(pattern) # same engine the adapter matches with (timeout-guarded)
373
+ except regex.error as e:
374
+ raise fail(f"env_fault_patterns entry is not a valid regex: {pattern!r} ({e})") from e
375
+
376
+
377
+ def _legacy_adapter_default(dialect: str) -> str:
378
+ """The adapter kind a TOML profile that predates the ``adapter`` field meant.
379
+
380
+ Before the registry, ``hooks.dialect`` WAS the class selector: ``make_adapters``
381
+ sent every hookless profile to the opencode HTTP adapters and everything else to
382
+ the generic ones. Project overlays are a documented customization point
383
+ (``<project>/.froid-loop/profiles/*.toml``, same name overrides), and the way to
384
+ tweak the opencode profile's ``binary``/``env``/``model`` was to copy the
385
+ packaged one — which carried no ``adapter`` key, because the key did not exist.
386
+ Taking the dataclass default for those files would silently move a working
387
+ hookless run onto tmux, where it launches the CLI in a window and waits out
388
+ ``session_timeout_min`` for a ``Stop`` hook a hookless profile never registers —
389
+ and ``validate`` stays green, because every check it would trip keys on
390
+ ``hookless`` too. So reproduce the old dispatch instead of defaulting.
391
+
392
+ Only the absent key takes this path; an explicit ``adapter`` is always honored,
393
+ including the now-legal hookless-but-not-``opencode-http`` combination the axes
394
+ were decoupled to allow. Naming the two bundled kinds here is a fact about what
395
+ the *old* dispatch did, not a valid-kinds set — that set is only ever
396
+ ``registry.known_adapter_kinds()``. Imported inside the function so this module
397
+ keeps no import-time dependency on the registry."""
398
+ from .registry import GENERIC, OPENCODE_HTTP
399
+
400
+ return OPENCODE_HTTP if dialect == "none" else GENERIC
401
+
402
+
403
+ def _parse_profile(doc: dict, source: str) -> CLIProfile:
404
+ """Coerce a TOML document into a :class:`CLIProfile`.
405
+
406
+ SHAPE only: the container and element types a TOML document can get wrong and
407
+ a constructed dataclass cannot. Every value-level invariant lives in
408
+ :func:`_validate_profile`, called on the result — so the entry-point route,
409
+ which has no document to coerce, enforces exactly the same set."""
410
+
411
+ def fail(msg: str) -> ProfileError:
412
+ return ProfileError(f"profile {source}: {msg}")
413
+
414
+ def str_list(key: str) -> tuple[str, ...]:
415
+ # TOML arrays parse as list; reject a bare string (which would iterate to
416
+ # per-character entries) or a scalar (a raw TypeError) with a friendly error.
417
+ raw = doc.get(key, [])
418
+ if not isinstance(raw, list) or not all(isinstance(x, str) for x in raw):
419
+ raise fail(f"{key} must be a list of strings")
420
+ return tuple(raw)
421
+
422
+ hooks_d = doc.get("hooks")
423
+ if not isinstance(hooks_d, dict):
424
+ raise fail("missing [hooks] table")
425
+ events_d = hooks_d.get("events", {})
426
+ if not isinstance(events_d, dict):
427
+ raise fail("hooks.events must map native event names to canonical ones")
428
+
429
+ # A dedicated shape check rather than the `str()` coercion the neighbouring
430
+ # scalars get, because `adapter` has no parse-time membership test to land in
431
+ # afterwards: `str(["x"])` would coerce a TOML array to the literal `"['x']"`
432
+ # and carry it all the way to `get_adapter_kind`, which would then name that
433
+ # nonsense as the unknown kind. #384's rule — a malformed value funnels into
434
+ # ProfileError at the boundary, never a silent coercion.
435
+ raw_adapter = doc.get("adapter")
436
+ if raw_adapter is None:
437
+ raw_adapter = _legacy_adapter_default(str(hooks_d.get("dialect", "")))
438
+ if not isinstance(raw_adapter, str):
439
+ raise fail(f"adapter must be a string: got {type(raw_adapter).__name__}")
440
+
441
+ profile = CLIProfile(
442
+ name=str(doc.get("name", "")).strip(),
443
+ binary=str(doc.get("binary", "")).strip(),
444
+ hooks=HookSpec(
445
+ dialect=str(hooks_d.get("dialect", "")),
446
+ config_path=str(hooks_d.get("config_path", "")),
447
+ events={str(k): str(v) for k, v in events_d.items()},
448
+ ),
449
+ adapter=raw_adapter.strip(),
450
+ skill_tree=str(doc.get("skill_tree", ".claude/skills")),
451
+ prompt_template=str(doc.get("prompt_template", "{prompt}")),
452
+ launch_args=str_list("launch_args"),
453
+ bypass_args=str_list("bypass_args"),
454
+ model_flag=str(doc.get("model_flag", "--model")),
455
+ env={str(k): str(v) for k, v in doc.get("env", {}).items()},
456
+ usage_parser=str(doc.get("usage_parser", "none")),
457
+ # `float()`/`int()` are the raw coercions `_load_toml`'s CONVERSION_FAULTS
458
+ # funnel exists for: they answer OverflowError for `inf` and for an integer
459
+ # too large to be a float, both of which are legal TOML.
460
+ usage_grace_s=float(doc.get("usage_grace_s", 0.0)),
461
+ stop_without_result_nudges=(
462
+ None if (raw := doc.get("stop_without_result_nudges")) is None else int(raw)
463
+ ),
464
+ subagent_stop_without_transcript=bool(doc.get("subagent_stop_without_transcript", False)),
465
+ first_run_note=str(doc.get("first_run_note", "")),
466
+ seed_files=str_list("seed_files"),
467
+ env_fault_patterns=str_list("env_fault_patterns"),
468
+ )
469
+ _validate_profile(profile, source)
470
+ return profile
471
+
472
+
473
+ def _read_profile_text(entry: Traversable | Path, source: str) -> str:
474
+ """Read a profile TOML as text, converting a read fault into ProfileError.
475
+
476
+ Not part of `_load_toml`'s CONVERSION_FAULTS funnel and not reachable by
477
+ widening it: that funnel wraps `_parse_profile`, and the decode happens in
478
+ the *argument expression* at both of `load_profiles`' call sites, before
479
+ `_load_toml` is entered. So a non-UTF-8 overlay escaped as a raw
480
+ `UnicodeDecodeError` (a ValueError) while every consumer keys its fault
481
+ handling on ProfileError — `validate`'s role loop crashed before printing
482
+ its `--json` document instead of reporting an `adapter.profile` failure
483
+ naming the file, and `get_profile` backs every adapter resolution, so the
484
+ same escape reached run/sweep preflight (#473). Both-arms precedent:
485
+ `stories.py`'s manifest read; the decode-only twin is `policy.load`.
486
+
487
+ Takes the packaged built-ins too. They are trusted, but a corrupt package
488
+ is a packaging bug and should say so with a typed error rather than a
489
+ traceback. One helper covers both call shapes: an `importlib.resources`
490
+ Traversable and a `Path` each expose ``read_text(encoding=...)``.
491
+ """
492
+ try:
493
+ return entry.read_text(encoding="utf-8")
494
+ except UnicodeDecodeError as e:
495
+ raise ProfileError(f"profile {source}: not valid UTF-8: {e}") from e
496
+ except OSError as e:
497
+ # A profile that is present but cannot be read — permissions, an I/O
498
+ # error, a dead mount. The overlay's `is_dir()` + glob rule out ABSENCE
499
+ # and nothing else, so this escaped as a bare OSError.
500
+ raise ProfileError(f"profile {source}: unreadable: {e}") from e
501
+
502
+
503
+ def _load_toml(text: str, source: str) -> CLIProfile:
504
+ try:
505
+ doc = tomllib.loads(text)
506
+ except tomllib.TOMLDecodeError as e:
507
+ raise ProfileError(f"profile {source}: invalid TOML: {e}") from e
508
+ try:
509
+ return _parse_profile(doc, source)
510
+ except ProfileError:
511
+ raise # intent: a domain error is never re-wrapped (it is not a CONVERSION_FAULT)
512
+ except CONVERSION_FAULTS as e:
513
+ # A funnel, not per-field guards: `_parse_profile`'s raw conversions
514
+ # (`float()`, `int()`, `.items()`) raise bare conversion errors on
515
+ # TOML-legal values of the wrong type, and every consumer keys its
516
+ # fault handling on ProfileError — `validate`'s role loop reports an
517
+ # `adapter.profile` failure, `_require_base_skills` skips the one
518
+ # profile, `install` prints FAIL. A bare escape crashed `validate`
519
+ # before any document was printed.
520
+ raise ProfileError(f"profile {source}: malformed field value: {e}") from e
521
+
522
+
523
+ # The entry-point group an out-of-tree package advertises extra profiles under —
524
+ # the companion to adapters/registry.py's `froid_loop.adapters` group. Each entry
525
+ # point loads to a provider: a callable returning an iterable of CLIProfile (or an
526
+ # iterable directly), e.g. one built from the package's own bundled TOML. Scanned
527
+ # once per process (a third-party import failure is not transient); the resulting
528
+ # profiles are process-global (project-independent), so only the project overlay
529
+ # is re-read per load_profiles call. A broken entry point is recorded, not raised.
530
+ PROFILES_GROUP = "froid_loop.profiles"
531
+ _EXTERNALS_LOADED = False
532
+ _EXTERNAL_PROFILES: dict[str, CLIProfile] = {}
533
+ _PROFILE_LOAD_ERRORS: dict[str, str] = {}
534
+
535
+
536
+ def _coerce_profiles(produced: object, ep_name: str) -> list[CLIProfile]:
537
+ """A provider may return a callable's result or an iterable directly; either
538
+ way it must yield CLIProfile instances that satisfy the same invariants a TOML
539
+ profile does. Anything else is the package's bug — reported (per
540
+ :func:`external_profile_errors`), never trusted into the map.
541
+
542
+ The :func:`_validate_profile` call is the point of this function: without it a
543
+ Python provider is the one route into the profile map with no parser in front
544
+ of it, and it could install a state ``_parse_profile`` would refuse."""
545
+ try:
546
+ items = list(produced) # pyright: ignore[reportArgumentType] — TypeError is the check
547
+ except TypeError as exc:
548
+ raise ProfileError(
549
+ f"{ep_name}: profile provider must return an iterable of CLIProfile"
550
+ ) from exc
551
+ for item in items:
552
+ if not isinstance(item, CLIProfile):
553
+ raise ProfileError(
554
+ f"{ep_name}: profile provider yielded {type(item).__name__}, not CLIProfile"
555
+ )
556
+ _validate_profile(item, f"entry point {ep_name}")
557
+ return items
558
+
559
+
560
+ def _load_external_profiles() -> dict[str, CLIProfile]:
561
+ """Import every ``froid_loop.profiles`` entry point and collect the profiles it
562
+ provides, first-registration-wins on a name collision. Scan-once; failures are
563
+ recorded in ``_PROFILE_LOAD_ERRORS`` (surfaced via
564
+ :func:`external_profile_errors`), never raised — a broken adapter package must
565
+ not break profile loading for everything else.
566
+
567
+ A provider is rejected WHOLE: one invalid profile in the returned batch drops
568
+ the batch, because ``_coerce_profiles`` raises before any of them is recorded.
569
+ Deliberate — a provider is one package's declaration, and half-installing it
570
+ would leave an operator with a profile set no error message accounts for.
571
+
572
+ Entry points are visited in (name, distribution) order, so which provider wins
573
+ a name collision is a property of the packages rather than of ``sys.path``
574
+ ordering. The distribution is part of the key because the name alone is not a
575
+ total order — ``entry_points(group=...)`` does not dedup across distributions,
576
+ and ``sorted`` being stable would resolve a same-name tie back into discovery
577
+ order (the adapter scan sorts on the same key, for the same reason)."""
578
+ global _EXTERNALS_LOADED
579
+ if _EXTERNALS_LOADED:
580
+ return _EXTERNAL_PROFILES
581
+ _EXTERNALS_LOADED = True
582
+ try:
583
+ eps = sorted(
584
+ importlib.metadata.entry_points(group=PROFILES_GROUP),
585
+ key=lambda e: (e.name, getattr(e.dist, "name", "") or ""),
586
+ )
587
+ except Exception as exc: # noqa: BLE001 — diagnostics path, never crash loading
588
+ _PROFILE_LOAD_ERRORS["<entry-point scan>"] = f"{type(exc).__name__}: {exc}"
589
+ return _EXTERNAL_PROFILES
590
+ for ep in eps:
591
+ try:
592
+ provider = ep.load()
593
+ produced = provider() if callable(provider) else provider
594
+ for profile in _coerce_profiles(produced, ep.name):
595
+ _EXTERNAL_PROFILES.setdefault(profile.name, profile)
596
+ except Exception as exc: # noqa: BLE001 — one bad package must not hide the rest
597
+ record_load_error(_PROFILE_LOAD_ERRORS, ep, exc)
598
+ return _EXTERNAL_PROFILES
599
+
600
+
601
+ def external_profile_errors() -> dict[str, str]:
602
+ """Entry-point name -> failure reason(s) for every external profile provider
603
+ that failed to load this process (empty when all loaded). For diagnostics
604
+ surfaces.
605
+
606
+ One value may carry MORE than one reason, ``"; "``-joined: two distributions
607
+ may advertise the same entry-point name, and each of their failures is kept
608
+ (see :func:`~.entrypoints.record_load_error`). Each reason is labelled with
609
+ its distribution whenever one is resolvable.
610
+
611
+ Performs the scan rather than assuming a neighbouring call already did. The
612
+ only other trigger is :func:`load_profiles`, which ``validate`` reaches through
613
+ ``get_profile`` — inside the block that a ``PolicyError`` aborts. Reading a map
614
+ nothing had populated would report a broken profile package as absent for a
615
+ reason having nothing to do with that package, exactly when an operator is
616
+ already looking at a broken config. Scan-once still holds."""
617
+ _load_external_profiles()
618
+ return dict(_PROFILE_LOAD_ERRORS)
619
+
620
+
621
+ def load_profiles(project: Path | None = None) -> dict[str, CLIProfile]:
622
+ """Packaged built-ins, overlaid by ``froid_loop.profiles`` entry-point
623
+ profiles, overlaid by <project>/.froid-loop/profiles/*.toml.
624
+
625
+ Precedence is packaged < entry-point < project: a co-installed adapter package
626
+ extends (or overrides) the bundled set, and a project TOML always wins."""
627
+ profiles: dict[str, CLIProfile] = {}
628
+ packaged = resources.files("froid_loop.data").joinpath("profiles")
629
+ for entry in sorted(packaged.iterdir(), key=lambda e: e.name):
630
+ if entry.name.endswith(".toml"):
631
+ profile = _load_toml(_read_profile_text(entry, entry.name), entry.name)
632
+ # Stamped HERE and nowhere else: this loop is the only code that knows
633
+ # a profile came out of the package rather than off a project's disk.
634
+ profiles[profile.name] = replace(profile, packaged=True)
635
+ profiles.update(_load_external_profiles())
636
+ if project is not None:
637
+ user_dir = project / USER_PROFILES_REL
638
+ if user_dir.is_dir():
639
+ for path in sorted(user_dir.glob("*.toml")):
640
+ profile = _load_toml(_read_profile_text(path, str(path)), str(path))
641
+ profiles[profile.name] = profile
642
+ return profiles
643
+
644
+
645
+ def get_profile(name: str, project: Path | None = None) -> CLIProfile:
646
+ profiles = load_profiles(project)
647
+ profile = profiles.get(ALIASES.get(name, name))
648
+ if profile is None:
649
+ raise ProfileError(f"unknown CLI profile: {name!r} (available: {sorted(profiles)})")
650
+ return profile