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
froid_loop/policy.py ADDED
@@ -0,0 +1,1569 @@
1
+ """Policy-as-data: .froid-loop/policy.toml -> immutable Policy dataclasses."""
2
+
3
+ # Strict-checked under #245 Stage 2, with the three "expression fully known"
4
+ # rules below relaxed for this file only: the snapshot/TOML readers rebuild typed
5
+ # Policy objects from loosely-typed persisted data — `tomllib.load` and a run's
6
+ # json-round-tripped `asdict(Policy)` both hand back `dict[str, Any]`, and
7
+ # isinstance-narrowing an `Any` yields `dict[Unknown, Unknown]` — so `.get(...)`
8
+ # chains off those dicts read as Unknown. Clearing them would take a TypedDict
9
+ # per persisted shape or runtime coercions (out of scope for a no-runtime-change
10
+ # gate); every other strict rule stays on and still catches annotation drift.
11
+ # pyright: reportUnknownArgumentType=false, reportUnknownMemberType=false, reportUnknownVariableType=false
12
+
13
+ from __future__ import annotations
14
+
15
+ import re
16
+ import tomllib
17
+ import warnings
18
+ from dataclasses import asdict, dataclass, field
19
+ from pathlib import Path
20
+ from typing import Any
21
+
22
+ from .platform_util import (
23
+ atomic_write_bytes_confined,
24
+ has_parent_ref,
25
+ is_absolute_path,
26
+ names_tree_root,
27
+ names_win32_alias,
28
+ )
29
+
30
+ POLICY_FILE = Path(".froid-loop") / "policy.toml"
31
+
32
+ GATE_MODES = {"none", "per-epic", "per-story-spec-approval"}
33
+ RETRO_MODES = {"never", "notify", "auto"}
34
+ SESSION_BUDGET_MODES = {"off", "warn", "enforce"}
35
+ SWEEP_AUTO_MODES = {"never", "per-epic", "run-end"}
36
+ REVIEW_TRIGGER_MODES = {"always", "recommended"}
37
+ REVIEW_ON_TIMEOUT_MODES = {"retry", "salvage-if-done", "defer"}
38
+ REVIEW_ON_STATUS_CONTRADICTION_MODES = {"escalate", "retry"}
39
+ # Session stages, in run order. Lives here rather than in the TUI because
40
+ # settings_schema's expand_stages loop fans a template section out over it.
41
+ STAGES = ("dev", "review", "triage")
42
+ # Where the run gets its story queue. "sprint-status" (default) is the classic
43
+ # flow — froid-sprint-planning writes sprint-status.yaml from prose epics.
44
+ # "stories" is the opt-in folder+id dispatch flow (Froid Plane #2549): a typed,
45
+ # human-reviewed stories.yaml sibling of SPEC.md drives the loop.
46
+ STORIES_SOURCES = {"sprint-status", "stories"}
47
+ ISOLATION_MODES = {"none", "worktree"}
48
+ BRANCH_PER_MODES = {"story", "run"}
49
+ MERGE_STRATEGIES = {"ff", "merge", "squash"}
50
+ DEV_SKILLS = {"froid-dev-auto"}
51
+
52
+ # Backend names are registry keys (adapters/multiplexer.py), never paths or
53
+ # shell input; the alphabet mirrors what built-in and plugin backends use.
54
+ _MUX_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
55
+ # write_mux_backend's line targets: a [section] header, and the (possibly
56
+ # commented) `backend =` anchor line inside [mux]. Template prose comments must
57
+ # never start with `backend =` or the anchor match would hit them first.
58
+ _TOML_SECTION_RE = re.compile(r"^\s*\[(?P<name>[^\]]+)\]\s*(?:#.*)?$")
59
+ _MUX_KEY_RE = re.compile(r"^\s*#?\s*backend\s*=")
60
+
61
+ # Deprecated [engine] keys, folded into [plugins.unity] at load time. The
62
+ # game-engine layer is now a plugin; [engine] is a one-release compatibility
63
+ # alias (see _fold_deprecated_engine).
64
+ _ENGINE_SETTING_KEYS = ("editor_mode", "mcp", "unity_path", "ready_timeout_sec", "ready_grace_sec")
65
+
66
+
67
+ class PolicyError(Exception):
68
+ pass
69
+
70
+
71
+ @dataclass(frozen=True)
72
+ class GatesPolicy:
73
+ mode: str = "per-epic"
74
+ on_escalation: str = "pause" # CRITICAL escalations always pause; field reserved
75
+ retrospective: str = "notify"
76
+
77
+
78
+ @dataclass(frozen=True)
79
+ class LimitsPolicy:
80
+ max_review_cycles: int = 3
81
+ max_dev_attempts: int = 2
82
+ # additional review rounds the orchestrator grants *solely* because a
83
+ # completed round finalized the story (status: done) yet still set
84
+ # `followup_review_recommended: true`. Once this many such self-recommended
85
+ # follow-ups have been honored, the next finalized-but-still-recommending
86
+ # round force-converges instead of burning another cycle: verify → refile the
87
+ # lingering recommendation to the deferred-work ledger → commit. Damps the
88
+ # structurally non-convergent step-04 rule (every review pass patches findings
89
+ # and therefore recommends another pass). max_review_cycles stays the hard
90
+ # outer bound. 0 = never honor a pass's own follow-up recommendation
91
+ # (converge + refile on the first finalized round that still recommends one).
92
+ max_followup_reviews: int = 1
93
+ session_timeout_min: int = 90
94
+ # hard bound on any single git subprocess the orchestrator spawns (diff,
95
+ # reset, snapshot, …). The default is a sane normal-case ceiling, but a
96
+ # loaded host or a very large worktree can legitimately exceed it (#156) —
97
+ # exceeding it is a handled GitError, never a run crash, and raising the
98
+ # bound here is the fix when it fires spuriously.
99
+ git_timeout_s: int = 120
100
+ # bounded grace for verified session teardown (#157): after the first
101
+ # best-effort window kill the adapter polls liveness up to this many
102
+ # seconds; a window that survives it gets its pane pids force-killed and
103
+ # the window killed again. 0 = the old single unverified best-effort kill
104
+ # (the rollback lever if escalation ever misfires).
105
+ teardown_grace_s: int = 20
106
+ stop_without_result_nudges: int = 1
107
+ # how long a dev/review session may stay silent before it is declared
108
+ # stalled. The grace starts at session launch and re-arms on transport
109
+ # activity (pane-log output or parent/child OpenCode SSE frames) and fresh
110
+ # Stop/idle evidence, so productive work keeps extending it. Bounded by
111
+ # session_timeout_min. 0 disables the launch timer while retaining fail-fast
112
+ # handling when a turn ends without a terminal spec/result.
113
+ dev_stall_grace_s: int = 600
114
+ # how many best-effort wake nudges a silent dev/review session receives on
115
+ # dev_stall_grace_s expiry before it is called stalled. Transport activity
116
+ # re-arms the grace without spending a nudge; fresh Stop/idle evidence also
117
+ # restores this per-silence budget. The monotonic cap below still bounds the
118
+ # total, because an accepted nudge is not proof that the session woke. 0 =
119
+ # stall on grace expiry.
120
+ dev_stall_nudges: int = 2
121
+ # monotonic (never-restored) cap on total stall wake-nudges for a dev/review
122
+ # session (SessionSpec.stall_nudges_cap). The per-silence dev_stall_nudges
123
+ # budget is restored on every fresh Stop so a cooperative session awaiting a
124
+ # slow background process can keep waiting — but the wake nudge is itself a
125
+ # submitted turn, so a session that merely *answers* it ends in another
126
+ # result-less Stop and re-earns the budget: without this cap the loop rides
127
+ # the refill until session_timeout_min, burning a turn per cycle (#149).
128
+ # After this many total nudges the session is declared stalled instead
129
+ # (post-kill reconcile still rescues a finished one whose artifact is on
130
+ # disk). 0 = stall on first grace expiry.
131
+ dev_stall_nudges_cap: int = 6
132
+ # same monotonic cap for injected plugin-workflow sessions: one that keeps
133
+ # ending its turn without writing its completion marker is declared stalled
134
+ # after this many total nudges (non-blocking workflows then advance the
135
+ # phase).
136
+ workflow_stall_nudges_cap: int = 3
137
+ # One targeted nudge per session (#276 M4) when a Stop finds a marker-less
138
+ # terminal-frontmatter spec (a review that finalized `status: done`/`blocked`
139
+ # without appending its required `## Auto Run Result` section): ask the skill
140
+ # to append that section now, then end its turn — repairing the omission at
141
+ # the source so the normal marker scan harvests it. Sent exactly once per
142
+ # session (a never-cleared set), never refilled, and touching no stall
143
+ # counters; the harness-side frontmatter synthesis (#224) stays the backstop
144
+ # for a session that never complies. True enables it; False keeps the pre-M4
145
+ # behavior (synthesis only).
146
+ dev_contract_nudge: bool = True
147
+ # advisory cost-weighted cap on a whole STORY's cumulative spend, re-checked
148
+ # at every session boundary and warned about once per story (#336). Nothing
149
+ # is terminated on the crossing — enforcement is max_tokens_per_session below.
150
+ max_tokens_per_story: int = 2_000_000
151
+ # weight of cache-read tokens in the budget check (1.0 = count raw)
152
+ cache_read_weight: float = 0.1
153
+ # Mid-session token-budget guard (#158). "off" = no sampling; "warn" =
154
+ # ATTENTION + lifecycle breadcrumb once, session runs to its natural end;
155
+ # "enforce" = warn actions + wrap-up nudge + grace window, then the session
156
+ # ends over_budget (rides the ordinary retry→defer arm). Adapters with no
157
+ # mid-session usage signal (usage_parser "none", copilot's shutdown-only
158
+ # flush) leave the guard inert regardless of mode.
159
+ session_budget_mode: str = "warn"
160
+ # weighted (cache_read_weight-discounted) per-SESSION cap the adapter wait
161
+ # loops sample cumulative usage against every ~30s heartbeat tick. Distinct
162
+ # from the advisory per-story max_tokens_per_story: this one is per session
163
+ # and can end it; that one spans a story's sessions and only warns.
164
+ max_tokens_per_session: int = 4_000_000
165
+ # enforce mode: seconds a tripped session gets to wrap up after the nudge
166
+ # before it is terminated over_budget. 0 = terminate at trip, no nudge.
167
+ session_budget_grace_s: int = 240
168
+
169
+
170
+ @dataclass(frozen=True)
171
+ class VerifyPolicy:
172
+ commands: tuple[str, ...] = ()
173
+ # stream_capture_kb bounds, per stream, the verifier stdout/stderr retained
174
+ # under the run's `verify/` directory for plugins and post-mortems (#641).
175
+ # A tail is kept, matching every other bound on this output — the merged
176
+ # `output_tail` is `[-2000:]`, and the end of a failing suite is where the
177
+ # failure is. The journal record stays honest about the cut: it carries the
178
+ # FULL byte count beside the retained one and an explicit truncation flag,
179
+ # because a silently short file reads as a complete one.
180
+ #
181
+ # 256 KiB is chosen against what the store is FOR: a repair session or a
182
+ # plugin reading a failing suite's tail. A verbose pytest/ruff failure runs
183
+ # tens of KB, so the cap is generous enough that the realistic case is never
184
+ # cut, while a chatty command under COMMAND_TIMEOUT_S (30 minutes) can no
185
+ # longer emit hundreds of MB per attempt. Worst case is bounded and small:
186
+ # commands x 2 streams x attempts x 256 KiB. It sits far under the file-store
187
+ # precedent it is modelled on (scm.failed_diff_max_mb = 5) and far above the
188
+ # inline-journal caps, which is the right side of both.
189
+ #
190
+ # 0 = capture nothing: no files are written at all, and the record still
191
+ # lands with null pointers and the full byte counts, so the journal keeps
192
+ # saying what the command emitted even when none of it is retained.
193
+ stream_capture_kb: int = 256
194
+
195
+
196
+ @dataclass(frozen=True)
197
+ class NotifyPolicy:
198
+ desktop: bool = True
199
+ file: bool = True
200
+
201
+
202
+ @dataclass(frozen=True)
203
+ class ReviewPolicy:
204
+ # When False, the orchestrator runs no follow-up review session; the
205
+ # froid-build-auto session's own inline review is the only review and it
206
+ # finalizes the story straight to done.
207
+ enabled: bool = True
208
+ # When (and only when) enabled is True, decides when the follow-up review
209
+ # session (a froid-build-auto re-invocation on the done spec) actually runs:
210
+ # "recommended" (default) — only when the froid-build-auto session set
211
+ # `followup_review_recommended: true` in the spec frontmatter. The
212
+ # skill self-reviews inline on every story and flags this when its
213
+ # review-driven changes were significant enough to warrant an
214
+ # independent second opinion. Otherwise the deterministic gates run
215
+ # and the story commits without a second review session.
216
+ # "always" — run the second-opinion review on every story (pre-PR-#2505
217
+ # behavior). The skill's recommendation flag is recorded but ignored.
218
+ # Either way the review loop is bounded by two limits: limits.max_review_cycles
219
+ # is the hard outer cap on cycles, and limits.max_followup_reviews damps the
220
+ # structurally non-convergent case — a round that finalizes the story yet keeps
221
+ # recommending an independent follow-up — by converging + refiling once the
222
+ # damping grant is spent instead of looping to the outer cap.
223
+ trigger: str = "recommended"
224
+ # What a timeout-like review verdict (timeout / stalled / over_budget — the
225
+ # same set the post-kill reconcile treats as rescue-eligible) costs (#271):
226
+ # "retry" (default) — today's behavior: burn a review cycle per timeout
227
+ # until limits.max_review_cycles, then defer.
228
+ # "salvage-if-done" — when the spec's frontmatter shows the dev product
229
+ # already finalized (`done`, or the `in-review` mid-review interrupt,
230
+ # reset forward) and the deterministic verify gate passes, commit the
231
+ # work and refile any outstanding follow-up review to deferred work
232
+ # instead of burning another full review pass on an empty delta.
233
+ # "defer" — give up on the first timeout-like verdict (no retries).
234
+ # `crashed` and env-fault (#194) verdicts keep their own routing in every mode.
235
+ on_timeout: str = "retry"
236
+ # What a review that revokes the story's sprint sign-off costs (#334). The
237
+ # orchestrator advances sprint-status to `done` at dev time; a review session
238
+ # that judges the story unfinished and writes the board back to an earlier
239
+ # stage contradicts that — and nothing in the review loop re-advances the
240
+ # board, so every remaining cycle re-reads the same failure and the story
241
+ # ends deferred + rolled back.
242
+ # "escalate" (default) — pause the run naming both sides of the
243
+ # disagreement, so a human resolves it instead of the budget burning
244
+ # down onto a rollback.
245
+ # "retry" — legacy behavior: treat it as an ordinary verify failure, burn
246
+ # review cycles to limits.max_review_cycles, then defer.
247
+ # Keys on sprint-status only: the spec's own frontmatter status legitimately
248
+ # cycles (in-review/in-progress) while a review patches, and `status: blocked`
249
+ # remains the sanctioned way for a review to hand a story back to a human.
250
+ on_status_contradiction: str = "escalate"
251
+
252
+
253
+ @dataclass(frozen=True)
254
+ class StoriesPolicy:
255
+ """Story-queue source selection. Default reproduces sprint mode exactly.
256
+
257
+ ``source = "stories"`` opts a run into folder+id dispatch: the loop reads a
258
+ typed ``stories.yaml`` (Story Breakdown output, sibling of ``SPEC.md``) under
259
+ ``spec_folder`` and dispatches each entry by folder+id instead of walking
260
+ ``sprint-status.yaml``. ``spec_folder`` is the project-relative (or absolute)
261
+ path to the epic's spec folder; required and must parse when
262
+ ``source = "stories"``. There is deliberately **no** ``continue_independent``
263
+ knob — the manifest is strictly serial (no ``depends_on``), so a blocked
264
+ story always pauses the run for resolve rather than leapfrogging to later
265
+ work."""
266
+
267
+ source: str = "sprint-status"
268
+ spec_folder: str = ""
269
+
270
+
271
+ @dataclass(frozen=True)
272
+ class DevPolicy:
273
+ # Which inner dev skill the orchestrator drives. The sole supported value is
274
+ # "froid-dev-auto", the generic upstream dev primitive (Froid Plane PR #2500):
275
+ # it writes no result.json — the GenericDevAdapter synthesizes one from the
276
+ # spec the session leaves on disk. The field is retained (rather than inlined)
277
+ # as the seam for a future alternative dev skill; see DEV_SKILLS.
278
+ #
279
+ # NOT the name a session is dispatched with. Upstream renamed the primitive
280
+ # froid-dev-auto -> froid-build-auto (Froid Plane#2651), so the invoked name is
281
+ # resolved from what is actually on disk (Engine._dev_skill, via
282
+ # install.dev_primitive_or_default) and a project on either era works with
283
+ # this field untouched. This value is the ADAPTER DISCRIMINATOR — it selects
284
+ # the decoupled generic-dev behaviour seams (engine._generic_dev,
285
+ # runsetup.py's result-synthesis switch) — so it keeps the pre-rename
286
+ # spelling as a stable key rather than tracking the upstream directory name.
287
+ skill: str = "froid-dev-auto"
288
+
289
+
290
+ @dataclass(frozen=True)
291
+ class TuiPolicy:
292
+ # low_frame_rate caps Textual to 15fps and disables animations (sets
293
+ # TEXTUAL_FPS / TEXTUAL_ANIMATIONS before the app imports textual). Fixes
294
+ # repaint tearing/garbage when driving the TUI over a slow/high-latency
295
+ # link (SSH, Tailscale) where a 60fps update stream can't drain in time.
296
+ low_frame_rate: bool = False
297
+ # Persisted dashboard pane geometry, in terminal cells. 0 = unset: the layout
298
+ # keeps its built-in default proportions (so a fresh project looks unchanged
299
+ # and only user-resized panes land in the file). The TUI writes these when a
300
+ # pane is resized by mouse-drag or the Ctrl+W resize mode, and seeds them back
301
+ # on the next launch. Per-project, since policy.toml is project-scoped.
302
+ left_width: int = 0 # sidebar (#left) width, columns
303
+ runs_height: int = 0 # Runs pane height, rows (top of the left column)
304
+ deferred_height: int = 0 # Deferred pane height, rows (bottom of the left column)
305
+ tasks_height: int = 0 # Tasks table height, rows (detail column)
306
+
307
+
308
+ @dataclass(frozen=True)
309
+ class OperatorPolicy:
310
+ # Whether a dev session may park a story at `awaiting-operator` — the
311
+ # terminal state for a story whose agent-doable work is finished and
312
+ # committed but whose acceptance criteria include external actions only a
313
+ # human can perform (#335). Default-on: without it such a story has no honest
314
+ # outcome, and the two it would otherwise take are both wrong — `done` hides
315
+ # the outstanding work behind a green board, `blocked` halts a run over work
316
+ # the loop was never going to do. Off, the engine injects no park
317
+ # instruction, never targets the sprint token, and the verify gates do not
318
+ # know the status, so a session that writes it anyway is retried with that
319
+ # mismatch as feedback rather than silently committing.
320
+ enabled: bool = True
321
+
322
+
323
+ @dataclass(frozen=True)
324
+ class MuxPolicy:
325
+ # Terminal-multiplexer backend for THIS machine (the transport axis — which
326
+ # tmux-like program hosts sessions; independent of [adapter], the coding-CLI
327
+ # axis). "" = auto-select. Whether the name is actually registered is checked
328
+ # at selection time (adapters/multiplexer.py), not here — policy stays
329
+ # data-only, and a plugin backend may not be importable in every context that
330
+ # parses policy. Machine-specific: `froid-loop init` gitignores policy.toml.
331
+ backend: str = ""
332
+
333
+
334
+ @dataclass(frozen=True)
335
+ class SweepPolicy:
336
+ auto: str = "never" # never | per-epic | run-end
337
+ max_bundles: int = 5 # bundles executed per sweep; triage excess is truncated
338
+ max_triage_attempts: int = 2
339
+ max_migration_attempts: int = 2 # legacy-ledger migration retries before escalating
340
+ repeat: bool = False # re-triage after a cycle completes; continue on new deferred work
341
+ max_cycles: int = 5 # total cycles per sweep run when repeat is on
342
+
343
+
344
+ @dataclass(frozen=True)
345
+ class CleanupPolicy:
346
+ """Disk reclamation for `.froid-loop/runs`. Worktree reconcile + artifact
347
+ trim only ever touch terminal (finished/stopped) runs — paused/interrupted
348
+ runs stay intact so they remain resumable."""
349
+
350
+ run_retention: int = 10 # newest concluded runs kept whole; older ones trimmed/archived
351
+ retention_days: int = 0 # 0 = disabled; else also keep runs newer than N days
352
+ trim_artifacts: bool = True # drop the worktrees/ tree from concluded runs (keeps run viewable)
353
+ archive_old: bool = True # archive (vs hard-delete) runs past the window
354
+ auto_clean_on_finish: bool = True # reconcile stale worktrees + retention at clean finish
355
+ clean_tmp: bool = True # let engine plugins clean their /tmp scratch (e.g. Unity MCP zips)
356
+
357
+
358
+ @dataclass(frozen=True)
359
+ class StageAdapterPolicy:
360
+ """Per-stage overrides; None = inherit from [adapter]."""
361
+
362
+ name: str | None = None
363
+ model: str | None = None
364
+ extra_args: tuple[str, ...] | None = None
365
+ # None = inherit from [adapter] (which itself falls back to the CLI profile)
366
+ usage_grace_s: float | None = None
367
+ stop_without_result_nudges: int | None = None
368
+
369
+
370
+ @dataclass(frozen=True)
371
+ class ResolvedAdapter:
372
+ name: str
373
+ model: str
374
+ # None = use the profile's default bypass flags; a list replaces them
375
+ extra_args: tuple[str, ...] | None
376
+ # None = fall back to the CLI profile's default (usage_grace_s) / the global
377
+ # limits.stop_without_result_nudges respectively
378
+ usage_grace_s: float | None = None
379
+ stop_without_result_nudges: int | None = None
380
+
381
+
382
+ @dataclass(frozen=True)
383
+ class AdapterPolicy:
384
+ name: str = "claude" # CLI profile name; "claude-code-tmux" kept as legacy alias
385
+ model: str = ""
386
+ # None = use the profile's default bypass flags; a list replaces them
387
+ extra_args: tuple[str, ...] | None = None
388
+ # kill the run's froid-loop-<id> tmux session when it finishes (False keeps
389
+ # it around for post-run inspection)
390
+ cleanup_session_on_finish: bool = True
391
+ # None = inherit from the selected CLI profile / global limits (see
392
+ # ResolvedAdapter); a value overrides the profile's shipped default.
393
+ usage_grace_s: float | None = None
394
+ stop_without_result_nudges: int | None = None
395
+ dev: StageAdapterPolicy = field(default_factory=StageAdapterPolicy)
396
+ review: StageAdapterPolicy = field(default_factory=StageAdapterPolicy)
397
+ triage: StageAdapterPolicy = field(default_factory=StageAdapterPolicy)
398
+
399
+ def resolved(self, role: str) -> ResolvedAdapter:
400
+ stage = {"dev": self.dev, "review": self.review, "triage": self.triage}.get(role)
401
+ if stage is None:
402
+ return ResolvedAdapter(
403
+ self.name,
404
+ self.model,
405
+ self.extra_args,
406
+ self.usage_grace_s,
407
+ self.stop_without_result_nudges,
408
+ )
409
+ name = stage.name if stage.name is not None else self.name
410
+ # model and extra_args are client-specific: inherit from the base only
411
+ # when the stage runs the same client; a client switch falls back to
412
+ # that profile's defaults (CLI default model, profile bypass flags).
413
+ same_client = name == self.name
414
+ # usage_grace_s / stop_without_result_nudges are benign timing knobs that
415
+ # mean "fall back to the profile default" when None, so plain stage ??
416
+ # base inheritance is safe regardless of a client switch.
417
+ return ResolvedAdapter(
418
+ name=name,
419
+ model=(stage.model if stage.model is not None else (self.model if same_client else "")),
420
+ extra_args=(
421
+ stage.extra_args
422
+ if stage.extra_args is not None
423
+ else (self.extra_args if same_client else None)
424
+ ),
425
+ usage_grace_s=(
426
+ stage.usage_grace_s if stage.usage_grace_s is not None else self.usage_grace_s
427
+ ),
428
+ stop_without_result_nudges=(
429
+ stage.stop_without_result_nudges
430
+ if stage.stop_without_result_nudges is not None
431
+ else self.stop_without_result_nudges
432
+ ),
433
+ )
434
+
435
+
436
+ def _snapshot_extra_args(raw: Any) -> tuple[str, ...] | None:
437
+ # asdict() turns the extra_args tuple into a list and json keeps it a list;
438
+ # rebuild the tuple so a reconstructed AdapterPolicy compares equal to a
439
+ # freshly-parsed one (the #189 tuple-vs-list trap). None stays None.
440
+ #
441
+ # Deliberately keeps the lenient coercion `_typed_str_tuple` replaced in
442
+ # `loads()`: the input here is not user TOML but a json round-tripped
443
+ # `asdict(Policy)` whose extra_args was already validated on the way in, and
444
+ # the caller wraps this in `except Exception: return None` because it feeds
445
+ # status/TUI display surfaces that must never crash. Raising a PolicyError
446
+ # here would only be swallowed, at the cost of blanking the display.
447
+ if raw is None:
448
+ return None
449
+ return tuple(str(a) for a in raw)
450
+
451
+
452
+ def _stage_from_snapshot(raw: Any) -> StageAdapterPolicy:
453
+ # A missing or non-dict stage entry rebuilds to the all-inherit default,
454
+ # matching StageAdapterPolicy()'s field defaults.
455
+ if not isinstance(raw, dict):
456
+ return StageAdapterPolicy()
457
+ name = raw.get("name")
458
+ model = raw.get("model")
459
+ return StageAdapterPolicy(
460
+ name=None if name is None else str(name),
461
+ model=None if model is None else str(model),
462
+ extra_args=_snapshot_extra_args(raw.get("extra_args")),
463
+ usage_grace_s=raw.get("usage_grace_s"),
464
+ stop_without_result_nudges=raw.get("stop_without_result_nudges"),
465
+ )
466
+
467
+
468
+ def adapter_policy_from_snapshot(snapshot: dict[str, Any] | None) -> AdapterPolicy | None:
469
+ """Rebuild an :class:`AdapterPolicy` from a run's persisted policy snapshot.
470
+
471
+ ``snapshot`` is ``RunState.policy_snapshot`` — the json-round-tripped
472
+ ``asdict(Policy)``. This reconstructs the ``[adapter]`` sub-tree (the base
473
+ plus the dev/review/triage :class:`StageAdapterPolicy` stages) so display
474
+ paths can reuse the canonical :meth:`AdapterPolicy.resolved` instead of
475
+ re-deriving its stage-inheritance / client-switch rules against a raw dict.
476
+
477
+ Returns ``None`` when there is nothing trustworthy to rebuild: ``snapshot``
478
+ is ``None`` or not a dict, ``snapshot["adapter"]`` is not a dict, or it lacks
479
+ a non-empty string ``name`` — an all-defaults reconstruction would falsely
480
+ display "claude" for a run that predates adapter stamping. The rebuild is
481
+ wrapped so a malformed snapshot yields ``None`` rather than raising: this
482
+ feeds status/TUI display surfaces that must never crash.
483
+ """
484
+ try:
485
+ if not isinstance(snapshot, dict):
486
+ return None
487
+ adapter_d = snapshot.get("adapter")
488
+ if not isinstance(adapter_d, dict):
489
+ return None
490
+ name = adapter_d.get("name")
491
+ if not isinstance(name, str) or not name:
492
+ return None
493
+ return AdapterPolicy(
494
+ name=name,
495
+ model=str(adapter_d.get("model", AdapterPolicy.model)),
496
+ extra_args=_snapshot_extra_args(adapter_d.get("extra_args")),
497
+ cleanup_session_on_finish=bool(
498
+ adapter_d.get("cleanup_session_on_finish", AdapterPolicy.cleanup_session_on_finish)
499
+ ),
500
+ usage_grace_s=adapter_d.get("usage_grace_s"),
501
+ stop_without_result_nudges=adapter_d.get("stop_without_result_nudges"),
502
+ dev=_stage_from_snapshot(adapter_d.get("dev")),
503
+ review=_stage_from_snapshot(adapter_d.get("review")),
504
+ triage=_stage_from_snapshot(adapter_d.get("triage")),
505
+ )
506
+ except Exception:
507
+ return None
508
+
509
+
510
+ @dataclass(frozen=True)
511
+ class ScmPolicy:
512
+ # isolation = none -> work happens in place on the checked-out branch
513
+ # (today's behavior; no branches, no merge-back).
514
+ # isolation = worktree -> each unit runs in its own git worktree/branch and
515
+ # merges back into target_branch locally (Phase 3).
516
+ isolation: str = "none" # none | worktree
517
+ branch_per: str = "story" # story | run (worktree mode only)
518
+ target_branch: str = "" # "" = the branch checked out at run start
519
+ merge_strategy: str = "merge" # ff | merge | squash
520
+ delete_branch: bool = True # delete the unit branch after a successful merge
521
+ keep_failed: bool = True # keep a failed unit's worktree+branch for inspection
522
+ # rollback_on_failure governs in-place (isolation = "none") recovery after a
523
+ # failed attempt / rejected review. Default OFF: the orchestrator never
524
+ # touches the working tree — it pauses the run with manual recovery
525
+ # instructions, so a half-finished attempt is left for you to inspect. ON:
526
+ # the orchestrator auto-reverts the attempt's tracked changes and removes the
527
+ # untracked files THIS run created (never a blanket `git clean`; pre-existing
528
+ # untracked files and the whole _froid-output/ are preserved) — convenient but
529
+ # it discards the attempt's uncommitted work, so a warning is journalled when
530
+ # it fires. Worktree isolation sidesteps this entirely (failed work stays in
531
+ # its worktree), so this knob only matters for isolation = "none". This flag
532
+ # governs unattended/stopped attempts only: a human-initiated escalation
533
+ # resolve re-drive always auto-recovers regardless — it reverts the failed
534
+ # attempt's source but preserves the corrected spec under the FROID artifact
535
+ # folders, which it treats as orchestrator-owned.
536
+ rollback_on_failure: bool = False
537
+ # preserve_keep bounds both recovery-ref families auto-rollback parks before
538
+ # its hard reset — the attempt-preserve/* branches and the
539
+ # refs/attempt-preserve-dirty/* worktree snapshots: each run start keeps only
540
+ # the N most recent per family (by committer date) and deletes the tail, so a
541
+ # long-lived project with rollback_on_failure on doesn't accumulate them
542
+ # forever. 0 = never prune (maximum safety).
543
+ preserve_keep: int = 20
544
+ # failed_diff_max_mb caps the per-file size (MB) of untracked files captured
545
+ # into a kept-failed unit's forensic changes.patch, so a stray build dir or
546
+ # huge log can't blow it up; oversized files are skipped with a labelled
547
+ # marker in the patch. failed_diff_unlimited lifts the cap entirely (capture
548
+ # everything regardless of size) — convenient but may produce very large
549
+ # patches, so a warning is journalled when it's active.
550
+ failed_diff_max_mb: int = 5
551
+ failed_diff_unlimited: bool = False
552
+ # commit_message_template, when non-empty, is the commit message dev sessions
553
+ # use for a story's commit (placeholders {story_key}, {run_id} and
554
+ # {story_title} — the spec's `title:` frontmatter, else a first `#` heading, minus any
555
+ # "Story <id>:" label, falling back to the key — are substituted). Empty = the
556
+ # built-in default message.
557
+ commit_message_template: str = ""
558
+ # max_parallel: units in flight at once. Parallel fan-out (Phase 5) is not
559
+ # built yet, so any value > 1 is clamped to 1 in loads() — the knob exists
560
+ # but is inert until the parallel scheduler lands.
561
+ max_parallel: int = 1
562
+ # A `git worktree add` checks out tracked files only, so gitignored MCP/CLI
563
+ # configs are missing from every fresh worktree and isolated sessions can't
564
+ # reach their MCP server. seed_adapter_defaults copies each loaded adapter's
565
+ # own seed_files (e.g. claude -> .mcp.json/.claude/settings.json) into the
566
+ # worktree; worktree_seed adds extra project-specific paths on top.
567
+ seed_adapter_defaults: bool = True
568
+ worktree_seed: tuple[str, ...] = ()
569
+
570
+ def __post_init__(self) -> None:
571
+ # branch_per="run" shares a single branch across every unit in the run;
572
+ # deleting it after the first unit's merge would defeat that (the next
573
+ # unit would re-cut a fresh branch). Coerce delete_branch off so the
574
+ # shared-branch semantics actually hold, regardless of how this policy
575
+ # was constructed.
576
+ if self.branch_per == "run" and self.delete_branch:
577
+ object.__setattr__(self, "delete_branch", False)
578
+
579
+
580
+ @dataclass(frozen=True)
581
+ class PluginsPolicy:
582
+ # Trust allowlist for the plugin system. A plugin folder dropped under
583
+ # .froid-loop/plugins/ (or shipped under froid_loop/data/plugins/) loads its
584
+ # declarative manifest — settings + out-of-process shell hooks — regardless.
585
+ # A plugin that declares an in-process [python] module is NEVER imported or
586
+ # executed unless its name appears here. Absent table = no plugins trusted,
587
+ # which reproduces today's behavior exactly.
588
+ enabled: tuple[str, ...] = ()
589
+ # Per-plugin settings, parsed from the [plugins.<name>] sub-tables. Each
590
+ # value is the raw settings dict for that plugin; the plugin's own schema
591
+ # gives the keys meaning. Read through Policy.plugin_setting(). A plugin
592
+ # need not be in `enabled` to carry settings here (settings are data, only
593
+ # in-process [python] is trust-gated), but the settings UI renders a
594
+ # plugin's section only when it is enabled.
595
+ settings: dict[str, dict[str, Any]] = field(default_factory=dict)
596
+
597
+
598
+ @dataclass(frozen=True)
599
+ class Policy:
600
+ gates: GatesPolicy = field(default_factory=GatesPolicy)
601
+ limits: LimitsPolicy = field(default_factory=LimitsPolicy)
602
+ verify: VerifyPolicy = field(default_factory=VerifyPolicy)
603
+ notify: NotifyPolicy = field(default_factory=NotifyPolicy)
604
+ review: ReviewPolicy = field(default_factory=ReviewPolicy)
605
+ stories: StoriesPolicy = field(default_factory=StoriesPolicy)
606
+ dev: DevPolicy = field(default_factory=DevPolicy)
607
+ adapter: AdapterPolicy = field(default_factory=AdapterPolicy)
608
+ sweep: SweepPolicy = field(default_factory=SweepPolicy)
609
+ scm: ScmPolicy = field(default_factory=ScmPolicy)
610
+ cleanup: CleanupPolicy = field(default_factory=CleanupPolicy)
611
+ plugins: PluginsPolicy = field(default_factory=PluginsPolicy)
612
+ tui: TuiPolicy = field(default_factory=TuiPolicy)
613
+ operator: OperatorPolicy = field(default_factory=OperatorPolicy)
614
+ mux: MuxPolicy = field(default_factory=MuxPolicy)
615
+
616
+ def to_dict(self) -> dict[str, Any]:
617
+ return asdict(self)
618
+
619
+ def plugin_setting(self, name: str, key: str, default: Any = None) -> Any:
620
+ """A single setting for plugin ``name`` from its [plugins.<name>] table,
621
+ or ``default`` when unset. The plugin's schema supplies the real default
622
+ when this is called with the schema default as ``default``."""
623
+ return self.plugins.settings.get(name, {}).get(key, default)
624
+
625
+
626
+ def _section(doc: dict[str, Any], name: str) -> dict[str, Any]:
627
+ value = doc.get(name, {})
628
+ if not isinstance(value, dict):
629
+ raise PolicyError(f"[{name}] must be a table")
630
+ return value
631
+
632
+
633
+ def _opt_grace(d: dict[str, Any], where: str) -> float | None:
634
+ """An optional per-stage override; ``None`` means "inherit", which is why this
635
+ cannot take `_typed_float`'s default. The type guard is the same one, inline:
636
+ a TOML int is a legal number here (``usage_grace_s = 30``), a bool is not."""
637
+ raw = d.get("usage_grace_s")
638
+ if raw is None:
639
+ return None
640
+ if isinstance(raw, bool) or not isinstance(raw, (int, float)):
641
+ raise PolicyError(f"{where}.usage_grace_s must be a number: got {raw!r}")
642
+ value = float(raw)
643
+ if value < 0:
644
+ raise PolicyError(f"{where}.usage_grace_s must be >= 0: got {value}")
645
+ return value
646
+
647
+
648
+ def _opt_nudges(d: dict[str, Any], where: str) -> int | None:
649
+ """The `_opt_grace` shape for the integer knob; see there for why it is inline."""
650
+ raw = d.get("stop_without_result_nudges")
651
+ if raw is None:
652
+ return None
653
+ if isinstance(raw, bool) or not isinstance(raw, int):
654
+ raise PolicyError(f"{where}.stop_without_result_nudges must be an integer: got {raw!r}")
655
+ value = int(raw)
656
+ if value < 0:
657
+ raise PolicyError(f"{where}.stop_without_result_nudges must be >= 0: got {value}")
658
+ return value
659
+
660
+
661
+ def _tui_dim(d: dict[str, Any], key: str) -> int:
662
+ """A persisted TUI pane dimension (cells). 0 = unset; negatives are rejected.
663
+ Strict like scm.max_parallel: a TOML bool or float would coerce silently and
664
+ corrupt the saved geometry, so require a real int."""
665
+ raw = d.get(key, 0)
666
+ if isinstance(raw, bool) or not isinstance(raw, int):
667
+ raise PolicyError(f"tui.{key} must be a non-negative integer: got {raw!r}")
668
+ if raw < 0:
669
+ raise PolicyError(f"tui.{key} must be >= 0: got {raw}")
670
+ return raw
671
+
672
+
673
+ def _stage_adapter(adapter_d: dict[str, Any], key: str) -> StageAdapterPolicy:
674
+ raw = adapter_d.get(key, {})
675
+ if not isinstance(raw, dict):
676
+ raise PolicyError(f"[adapter.{key}] must be a table")
677
+ return StageAdapterPolicy(
678
+ name=_opt_typed_str(raw, f"adapter.{key}", "name"),
679
+ model=_opt_typed_str(raw, f"adapter.{key}", "model"),
680
+ extra_args=_typed_str_tuple(raw, f"adapter.{key}", "extra_args"),
681
+ usage_grace_s=_opt_grace(raw, f"adapter.{key}"),
682
+ stop_without_result_nudges=_opt_nudges(raw, f"adapter.{key}"),
683
+ )
684
+
685
+
686
+ def _validate_plugin_settings(name: str, raw: dict[str, Any], specs: Any) -> None:
687
+ """Validate a [plugins.<name>] table against its plugin's setting specs
688
+ (objects exposing key/type/options). Unknown keys and type/option mismatches
689
+ raise PolicyError; a None schema means the plugin isn't loaded here, skip."""
690
+ if specs is None:
691
+ return
692
+ by_key = {s.key: s for s in specs}
693
+ for key, value in raw.items():
694
+ spec = by_key.get(key)
695
+ if spec is None:
696
+ raise PolicyError(f"plugins.{name}: unknown setting {key!r}")
697
+ kind = spec.type
698
+ if kind == "bool" and not isinstance(value, bool):
699
+ raise PolicyError(f"plugins.{name}.{key} must be a boolean")
700
+ # bool is a subclass of int; reject it explicitly for numeric kinds.
701
+ if kind == "int" and (isinstance(value, bool) or not isinstance(value, int)):
702
+ raise PolicyError(f"plugins.{name}.{key} must be an integer")
703
+ if kind == "float" and (isinstance(value, bool) or not isinstance(value, (int, float))):
704
+ raise PolicyError(f"plugins.{name}.{key} must be a number")
705
+ if kind == "str" and not isinstance(value, str):
706
+ raise PolicyError(f"plugins.{name}.{key} must be a string")
707
+ if kind == "select" and value not in spec.options:
708
+ raise PolicyError(
709
+ f"plugins.{name}.{key} must be one of {list(spec.options)}: got {value!r}"
710
+ )
711
+
712
+
713
+ # The typed readers every user-TOML section coerces through. `where` is the
714
+ # section label the message names ("limits", "scm", "adapter.dev", ...), so one
715
+ # definition serves every section instead of a per-section family; a bare
716
+ # `int()`/`bool()` in their place raises a raw ValueError/TypeError, which is
717
+ # neither a PolicyError nor an OSError and so escapes every degrade handler in
718
+ # the codebase (#440, and #278 for the [limits] leg these grew out of).
719
+
720
+
721
+ def _typed_int(d: dict[str, Any], where: str, key: str, default: int) -> int:
722
+ value = d.get(key, default)
723
+ # bool is a subclass of int; a TOML `true` would read as 1 and silently
724
+ # rewrite the knob, so reject it before the isinstance check passes it.
725
+ if isinstance(value, bool) or not isinstance(value, int):
726
+ raise PolicyError(f"{where}.{key} must be an integer: got {value!r}")
727
+ return value
728
+
729
+
730
+ def _typed_float(d: dict[str, Any], where: str, key: str, default: float) -> float:
731
+ value = d.get(key, default)
732
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
733
+ raise PolicyError(f"{where}.{key} must be a number: got {value!r}")
734
+ return float(value)
735
+
736
+
737
+ def _typed_bool(d: dict[str, Any], where: str, key: str, default: bool) -> bool:
738
+ value = d.get(key, default)
739
+ if not isinstance(value, bool):
740
+ raise PolicyError(f"{where}.{key} must be a boolean: got {value!r}")
741
+ return value
742
+
743
+
744
+ def _typed_str(d: dict[str, Any], where: str, key: str, default: str) -> str:
745
+ value = d.get(key, default)
746
+ if not isinstance(value, str):
747
+ raise PolicyError(f"{where}.{key} must be a string: got {value!r}")
748
+ return value
749
+
750
+
751
+ def _opt_typed_str(d: dict[str, Any], where: str, key: str) -> str | None:
752
+ """The `_typed_str` shape for a key whose unset state is None rather than a
753
+ default — the per-stage `[adapter.<stage>]` overrides inherit from the parent
754
+ `[adapter]` table when absent, so they cannot express "unset" as a value."""
755
+ value = d.get(key)
756
+ if value is None:
757
+ return None
758
+ if not isinstance(value, str):
759
+ raise PolicyError(f"{where}.{key} must be a string: got {value!r}")
760
+ return value
761
+
762
+
763
+ def _typed_str_tuple(d: dict[str, Any], where: str, key: str) -> tuple[str, ...] | None:
764
+ """An optional TOML array of strings, or None when the key is absent.
765
+
766
+ Shape before entries, for the reason `scm.worktree_seed` states below: a bare
767
+ string is iterable, so `tuple(str(a) for a in raw)` explodes it per character
768
+ into one-character entries that each look like a valid argument, and a scalar
769
+ raises a bare TypeError out of `loads` where every other malformed value here
770
+ raises PolicyError."""
771
+ raw = d.get(key)
772
+ if raw is None:
773
+ return None
774
+ if isinstance(raw, (str, bytes)) or not isinstance(raw, (list, tuple)):
775
+ raise PolicyError(f"{where}.{key} must be an array of strings: got {raw!r}")
776
+ if not all(isinstance(a, str) for a in raw):
777
+ raise PolicyError(f"{where}.{key} must be an array of strings: got {list(raw)!r}")
778
+ return tuple(raw)
779
+
780
+
781
+ def load(path: Path | None) -> Policy:
782
+ """Load policy from a TOML file; a missing file yields all defaults.
783
+
784
+ An undecodable file is a `PolicyError` like a malformed one. `read_text` raises
785
+ `UnicodeDecodeError`, which is a `ValueError` and not an `OSError`, so left raw it
786
+ escapes every `except (PolicyError, OSError)` handler in the codebase — and those
787
+ handlers are the ones whose whole job is to degrade to defaults rather than take
788
+ the process down. Converting here fixes them all at once instead of asking each
789
+ to name a second exception type it has no other reason to know about."""
790
+ if path is None or not path.is_file():
791
+ return loads("")
792
+ try:
793
+ text = path.read_text(encoding="utf-8")
794
+ except UnicodeDecodeError as e:
795
+ raise PolicyError(f"{path}: not valid UTF-8: {e}") from e
796
+ try:
797
+ return loads(text)
798
+ except PolicyError as e:
799
+ raise PolicyError(f"{path}: {e}") from e
800
+
801
+
802
+ def loads(text: str, plugin_schemas: dict[str, Any] | None = None) -> Policy:
803
+ """Parse and validate policy TOML text; empty text yields all defaults.
804
+
805
+ ``plugin_schemas`` optionally maps a plugin name to its sequence of setting
806
+ specs (objects with ``key``/``type``/``options`` attributes). When given,
807
+ every present ``[plugins.<name>]`` table whose plugin is in the mapping is
808
+ validated against that schema: unknown keys and type/option mismatches raise
809
+ PolicyError. Plugin tables without a supplied schema pass through untouched
810
+ (a plugin may not be loaded in every context that reads policy)."""
811
+ try:
812
+ doc: dict[str, Any] = tomllib.loads(text)
813
+ except tomllib.TOMLDecodeError as e:
814
+ raise PolicyError(f"invalid policy TOML: {e}") from e
815
+
816
+ gates_d = _section(doc, "gates")
817
+ limits_d = _section(doc, "limits")
818
+ verify_d = _section(doc, "verify")
819
+ notify_d = _section(doc, "notify")
820
+ review_d = _section(doc, "review")
821
+ stories_d = _section(doc, "stories")
822
+ dev_d = _section(doc, "dev")
823
+ adapter_d = _section(doc, "adapter")
824
+ sweep_d = _section(doc, "sweep")
825
+ scm_d = _section(doc, "scm")
826
+ cleanup_d = _section(doc, "cleanup")
827
+ engine_d = _section(doc, "engine") # deprecated; folded into [plugins] below
828
+ plugins_d = _section(doc, "plugins")
829
+ tui_d = _section(doc, "tui")
830
+ operator_d = _section(doc, "operator")
831
+ mux_d = _section(doc, "mux")
832
+
833
+ gates = GatesPolicy(
834
+ mode=_typed_str(gates_d, "gates", "mode", GatesPolicy.mode),
835
+ on_escalation=_typed_str(gates_d, "gates", "on_escalation", GatesPolicy.on_escalation),
836
+ retrospective=_typed_str(gates_d, "gates", "retrospective", GatesPolicy.retrospective),
837
+ )
838
+ if gates.mode not in GATE_MODES:
839
+ raise PolicyError(f"gates.mode must be one of {sorted(GATE_MODES)}: got {gates.mode!r}")
840
+ if gates.retrospective not in RETRO_MODES:
841
+ raise PolicyError(
842
+ f"gates.retrospective must be one of {sorted(RETRO_MODES)}: got {gates.retrospective!r}"
843
+ )
844
+
845
+ limits = LimitsPolicy(
846
+ max_review_cycles=_typed_int(
847
+ limits_d, "limits", "max_review_cycles", LimitsPolicy.max_review_cycles
848
+ ),
849
+ max_dev_attempts=_typed_int(
850
+ limits_d, "limits", "max_dev_attempts", LimitsPolicy.max_dev_attempts
851
+ ),
852
+ max_followup_reviews=_typed_int(
853
+ limits_d, "limits", "max_followup_reviews", LimitsPolicy.max_followup_reviews
854
+ ),
855
+ session_timeout_min=_typed_int(
856
+ limits_d, "limits", "session_timeout_min", LimitsPolicy.session_timeout_min
857
+ ),
858
+ git_timeout_s=_typed_int(limits_d, "limits", "git_timeout_s", LimitsPolicy.git_timeout_s),
859
+ teardown_grace_s=_typed_int(
860
+ limits_d, "limits", "teardown_grace_s", LimitsPolicy.teardown_grace_s
861
+ ),
862
+ stop_without_result_nudges=_typed_int(
863
+ limits_d,
864
+ "limits",
865
+ "stop_without_result_nudges",
866
+ LimitsPolicy.stop_without_result_nudges,
867
+ ),
868
+ dev_stall_grace_s=_typed_int(
869
+ limits_d, "limits", "dev_stall_grace_s", LimitsPolicy.dev_stall_grace_s
870
+ ),
871
+ dev_stall_nudges=_typed_int(
872
+ limits_d, "limits", "dev_stall_nudges", LimitsPolicy.dev_stall_nudges
873
+ ),
874
+ dev_stall_nudges_cap=_typed_int(
875
+ limits_d, "limits", "dev_stall_nudges_cap", LimitsPolicy.dev_stall_nudges_cap
876
+ ),
877
+ workflow_stall_nudges_cap=_typed_int(
878
+ limits_d, "limits", "workflow_stall_nudges_cap", LimitsPolicy.workflow_stall_nudges_cap
879
+ ),
880
+ dev_contract_nudge=_typed_bool(
881
+ limits_d, "limits", "dev_contract_nudge", LimitsPolicy.dev_contract_nudge
882
+ ),
883
+ max_tokens_per_story=_typed_int(
884
+ limits_d, "limits", "max_tokens_per_story", LimitsPolicy.max_tokens_per_story
885
+ ),
886
+ cache_read_weight=_typed_float(
887
+ limits_d, "limits", "cache_read_weight", LimitsPolicy.cache_read_weight
888
+ ),
889
+ session_budget_mode=_typed_str(
890
+ limits_d, "limits", "session_budget_mode", LimitsPolicy.session_budget_mode
891
+ ),
892
+ max_tokens_per_session=_typed_int(
893
+ limits_d, "limits", "max_tokens_per_session", LimitsPolicy.max_tokens_per_session
894
+ ),
895
+ session_budget_grace_s=_typed_int(
896
+ limits_d, "limits", "session_budget_grace_s", LimitsPolicy.session_budget_grace_s
897
+ ),
898
+ )
899
+ if limits.max_review_cycles < 1 or limits.max_dev_attempts < 1:
900
+ raise PolicyError("limits.max_review_cycles and limits.max_dev_attempts must be >= 1")
901
+ if limits.max_followup_reviews < 0:
902
+ raise PolicyError(
903
+ f"limits.max_followup_reviews must be >= 0: got {limits.max_followup_reviews}"
904
+ )
905
+ if limits.session_timeout_min < 1:
906
+ raise PolicyError(
907
+ f"limits.session_timeout_min must be >= 1: got {limits.session_timeout_min}"
908
+ )
909
+ if limits.git_timeout_s < 1:
910
+ raise PolicyError(f"limits.git_timeout_s must be >= 1: got {limits.git_timeout_s}")
911
+ if limits.teardown_grace_s < 0:
912
+ raise PolicyError(f"limits.teardown_grace_s must be >= 0: got {limits.teardown_grace_s}")
913
+ if limits.stop_without_result_nudges < 0:
914
+ raise PolicyError(
915
+ "limits.stop_without_result_nudges must be >= 0: "
916
+ f"got {limits.stop_without_result_nudges}"
917
+ )
918
+ if not 0.0 <= limits.cache_read_weight <= 1.0:
919
+ raise PolicyError(
920
+ f"limits.cache_read_weight must be between 0 and 1: got {limits.cache_read_weight}"
921
+ )
922
+ if limits.dev_stall_grace_s < 0:
923
+ raise PolicyError(f"limits.dev_stall_grace_s must be >= 0: got {limits.dev_stall_grace_s}")
924
+ if limits.dev_stall_nudges < 0:
925
+ raise PolicyError(f"limits.dev_stall_nudges must be >= 0: got {limits.dev_stall_nudges}")
926
+ if limits.dev_stall_nudges_cap < 0:
927
+ raise PolicyError(
928
+ f"limits.dev_stall_nudges_cap must be >= 0: got {limits.dev_stall_nudges_cap}"
929
+ )
930
+ if limits.workflow_stall_nudges_cap < 0:
931
+ raise PolicyError(
932
+ f"limits.workflow_stall_nudges_cap must be >= 0: got {limits.workflow_stall_nudges_cap}"
933
+ )
934
+ if limits.session_budget_mode not in SESSION_BUDGET_MODES:
935
+ raise PolicyError(
936
+ f"limits.session_budget_mode must be one of {sorted(SESSION_BUDGET_MODES)}: "
937
+ f"got {limits.session_budget_mode!r}"
938
+ )
939
+ if limits.max_tokens_per_story < 1:
940
+ raise PolicyError(
941
+ f"limits.max_tokens_per_story must be >= 1: got {limits.max_tokens_per_story}"
942
+ )
943
+ if limits.max_tokens_per_session < 1:
944
+ raise PolicyError(
945
+ f"limits.max_tokens_per_session must be >= 1: got {limits.max_tokens_per_session}"
946
+ )
947
+ if limits.session_budget_grace_s < 0:
948
+ raise PolicyError(
949
+ f"limits.session_budget_grace_s must be >= 0: got {limits.session_budget_grace_s}"
950
+ )
951
+
952
+ verify = VerifyPolicy(
953
+ commands=_typed_str_tuple(verify_d, "verify", "commands") or (),
954
+ stream_capture_kb=_typed_int(
955
+ verify_d, "verify", "stream_capture_kb", VerifyPolicy.stream_capture_kb
956
+ ),
957
+ )
958
+ if verify.stream_capture_kb < 0:
959
+ raise PolicyError(f"verify.stream_capture_kb must be >= 0: got {verify.stream_capture_kb}")
960
+ notify = NotifyPolicy(
961
+ desktop=_typed_bool(notify_d, "notify", "desktop", NotifyPolicy.desktop),
962
+ file=_typed_bool(notify_d, "notify", "file", NotifyPolicy.file),
963
+ )
964
+ review = ReviewPolicy(
965
+ enabled=_typed_bool(review_d, "review", "enabled", ReviewPolicy.enabled),
966
+ trigger=_typed_str(review_d, "review", "trigger", ReviewPolicy.trigger).strip(),
967
+ on_timeout=_typed_str(review_d, "review", "on_timeout", ReviewPolicy.on_timeout).strip(),
968
+ on_status_contradiction=_typed_str(
969
+ review_d, "review", "on_status_contradiction", ReviewPolicy.on_status_contradiction
970
+ ).strip(),
971
+ )
972
+ if review.trigger not in REVIEW_TRIGGER_MODES:
973
+ raise PolicyError(
974
+ f"review.trigger must be one of {sorted(REVIEW_TRIGGER_MODES)}: got {review.trigger!r}"
975
+ )
976
+ if review.on_timeout not in REVIEW_ON_TIMEOUT_MODES:
977
+ raise PolicyError(
978
+ f"review.on_timeout must be one of {sorted(REVIEW_ON_TIMEOUT_MODES)}:"
979
+ f" got {review.on_timeout!r}"
980
+ )
981
+ if review.on_status_contradiction not in REVIEW_ON_STATUS_CONTRADICTION_MODES:
982
+ raise PolicyError(
983
+ "review.on_status_contradiction must be one of "
984
+ f"{sorted(REVIEW_ON_STATUS_CONTRADICTION_MODES)}:"
985
+ f" got {review.on_status_contradiction!r}"
986
+ )
987
+ stories = StoriesPolicy(
988
+ source=_typed_str(stories_d, "stories", "source", StoriesPolicy.source).strip(),
989
+ spec_folder=_typed_str(
990
+ stories_d, "stories", "spec_folder", StoriesPolicy.spec_folder
991
+ ).strip(),
992
+ )
993
+ if stories.source not in STORIES_SOURCES:
994
+ raise PolicyError(
995
+ f"stories.source must be one of {sorted(STORIES_SOURCES)}: got {stories.source!r}"
996
+ )
997
+ # source="stories" needs a spec_folder to read stories.yaml from; the reverse
998
+ # (a spec_folder set under sprint-status mode) is a harmless leftover, ignored
999
+ # at run time — no error, so switching source back and forth keeps the path.
1000
+ if stories.source == "stories" and not stories.spec_folder:
1001
+ raise PolicyError('stories.source = "stories" requires stories.spec_folder to be set')
1002
+ dev = DevPolicy(skill=_typed_str(dev_d, "dev", "skill", DevPolicy.skill))
1003
+ if dev.skill not in DEV_SKILLS:
1004
+ raise PolicyError(
1005
+ f"dev.skill must be one of {sorted(DEV_SKILLS)}: got {dev.skill!r}. This is the "
1006
+ f"adapter discriminator, not the invoked skill name — the name a session is "
1007
+ f"dispatched with is resolved from the skill tree on disk, so a project on the "
1008
+ f"post-rename froid-build-auto needs no change here"
1009
+ )
1010
+ for legacy, replacement in (
1011
+ ("model_dev", "[adapter.dev] model"),
1012
+ ("model_review", "[adapter.review] model"),
1013
+ ):
1014
+ if legacy in adapter_d:
1015
+ raise PolicyError(f"adapter.{legacy} was removed — use {replacement} instead")
1016
+ adapter = AdapterPolicy(
1017
+ name=_typed_str(adapter_d, "adapter", "name", AdapterPolicy.name),
1018
+ model=_typed_str(adapter_d, "adapter", "model", AdapterPolicy.model),
1019
+ extra_args=_typed_str_tuple(adapter_d, "adapter", "extra_args"),
1020
+ cleanup_session_on_finish=_typed_bool(
1021
+ adapter_d,
1022
+ "adapter",
1023
+ "cleanup_session_on_finish",
1024
+ AdapterPolicy.cleanup_session_on_finish,
1025
+ ),
1026
+ usage_grace_s=_opt_grace(adapter_d, "adapter"),
1027
+ stop_without_result_nudges=_opt_nudges(adapter_d, "adapter"),
1028
+ dev=_stage_adapter(adapter_d, "dev"),
1029
+ review=_stage_adapter(adapter_d, "review"),
1030
+ triage=_stage_adapter(adapter_d, "triage"),
1031
+ )
1032
+ sweep = SweepPolicy(
1033
+ auto=_typed_str(sweep_d, "sweep", "auto", SweepPolicy.auto),
1034
+ max_bundles=_typed_int(sweep_d, "sweep", "max_bundles", SweepPolicy.max_bundles),
1035
+ max_triage_attempts=_typed_int(
1036
+ sweep_d, "sweep", "max_triage_attempts", SweepPolicy.max_triage_attempts
1037
+ ),
1038
+ max_migration_attempts=_typed_int(
1039
+ sweep_d, "sweep", "max_migration_attempts", SweepPolicy.max_migration_attempts
1040
+ ),
1041
+ repeat=_typed_bool(sweep_d, "sweep", "repeat", SweepPolicy.repeat),
1042
+ max_cycles=_typed_int(sweep_d, "sweep", "max_cycles", SweepPolicy.max_cycles),
1043
+ )
1044
+ if sweep.auto not in SWEEP_AUTO_MODES:
1045
+ raise PolicyError(
1046
+ f"sweep.auto must be one of {sorted(SWEEP_AUTO_MODES)}: got {sweep.auto!r}"
1047
+ )
1048
+ if (
1049
+ min(
1050
+ sweep.max_bundles,
1051
+ sweep.max_triage_attempts,
1052
+ sweep.max_migration_attempts,
1053
+ sweep.max_cycles,
1054
+ )
1055
+ < 1
1056
+ ):
1057
+ raise PolicyError(
1058
+ "sweep.max_bundles, sweep.max_triage_attempts, "
1059
+ "sweep.max_migration_attempts and sweep.max_cycles must be >= 1"
1060
+ )
1061
+ requested_parallel = _typed_int(scm_d, "scm", "max_parallel", ScmPolicy.max_parallel)
1062
+ if requested_parallel < 1:
1063
+ raise PolicyError(f"scm.max_parallel must be >= 1: got {requested_parallel}")
1064
+ # This one was strict before its sibling int knobs were (a TOML `true`, with
1065
+ # int(True) == 1, or a `1.9` coercing through int() would silently shrink a
1066
+ # safety-net budget); `_typed_int` is that same guard, message included.
1067
+ preserve_keep = _typed_int(scm_d, "scm", "preserve_keep", ScmPolicy.preserve_keep)
1068
+ if preserve_keep < 0:
1069
+ raise PolicyError(f"scm.preserve_keep must be >= 0: got {preserve_keep}")
1070
+ # Shape before entries, because `tuple(str(s) for s in raw)` silently accepts
1071
+ # things that are not a list of paths. Measured: `worktree_seed = ""` yields an
1072
+ # empty tuple (the config reads as applied and seeds nothing), `= "foo"` yields
1073
+ # ('f','o','o') — three bogus one-character entries that each pass the
1074
+ # per-entry guard below — and `= 5` raises a bare TypeError out of `loads`,
1075
+ # untyped, where every other malformed value here raises PolicyError.
1076
+ raw_seed = scm_d.get("worktree_seed", ())
1077
+ if isinstance(raw_seed, (str, bytes)) or not isinstance(raw_seed, (list, tuple)):
1078
+ raise PolicyError(f"scm.worktree_seed must be a list of paths: got {raw_seed!r}")
1079
+ if not all(isinstance(s, str) for s in raw_seed):
1080
+ raise PolicyError(f"scm.worktree_seed entries must be strings: got {list(raw_seed)!r}")
1081
+ scm = ScmPolicy(
1082
+ isolation=_typed_str(scm_d, "scm", "isolation", ScmPolicy.isolation),
1083
+ branch_per=_typed_str(scm_d, "scm", "branch_per", ScmPolicy.branch_per),
1084
+ target_branch=_typed_str(scm_d, "scm", "target_branch", ScmPolicy.target_branch),
1085
+ merge_strategy=_typed_str(scm_d, "scm", "merge_strategy", ScmPolicy.merge_strategy),
1086
+ delete_branch=_typed_bool(scm_d, "scm", "delete_branch", ScmPolicy.delete_branch),
1087
+ keep_failed=_typed_bool(scm_d, "scm", "keep_failed", ScmPolicy.keep_failed),
1088
+ rollback_on_failure=_typed_bool(
1089
+ scm_d, "scm", "rollback_on_failure", ScmPolicy.rollback_on_failure
1090
+ ),
1091
+ preserve_keep=preserve_keep,
1092
+ failed_diff_max_mb=_typed_int(
1093
+ scm_d, "scm", "failed_diff_max_mb", ScmPolicy.failed_diff_max_mb
1094
+ ),
1095
+ failed_diff_unlimited=_typed_bool(
1096
+ scm_d, "scm", "failed_diff_unlimited", ScmPolicy.failed_diff_unlimited
1097
+ ),
1098
+ commit_message_template=_typed_str(
1099
+ scm_d, "scm", "commit_message_template", ScmPolicy.commit_message_template
1100
+ ),
1101
+ # Phase 5 parallel fan-out is unbuilt: clamp to 1 so the knob is inert.
1102
+ max_parallel=min(requested_parallel, 1),
1103
+ seed_adapter_defaults=_typed_bool(
1104
+ scm_d, "scm", "seed_adapter_defaults", ScmPolicy.seed_adapter_defaults
1105
+ ),
1106
+ worktree_seed=tuple(raw_seed),
1107
+ )
1108
+ if scm.isolation not in ISOLATION_MODES:
1109
+ raise PolicyError(
1110
+ f"scm.isolation must be one of {sorted(ISOLATION_MODES)}: got {scm.isolation!r}"
1111
+ )
1112
+ if scm.branch_per not in BRANCH_PER_MODES:
1113
+ raise PolicyError(
1114
+ f"scm.branch_per must be one of {sorted(BRANCH_PER_MODES)}: got {scm.branch_per!r}"
1115
+ )
1116
+ if scm.merge_strategy not in MERGE_STRATEGIES:
1117
+ raise PolicyError(
1118
+ f"scm.merge_strategy must be one of {sorted(MERGE_STRATEGIES)}: "
1119
+ f"got {scm.merge_strategy!r}"
1120
+ )
1121
+ if scm.failed_diff_max_mb < 1:
1122
+ raise PolicyError(f"scm.failed_diff_max_mb must be >= 1: got {scm.failed_diff_max_mb}")
1123
+ # The same rule the other two seed sources already apply to their own entries
1124
+ # (adapters/profile.py `seed_files`, plugins/manifest.py `_check_relative_paths`).
1125
+ # All three feed one list into provision_worktree's seed loop, and this was the
1126
+ # only one arriving unvalidated.
1127
+ #
1128
+ # A ROOT-NAMING entry is why this is a guard rather than a tidy-up: it makes that
1129
+ # loop resolve src to the repo ROOT and dst to the worktree, both of which pass
1130
+ # its `is_relative_to` containment checks — a path IS relative to itself — so it
1131
+ # copies the entire repo into the worktree, gitignored and untracked files
1132
+ # included. And because a worktree mounts UNDER the repo (.froid-loop/runs/...),
1133
+ # that copy walks into its own destination: measured at 744 levels of nesting
1134
+ # before the path length failed, silently, since the seed copy suppresses errors.
1135
+ #
1136
+ # `names_tree_root` and not a `not seed` emptiness check, because "" is only one
1137
+ # spelling of the root. Measured: "" and "." produce a byte-identical
1138
+ # (src, raw, dst) triple in that loop, and a run seeded with ["."] copied an
1139
+ # untracked secret.env in and self-recursed until the path length failed, with
1140
+ # provision_worktree returning no skip at all.
1141
+ #
1142
+ # The "/" it then renders is INERT, not a blanket exclusion — git strips a bare
1143
+ # slash to a zero-length pattern that matches nothing (worktree_flow.py says the
1144
+ # same at the write side). That is what makes this worth guarding rather than
1145
+ # shrugging at: none of the copied surplus is shielded, so the unit's
1146
+ # `git add -A` stages the main checkout's untracked files into the story.
1147
+ #
1148
+ # Absolute and `..` entries are already contained by those same checks (silently
1149
+ # skipped); they are rejected here for consistency with the sibling sources, and
1150
+ # because a silently-inert seed entry reads as applied configuration when it is
1151
+ # not.
1152
+ #
1153
+ # The second refusal is a SEPARATE arm, not a fourth term in the first, because
1154
+ # the first one's message is false for what it catches: `NUL` and `cfg. ` are
1155
+ # project-relative by every measure those three predicates apply. What they are
1156
+ # not is deterministic — each names a different path on Windows than it does
1157
+ # here, so the same seed entry copies a different file (or a device) depending
1158
+ # on where the run happens. `names_win32_alias`'s docstring carries the two
1159
+ # rules, their sources, and which half of each is measurable on this platform.
1160
+ for seed in scm.worktree_seed:
1161
+ if names_tree_root(seed) or is_absolute_path(seed) or has_parent_ref(seed):
1162
+ raise PolicyError(
1163
+ f"scm.worktree_seed entries must be project-relative paths: got {seed!r}"
1164
+ )
1165
+ if names_win32_alias(seed):
1166
+ raise PolicyError(
1167
+ "scm.worktree_seed entries must not name a Windows device or end a component "
1168
+ f"in a period or space: got {seed!r}"
1169
+ )
1170
+ cleanup = CleanupPolicy(
1171
+ run_retention=_typed_int(
1172
+ cleanup_d, "cleanup", "run_retention", CleanupPolicy.run_retention
1173
+ ),
1174
+ retention_days=_typed_int(
1175
+ cleanup_d, "cleanup", "retention_days", CleanupPolicy.retention_days
1176
+ ),
1177
+ trim_artifacts=_typed_bool(
1178
+ cleanup_d, "cleanup", "trim_artifacts", CleanupPolicy.trim_artifacts
1179
+ ),
1180
+ archive_old=_typed_bool(cleanup_d, "cleanup", "archive_old", CleanupPolicy.archive_old),
1181
+ auto_clean_on_finish=_typed_bool(
1182
+ cleanup_d, "cleanup", "auto_clean_on_finish", CleanupPolicy.auto_clean_on_finish
1183
+ ),
1184
+ clean_tmp=_typed_bool(cleanup_d, "cleanup", "clean_tmp", CleanupPolicy.clean_tmp),
1185
+ )
1186
+ if cleanup.run_retention < 0:
1187
+ raise PolicyError(f"cleanup.run_retention must be >= 0: got {cleanup.run_retention}")
1188
+ if cleanup.retention_days < 0:
1189
+ raise PolicyError(f"cleanup.retention_days must be >= 0: got {cleanup.retention_days}")
1190
+ raw_enabled = plugins_d.get("enabled", ())
1191
+ if isinstance(raw_enabled, str) or not isinstance(raw_enabled, (list, tuple)):
1192
+ raise PolicyError("plugins.enabled must be a list of plugin names")
1193
+ enabled: list[str] = []
1194
+ for n in raw_enabled:
1195
+ if not isinstance(n, str):
1196
+ raise PolicyError(f"plugins.enabled entries must be strings: got {n!r}")
1197
+ enabled.append(n)
1198
+ # Every key under [plugins] other than `enabled` that is a table is a
1199
+ # per-plugin settings sub-table ([plugins.<name>]).
1200
+ plugin_settings = {
1201
+ str(k): dict(v) for k, v in plugins_d.items() if k != "enabled" and isinstance(v, dict)
1202
+ }
1203
+ # The game-engine layer is now a plugin. Fold a deprecated [engine] block into
1204
+ # [plugins] (enable the named plugin + map its keys to [plugins.<name>]) so
1205
+ # existing Unity configs keep working for one release; explicit [plugins.*]
1206
+ # values win over the folded ones.
1207
+ _fold_deprecated_engine(engine_d, enabled, plugin_settings)
1208
+ if plugin_schemas:
1209
+ for name, raw_settings in plugin_settings.items():
1210
+ _validate_plugin_settings(name, raw_settings, plugin_schemas.get(name))
1211
+ plugins = PluginsPolicy(enabled=tuple(enabled), settings=plugin_settings)
1212
+ tui = TuiPolicy(
1213
+ low_frame_rate=_typed_bool(tui_d, "tui", "low_frame_rate", TuiPolicy.low_frame_rate),
1214
+ left_width=_tui_dim(tui_d, "left_width"),
1215
+ runs_height=_tui_dim(tui_d, "runs_height"),
1216
+ deferred_height=_tui_dim(tui_d, "deferred_height"),
1217
+ tasks_height=_tui_dim(tui_d, "tasks_height"),
1218
+ )
1219
+ operator = OperatorPolicy(
1220
+ enabled=_typed_bool(operator_d, "operator", "enabled", OperatorPolicy.enabled)
1221
+ )
1222
+ mux = MuxPolicy(backend=_typed_str(mux_d, "mux", "backend", MuxPolicy.backend).strip())
1223
+ if mux.backend and not _MUX_NAME_RE.match(mux.backend):
1224
+ raise PolicyError(
1225
+ f"mux.backend must be a backend name (letters, digits, . _ -): got {mux.backend!r}"
1226
+ )
1227
+ return Policy(
1228
+ gates=gates,
1229
+ limits=limits,
1230
+ verify=verify,
1231
+ notify=notify,
1232
+ review=review,
1233
+ stories=stories,
1234
+ dev=dev,
1235
+ adapter=adapter,
1236
+ sweep=sweep,
1237
+ scm=scm,
1238
+ cleanup=cleanup,
1239
+ plugins=plugins,
1240
+ tui=tui,
1241
+ operator=operator,
1242
+ mux=mux,
1243
+ )
1244
+
1245
+
1246
+ def _fold_deprecated_engine(
1247
+ engine_d: dict[str, Any], enabled: list[str], plugin_settings: dict[str, dict[str, Any]]
1248
+ ) -> None:
1249
+ """Translate a legacy ``[engine]`` block into the plugin surface in place.
1250
+
1251
+ ``[engine] name = "unity"`` becomes ``[plugins] enabled = ["unity"]`` plus a
1252
+ ``[plugins.unity]`` table carrying editor_mode/mcp/unity_path/ready_*; the
1253
+ editor_mode↔scm.isolation coupling is now validated by the plugin itself
1254
+ (``UnityPlugin.validate``), not here. A no-op when ``[engine]`` is absent or
1255
+ its ``name`` is empty (the old "disabled" state)."""
1256
+ if not engine_d:
1257
+ return
1258
+ warnings.warn(
1259
+ "[engine] in policy.toml is deprecated; the game-engine layer is now a "
1260
+ 'plugin. Use [plugins] enabled = ["unity"] with a [plugins.unity] table. '
1261
+ "[engine] will be removed in a future release.",
1262
+ DeprecationWarning,
1263
+ stacklevel=3,
1264
+ )
1265
+ name = _typed_str(engine_d, "engine", "name", "").strip()
1266
+ if not name:
1267
+ return
1268
+ if name not in enabled:
1269
+ enabled.append(name)
1270
+ folded = {k: engine_d[k] for k in _ENGINE_SETTING_KEYS if k in engine_d}
1271
+ # explicit [plugins.<name>] values take precedence over the folded [engine] ones
1272
+ plugin_settings[name] = {**folded, **plugin_settings.get(name, {})}
1273
+
1274
+
1275
+ POLICY_TEMPLATE = """\
1276
+ # froid-loop orchestration policy. All keys optional; defaults shown.
1277
+
1278
+ [gates]
1279
+ mode = "per-epic" # none | per-epic | per-story-spec-approval
1280
+ retrospective = "notify" # never | notify | auto (auto unsupported in v1)
1281
+
1282
+ [limits]
1283
+ max_review_cycles = 3
1284
+ max_dev_attempts = 2
1285
+ max_followup_reviews = 1 # additional review rounds granted solely because a finalized (status: done) round still recommended a follow-up; once spent, such a round converges + refiles the recommendation instead of burning another cycle. 0 = never honor a pass's own recommendation
1286
+ session_timeout_min = 90
1287
+ git_timeout_s = 120 # bound on any single git subprocess; exceeding it pauses/degrades (never crashes the run) — raise on a loaded host or a very large worktree
1288
+ teardown_grace_s = 20 # verified teardown: poll a killed session window up to this long, then force-kill its pane pids and re-kill (#157). 0 = single unverified best-effort kill
1289
+ stop_without_result_nudges = 1
1290
+ dev_stall_grace_s = 600 # silence grace armed at dev/review launch and re-armed by transport activity or fresh Stop/idle evidence before bounded recovery. 0 = no launch timer, but a result-less turn end still fails fast
1291
+ dev_stall_nudges = 2 # best-effort wake nudges per silent grace before stalling; fresh Stop/idle evidence restores this budget. 0 = stall on grace expiry
1292
+ dev_stall_nudges_cap = 6 # total (never-restored) nudge bound per dev/review session; bounds launch-time recovery and Stop/idle budget refills because an accepted nudge does not guarantee a wake. 0 = stall on first grace expiry
1293
+ workflow_stall_nudges_cap = 3 # total (never-restored) stall nudges for an injected plugin-workflow session before it is called stalled; bounds a session that finished its work but never wrote its completion marker. 0 = stall on first grace expiry
1294
+ dev_contract_nudge = true # true: one targeted nudge per session (#276) when a Stop finds a spec finalized to a terminal frontmatter status but missing its `## Auto Run Result` marker, asking the skill to append it and end its turn; sent exactly once, never refilled, touches no stall counters. false: rely only on harness-side frontmatter synthesis
1295
+ max_tokens_per_story = 2000000
1296
+ cache_read_weight = 0.1 # cache reads bill at ~0.1x input on all vendors; 1.0 = count raw
1297
+ session_budget_mode = "warn" # off | warn | enforce — weighted per-SESSION cap sampled every ~30s mid-session; warn = one ATTENTION + breadcrumb; enforce = wrap-up nudge (best-effort courtesy; the kill is the guarantee) then over_budget termination (retry→defer). Live-verified on claude; other transcript parsers sample best-effort (mid-turn flush unverified); inert where no mid-session usage signal exists (usage_parser "none", copilot's shutdown-only flush)
1298
+ max_tokens_per_session = 4000000 # weighted cap a single session may spend before the guard trips; healthy sessions run ~1-2.5M weighted, so the default trips only true runaways (#158)
1299
+ session_budget_grace_s = 240 # enforce mode: seconds a tripped session gets to wrap up after the nudge before over_budget termination. 0 = terminate at trip, no nudge
1300
+
1301
+ [verify]
1302
+ # Deterministic gates run by the orchestrator after a clean review, before commit.
1303
+ commands = [] # e.g. ["pytest -q", "ruff check ."]
1304
+ stream_capture_kb = 256 # per-stream cap (KiB) on the verifier stdout/stderr retained under the run's verify/ directory; the TAIL is kept and the journal records the full byte count plus a truncation flag. 0 = capture nothing (records still land, with null pointers)
1305
+
1306
+ [notify]
1307
+ desktop = true # notify-send (Linux) / osascript (macOS) / PowerShell toast (Windows), best-effort
1308
+ file = true # ATTENTION file in the run dir
1309
+
1310
+ [review]
1311
+ # enabled = true -> run a follow-up review session (the dev primitive re-invoked
1312
+ # on the done spec for a fresh review pass) after a dev pass.
1313
+ # enabled = false -> skip that session; the dev pass's own inline review
1314
+ # is the only review and it finalizes the story straight to done.
1315
+ enabled = true
1316
+ # trigger (only consulted when enabled = true) decides WHEN that session runs:
1317
+ # "recommended" -> only when the dev pass flags the story with
1318
+ # `followup_review_recommended: true` (it self-reviews inline
1319
+ # and flags this when its changes warrant an independent pass).
1320
+ # "always" -> run the second-opinion review on every story.
1321
+ # The loop is bounded by limits.max_review_cycles (hard cap) and damped by
1322
+ # limits.max_followup_reviews (a round that finalizes the story yet keeps
1323
+ # recommending a follow-up converges + refiles once the grant is spent) either way.
1324
+ trigger = "recommended"
1325
+ # What a timeout-like review verdict (timeout/stalled/over_budget) costs:
1326
+ # "retry" -> burn a review cycle per timeout until max_review_cycles (default).
1327
+ # "salvage-if-done" -> if the dev product is already finalized and verify passes,
1328
+ # commit it and refile the outstanding follow-up to deferred work.
1329
+ # "defer" -> give up on the first timeout-like verdict.
1330
+ on_timeout = "retry"
1331
+ # What a review that writes sprint-status back off `done` (the sign-off the
1332
+ # orchestrator recorded at dev time) costs. Nothing in the review loop
1333
+ # re-advances the board, so every remaining cycle re-reads the same failure:
1334
+ # "escalate" -> pause the run naming both sides of the disagreement (default).
1335
+ # "retry" -> legacy: burn review cycles to max_review_cycles, then defer.
1336
+ on_status_contradiction = "escalate"
1337
+
1338
+ [stories]
1339
+ # Story-queue source. "sprint-status" (default) walks sprint-status.yaml written
1340
+ # by froid-sprint-planning. "stories" opts into folder+id dispatch: the loop reads
1341
+ # a typed stories.yaml (Story Breakdown output, sibling of SPEC.md) and dispatches
1342
+ # each entry by spec-folder + story id. `froid-loop run --spec <folder>` forces
1343
+ # stories mode for a single run regardless of this setting.
1344
+ source = "sprint-status" # sprint-status | stories
1345
+ # Required (and must parse) when source = "stories": the project-relative path to
1346
+ # the epic's spec folder holding stories.yaml + SPEC.md. Ignored under sprint-status.
1347
+ spec_folder = ""
1348
+
1349
+ [adapter]
1350
+ name = "claude" # claude | codex | gemini | copilot | antigravity | opencode-http (alias: opencode) | <custom .froid-loop/profiles/*.toml>
1351
+ model = "" # empty = CLI default model (opencode-http wants "provider/model")
1352
+ cleanup_session_on_finish = true # kill the run's tmux session when it finishes (false keeps it for inspection)
1353
+ # extra_args replaces the profile's default permission-bypass flags when set:
1354
+ # extra_args = ["--permission-mode", "bypassPermissions"]
1355
+ # Optional overrides of the CLI profile's own defaults (unset = inherit the
1356
+ # profile's shipped value; copilot ships usage_grace_s = 8 and
1357
+ # stop_without_result_nudges = 5):
1358
+ # usage_grace_s = 8.0 # seconds to poll the transcript for token usage after a session ends
1359
+ # stop_without_result_nudges = 5 # result-less Stop signals tolerated before a session is called stalled
1360
+
1361
+ # Per-stage overrides for the dev, review and sweep-triage passes. Unset keys
1362
+ # inherit from [adapter] when the stage runs the same client; a stage that
1363
+ # switches client falls back to that profile's defaults instead (model and
1364
+ # extra_args are client-specific). Stage tables must come after the [adapter]
1365
+ # keys above.
1366
+ # [adapter.dev]
1367
+ # model = "opus"
1368
+ # [adapter.review]
1369
+ # name = "codex"
1370
+ # model = "gpt-5-codex"
1371
+ # stop_without_result_nudges = 5 # e.g. a multi-turn review needs more nudges than dev
1372
+ # [adapter.triage]
1373
+ # model = "opus"
1374
+
1375
+ [sweep]
1376
+ # Deferred-work sweep: triage + execute open deferred-work.md entries.
1377
+ auto = "never" # never | per-epic | run-end (auto-triggered sweeps never prompt)
1378
+ max_bundles = 5 # bundles executed per sweep; triage excess is truncated
1379
+ max_triage_attempts = 2 # triage validation retries before escalating
1380
+ max_migration_attempts = 2 # legacy-ledger migration retries before escalating
1381
+ repeat = false # after a cycle completes, re-triage and continue on newly deferred work
1382
+ max_cycles = 5 # safety cap on total cycles per sweep run when repeat = true
1383
+
1384
+ [cleanup]
1385
+ # Disk reclamation for .froid-loop/runs. Only terminal (finished/stopped) runs are
1386
+ # ever touched; paused/interrupted runs stay intact so they remain resumable.
1387
+ # `froid-loop clean` applies these, and every run/sweep start reconciles worktrees
1388
+ # leaked by a mid-flight stop.
1389
+ run_retention = 10 # newest concluded runs kept whole; older ones are trimmed/archived (0 keeps none)
1390
+ retention_days = 0 # 0 = disabled; else also keep runs newer than N days regardless of count
1391
+ trim_artifacts = true # drop the heavy worktrees/ tree from concluded runs (run stays viewable in the TUI)
1392
+ archive_old = true # archive (.froid-loop/archive/<id>.tar.gz) rather than hard-delete runs past the window
1393
+ auto_clean_on_finish = true # reconcile stale worktrees + apply retention when a run finishes cleanly
1394
+ clean_tmp = true # let engine plugins clean their /tmp scratch on finish (e.g. Unity MCP server zips)
1395
+
1396
+ [scm]
1397
+ # Source-control isolation + merge-back. Defaults reproduce today's behavior:
1398
+ # work happens in place on the checked-out branch, with no branches.
1399
+ isolation = "none" # none | worktree
1400
+ branch_per = "story" # story | run (worktree mode only; "run" forces delete_branch = false)
1401
+ target_branch = "" # "" = the branch checked out at run start
1402
+ merge_strategy = "merge" # ff | merge | squash (worktree mode merges the unit branch into target locally)
1403
+ delete_branch = true # delete the unit branch after a successful merge
1404
+ keep_failed = true # keep a failed unit's worktree+branch for inspection
1405
+ rollback_on_failure = false # in-place (isolation="none") recovery after a failed attempt. false = never touch the tree; pause with manual recovery steps. true = auto-revert the attempt's tracked changes + remove only the untracked files this run created (WARNING: discards the attempt's uncommitted work; never a blanket git clean). Governs unattended/stopped attempts only: a resolved escalation's re-drive always auto-recovers regardless (reverts the failed source, keeps the corrected spec). Prefer isolation="worktree" to avoid touching your main checkout.
1406
+ preserve_keep = 20 # attempt-preserve/* branches and attempt-preserve-dirty/* snapshots kept at run start (per family), newest by committer date; the tail is deleted (0 = never prune)
1407
+ failed_diff_max_mb = 5 # per-file size cap (MB) for untracked files in a kept-failed unit's changes.patch; oversized files are skipped with a marker
1408
+ failed_diff_unlimited = false # true = capture the failed-unit diff with no size cap (may produce very large patches; warns when active)
1409
+ # commit_message_template: when set, the commit message dev sessions use for a
1410
+ # story's commit. {story_key}, {run_id} and {story_title} (the spec's `title:`
1411
+ # frontmatter, else a first `#` heading, minus any "Story <id>:" label; falls back to the
1412
+ # key) are substituted.
1413
+ # Empty = built-in default.
1414
+ commit_message_template = ""
1415
+ max_parallel = 1 # units in flight at once (parallel fan-out unbuilt; values > 1 clamp to 1)
1416
+ # A git worktree checks out tracked files only, so gitignored MCP/CLI configs are
1417
+ # absent from every fresh worktree and isolated sessions can't reach their MCP
1418
+ # server. seed_adapter_defaults copies each loaded adapter's own config files
1419
+ # (claude -> .mcp.json/.claude/settings.json, codex -> .codex/config.toml, etc.)
1420
+ # into the worktree; worktree_seed adds extra project-specific gitignored paths.
1421
+ seed_adapter_defaults = true # seed each loaded adapter's default gitignored configs into worktrees
1422
+ worktree_seed = [] # extra gitignored files to copy into each worktree, on top of adapter defaults
1423
+
1424
+ [plugins]
1425
+ # Plugin trust allowlist. A plugin dropped under .froid-loop/plugins/<name>/ loads
1426
+ # its declarative manifest (settings + out-of-process shell hooks) automatically.
1427
+ # A plugin that ships an in-process [python] module is NEVER imported or run
1428
+ # unless its name is listed here. Empty = no plugins trusted (today's behavior).
1429
+ enabled = [] # e.g. ["unity", "my-lint-plugin"]
1430
+
1431
+ # The game-engine layer is a plugin. For a Unity project whose dev/sweep cycle
1432
+ # drives a live Editor via an Editor MCP, enable it above and configure it here:
1433
+ # [plugins.unity]
1434
+ # editor_mode = "shared" # shared (live Editor; requires scm.isolation = "none")
1435
+ # # | per_worktree (one Editor per worktree; requires
1436
+ # # scm.isolation = "worktree")
1437
+ # mcp = "ivanmurzak" # which Editor MCP the scripts target: ivanmurzak | coplaydev
1438
+ # unity_path = "" # Editor binary for a per_worktree launch ("" = auto-detect)
1439
+ # ready_timeout_sec = 600 # how long the readiness gate waits for the Editor + MCP
1440
+ # ready_grace_sec = -1 # delay before the first probe (-1 = auto: per_worktree waits)
1441
+ # (The legacy [engine] block still loads with a deprecation warning, folded into
1442
+ # [plugins.unity] — migrate to [plugins] when convenient.)
1443
+
1444
+ [tui]
1445
+ # low_frame_rate = true caps Textual to 15fps and disables animations (sets
1446
+ # TEXTUAL_FPS=15 / TEXTUAL_ANIMATIONS=none at launch). Fixes repaint tearing
1447
+ # over slow/high-latency links (SSH, Tailscale). Equivalent to launching with
1448
+ # `froid-loop tui --low-frame-rate`. Takes effect the next time the TUI starts.
1449
+ low_frame_rate = false
1450
+ # Persisted dashboard pane sizes, in terminal cells. 0 = unset (use the built-in
1451
+ # default proportions). The TUI writes these when you resize a pane by mouse-drag
1452
+ # or the Ctrl+W resize mode; they are re-applied on the next launch. Usually you
1453
+ # won't hand-edit these — resize in the TUI instead.
1454
+ # left_width = 0 # sidebar width, columns
1455
+ # runs_height = 0 # Runs pane height, rows
1456
+ # deferred_height = 0 # Deferred pane height, rows
1457
+ # tasks_height = 0 # Tasks table height, rows
1458
+
1459
+ [operator]
1460
+ # Let a dev session park a story at `awaiting-operator`: its agent-doable work is
1461
+ # finished and COMMITTED, but its acceptance criteria include external actions
1462
+ # only a human can perform (buy a domain, publish a DNS record, grant an API
1463
+ # key). The story commits, the run moves on to the next one, and what is owed is
1464
+ # recorded in the story spec's `operator_actions:` frontmatter. Complete such a
1465
+ # story with `froid-loop confirm <story-key>` once you have done those actions.
1466
+ # Turn this off to hold sessions to the two older outcomes (done / blocked).
1467
+ enabled = true
1468
+
1469
+ [mux]
1470
+ # Terminal-multiplexer backend for this machine (the transport axis — which
1471
+ # tmux-like program hosts sessions; independent of [adapter], the coding-CLI
1472
+ # axis). Machine-specific: `froid-loop init` gitignores policy.toml, so this
1473
+ # choice never travels to teammates on other machines or OSes.
1474
+ # Unset = auto-select: the FROID_LOOP_MUX_BACKEND env var wins, then this key,
1475
+ # then the platform default (win32: psmux, elsewhere: tmux) when installed,
1476
+ # then the first registered backend that matches this platform and is
1477
+ # available. Naming a backend that is not registered fails loudly at launch.
1478
+ # `froid-loop mux` lists backends and shows the selection; `froid-loop mux set
1479
+ # <name>` writes this key. Takes effect on the next froid-loop invocation.
1480
+ # backend = "tmux"
1481
+ """
1482
+
1483
+
1484
+ def write_mux_backend(path: Path, name: str | None, *, confine_root: Path) -> None:
1485
+ """Persist (``name``) or clear (``None``) the ``[mux] backend`` key in the
1486
+ policy file at ``path``, preserving every other byte — devs hand-edit
1487
+ policy.toml, and the core install has no comment-preserving TOML writer
1488
+ (tomlkit ships only with the [tui] extra). A missing file is created from
1489
+ :data:`POLICY_TEMPLATE` so the written file keeps the full documentation.
1490
+
1491
+ The template anchors the key as a single ``# backend = "tmux"`` line under
1492
+ ``[mux]`` with all prose comments *above* it, so the rewrite is a targeted
1493
+ line replace: the first (possibly commented) ``backend =`` line inside
1494
+ ``[mux]`` is swapped for the new value, or re-commented on clear. A file
1495
+ predating the ``[mux]`` table gets the table appended at EOF (TOML tables
1496
+ are order-free, so appending is always safe).
1497
+
1498
+ ``confine_root`` is a REQUIRED keyword (#593). This function is handed a
1499
+ ``path`` and has no project of its own to derive a root from, so requiring
1500
+ the tree the policy file belongs to makes a caller that has not decided one
1501
+ a type error rather than a write that resolves ``.froid-loop/`` by name."""
1502
+ if name is not None and not _MUX_NAME_RE.match(name):
1503
+ raise PolicyError(
1504
+ f"mux.backend must be a backend name (letters, digits, . _ -): got {name!r}"
1505
+ )
1506
+ # bytes in / bytes out: text mode would translate a CRLF file's endings.
1507
+ text = path.read_bytes().decode("utf-8") if path.is_file() else POLICY_TEMPLATE
1508
+ new_line = f'backend = "{name}"' if name is not None else '# backend = "tmux"'
1509
+
1510
+ section = ""
1511
+ replaced = False
1512
+ mux_header_at: int | None = None
1513
+ out: list[str] = []
1514
+ for line in text.splitlines(keepends=True):
1515
+ header = _TOML_SECTION_RE.match(line)
1516
+ if header:
1517
+ section = header.group("name").strip()
1518
+ out.append(line)
1519
+ if section == "mux" and mux_header_at is None:
1520
+ mux_header_at = len(out) - 1
1521
+ continue
1522
+ if not replaced and section == "mux" and _MUX_KEY_RE.match(line):
1523
+ stripped = line.rstrip("\r\n")
1524
+ ending = line[len(stripped) :] or "\n"
1525
+ # backend names never contain '#' (_MUX_NAME_RE), so any '#' after
1526
+ # '=' on this line is a hand-added trailing comment worth keeping.
1527
+ hash_idx = stripped.find("#", stripped.index("="))
1528
+ trailing = (" " + stripped[hash_idx:]) if hash_idx != -1 else ""
1529
+ out.append(new_line + trailing + ending)
1530
+ replaced = True
1531
+ continue
1532
+ out.append(line)
1533
+ if not replaced:
1534
+ if mux_header_at is not None: # [mux] table present but the key line was deleted
1535
+ out.insert(mux_header_at + 1, new_line + "\n")
1536
+ else: # policy file predating the [mux] table
1537
+ if out and not out[-1].endswith("\n"):
1538
+ out.append("\n")
1539
+ out.append(f"\n[mux]\n{new_line}\n")
1540
+ result = "".join(out)
1541
+
1542
+ # Round-trip guard: never write a file this module can't read back to the
1543
+ # intended value (catches an anchor/regex drift before it corrupts config).
1544
+ parsed = loads(result)
1545
+ if parsed.mux.backend != (name or ""):
1546
+ raise PolicyError(
1547
+ f"internal error: rewriting {path} would read back "
1548
+ f"mux.backend = {parsed.mux.backend!r}, expected {(name or '')!r}"
1549
+ )
1550
+
1551
+ path.parent.mkdir(parents=True, exist_ok=True)
1552
+ # #363: the helper removes its temp on any raise — the hand-rolled tmp here was
1553
+ # the fixed name `.froid-loop/policy.toml.tmp`, which nothing gitignores (the
1554
+ # ignore line is the anchored literal `.froid-loop/policy.toml`) and which
1555
+ # `tui.settings.PolicyDoc.save` built identically, so the two could collide.
1556
+ # The BYTES helper, not the text one: this function reads bytes and writes bytes
1557
+ # on purpose (see the decode above) so a CRLF policy.toml keeps its endings.
1558
+ # Confined, and the BYTES arm of it: replacing the name (as the bare replace
1559
+ # did) clobbers a link planted at policy.toml, which `runsetup` says a driven
1560
+ # session can write — but it left every directory above resolved by name, so a
1561
+ # link at `.froid-loop/` aimed this write out of the project entirely (#593).
1562
+ # require_writable_target restores the PermissionError this raised on an
1563
+ # operator's read-only policy.toml before the write went atomic (#597).
1564
+ atomic_write_bytes_confined(
1565
+ path,
1566
+ result.encode("utf-8"),
1567
+ confine_root=confine_root,
1568
+ require_writable_target=True,
1569
+ )