froid-loop 0.11.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. froid_loop/__init__.py +11 -0
  2. froid_loop/__main__.py +12 -0
  3. froid_loop/adapters/__init__.py +3 -0
  4. froid_loop/adapters/base.py +254 -0
  5. froid_loop/adapters/entrypoints.py +63 -0
  6. froid_loop/adapters/env_fault.py +290 -0
  7. froid_loop/adapters/generic.py +2013 -0
  8. froid_loop/adapters/mock.py +49 -0
  9. froid_loop/adapters/multiplexer.py +914 -0
  10. froid_loop/adapters/opencode_http.py +1687 -0
  11. froid_loop/adapters/profile.py +650 -0
  12. froid_loop/adapters/psmux_backend.py +1428 -0
  13. froid_loop/adapters/registry.py +322 -0
  14. froid_loop/adapters/tmux_backend.py +35 -0
  15. froid_loop/adapters/tmux_base.py +630 -0
  16. froid_loop/checks.py +187 -0
  17. froid_loop/cli.py +5041 -0
  18. froid_loop/data/__init__.py +0 -0
  19. froid_loop/data/froid_loop_hook.py +228 -0
  20. froid_loop/data/froid_loop_probe_hook.py +88 -0
  21. froid_loop/data/plugins/example/plugin.toml +21 -0
  22. froid_loop/data/plugins/tea/plugin.toml +184 -0
  23. froid_loop/data/plugins/tea/tea_plugin.py +258 -0
  24. froid_loop/data/plugins/unity/plugin.toml +140 -0
  25. froid_loop/data/plugins/unity/unity_assets/FroidLoop.Unity.Editor.asmdef +16 -0
  26. froid_loop/data/plugins/unity/unity_assets/FroidLoop.Unity.Editor.asmdef.meta +7 -0
  27. froid_loop/data/plugins/unity/unity_assets/SceneAutoSaveGuard.cs +221 -0
  28. froid_loop/data/plugins/unity/unity_assets/SceneAutoSaveGuard.cs.meta +11 -0
  29. froid_loop/data/plugins/unity/unity_assets/_folders/Editor.meta +8 -0
  30. froid_loop/data/plugins/unity/unity_assets/_folders/FroidLoop.meta +8 -0
  31. froid_loop/data/plugins/unity/unity_cleanup.py +125 -0
  32. froid_loop/data/plugins/unity/unity_dialog_probe.py +239 -0
  33. froid_loop/data/plugins/unity/unity_facts.md +17 -0
  34. froid_loop/data/plugins/unity/unity_plugin.py +415 -0
  35. froid_loop/data/plugins/unity/unity_quiesce.py +234 -0
  36. froid_loop/data/plugins/unity/unity_ready.py +230 -0
  37. froid_loop/data/plugins/unity/unity_seed_assets.py +298 -0
  38. froid_loop/data/plugins/unity/unity_setup.py +551 -0
  39. froid_loop/data/plugins/unity/unity_teardown.py +362 -0
  40. froid_loop/data/profiles/antigravity.toml +52 -0
  41. froid_loop/data/profiles/claude.toml +85 -0
  42. froid_loop/data/profiles/codex.toml +22 -0
  43. froid_loop/data/profiles/copilot.toml +52 -0
  44. froid_loop/data/profiles/gemini.toml +26 -0
  45. froid_loop/data/profiles/opencode.toml +54 -0
  46. froid_loop/data/settings/core.toml +458 -0
  47. froid_loop/data/skills/README.md +93 -0
  48. froid_loop/data/skills/froid-loop-resolve/SKILL.md +288 -0
  49. froid_loop/data/skills/froid-loop-setup/SKILL.md +161 -0
  50. froid_loop/data/skills/froid-loop-setup/assets/module-help.csv +3 -0
  51. froid_loop/data/skills/froid-loop-setup/assets/module.yaml +19 -0
  52. froid_loop/data/skills/froid-loop-sweep/SKILL.md +100 -0
  53. froid_loop/data/skills/froid-loop-sweep/automation-mode.md +127 -0
  54. froid_loop/data/skills/froid-loop-sweep/deferred-work-format.md +302 -0
  55. froid_loop/data/skills/froid-loop-sweep/migration-mode.md +86 -0
  56. froid_loop/decisions.py +202 -0
  57. froid_loop/deferredwork.py +2282 -0
  58. froid_loop/devcontract.py +892 -0
  59. froid_loop/diagnostics.py +1104 -0
  60. froid_loop/documents.py +532 -0
  61. froid_loop/engine.py +7732 -0
  62. froid_loop/envvars.py +111 -0
  63. froid_loop/escalation.py +225 -0
  64. froid_loop/events.py +266 -0
  65. froid_loop/fences.py +103 -0
  66. froid_loop/froidconfig.py +226 -0
  67. froid_loop/frontmatter.py +526 -0
  68. froid_loop/gates.py +133 -0
  69. froid_loop/install.py +2936 -0
  70. froid_loop/journal.py +178 -0
  71. froid_loop/machine.py +148 -0
  72. froid_loop/model.py +898 -0
  73. froid_loop/operatoractions.py +474 -0
  74. froid_loop/platform_util.py +1490 -0
  75. froid_loop/plugins/__init__.py +64 -0
  76. froid_loop/plugins/bus.py +259 -0
  77. froid_loop/plugins/context.py +319 -0
  78. froid_loop/plugins/loader.py +145 -0
  79. froid_loop/plugins/manifest.py +279 -0
  80. froid_loop/plugins/model.py +296 -0
  81. froid_loop/plugins/registry.py +245 -0
  82. froid_loop/plugins/trust.py +75 -0
  83. froid_loop/policy.py +1569 -0
  84. froid_loop/probe.py +1044 -0
  85. froid_loop/process_host.py +408 -0
  86. froid_loop/recovery_flow.py +1561 -0
  87. froid_loop/resolve.py +283 -0
  88. froid_loop/runs.py +4715 -0
  89. froid_loop/runsetup.py +1293 -0
  90. froid_loop/sanitize.py +593 -0
  91. froid_loop/settings_schema.py +276 -0
  92. froid_loop/signals.py +160 -0
  93. froid_loop/sprintstatus.py +609 -0
  94. froid_loop/statemachine.py +57 -0
  95. froid_loop/stories.py +615 -0
  96. froid_loop/stories_engine.py +796 -0
  97. froid_loop/sweep.py +1892 -0
  98. froid_loop/tokens.py +196 -0
  99. froid_loop/tui/__init__.py +11 -0
  100. froid_loop/tui/app.py +1584 -0
  101. froid_loop/tui/data.py +840 -0
  102. froid_loop/tui/launch.py +1003 -0
  103. froid_loop/tui/screens/__init__.py +1 -0
  104. froid_loop/tui/screens/dashboard.py +1071 -0
  105. froid_loop/tui/screens/modals.py +943 -0
  106. froid_loop/tui/screens/settings_screen.py +477 -0
  107. froid_loop/tui/settings.py +135 -0
  108. froid_loop/tui/widgets.py +981 -0
  109. froid_loop/verify.py +4545 -0
  110. froid_loop/workspace.py +320 -0
  111. froid_loop/worktree_flow.py +2301 -0
  112. froid_loop-0.11.1.dist-info/METADATA +728 -0
  113. froid_loop-0.11.1.dist-info/RECORD +116 -0
  114. froid_loop-0.11.1.dist-info/WHEEL +4 -0
  115. froid_loop-0.11.1.dist-info/entry_points.txt +2 -0
  116. froid_loop-0.11.1.dist-info/licenses/LICENSE +30 -0
@@ -0,0 +1,477 @@
1
+ """Settings editor for .froid-loop/policy.toml.
2
+
3
+ The form is populated from the raw tomlkit document, so unset keys show
4
+ default placeholders instead of baked-in values, and saving only touches keys
5
+ the user actually changed — tomlkit keeps everything else, comments included,
6
+ byte-identical. Validation is policy.loads() itself via PolicyDoc.validate(),
7
+ plus — on save — each enabled in-process plugin's coupling check (e.g. unity
8
+ editor_mode↔scm.isolation), the same check the engine runs at startup; errors
9
+ block the save and land in the strip above the buttons. A dedicated Plugins
10
+ section is a roster of discovered plugins with enable toggles (their name in
11
+ [plugins] enabled); a plugin's settings appear as a sub-collapsible only once it
12
+ is enabled. Cleared fields
13
+ delete their keys (unset = default / inherit); the adapter extra_args fields
14
+ distinguish "use profile defaults" (override off → key absent) from "replace
15
+ with this list" (override on, possibly empty — None ≠ []).
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import shlex
21
+ from pathlib import Path
22
+
23
+ from rich.text import Text
24
+ from textual import events
25
+ from textual.app import ComposeResult
26
+ from textual.binding import Binding
27
+ from textual.containers import Horizontal, VerticalScroll
28
+ from textual.screen import Screen
29
+ from textual.validation import Number
30
+ from textual.widget import Widget
31
+ from textual.widgets import (
32
+ Button,
33
+ Collapsible,
34
+ Footer,
35
+ Header,
36
+ Input,
37
+ Label,
38
+ Select,
39
+ Static,
40
+ Switch,
41
+ TextArea,
42
+ )
43
+
44
+ from ... import policy as policy_mod
45
+ from ...policy import POLICY_FILE
46
+ from ...settings_schema import PluginInfo, SettingSpec, build_registry
47
+ from ..settings import PolicyDoc
48
+
49
+ # collect() sentinel for a field whose widget holds an unusable value
50
+ _INVALID = object()
51
+
52
+
53
+ class SettingsScreen(Screen[None]):
54
+ DEFAULT_CSS = """
55
+ SettingsScreen #form {
56
+ padding: 0 2;
57
+ }
58
+ SettingsScreen #note {
59
+ padding: 1 1 0 1;
60
+ color: $text-muted;
61
+ }
62
+ SettingsScreen .field {
63
+ height: auto;
64
+ }
65
+ SettingsScreen .fieldname {
66
+ width: 30;
67
+ padding: 1 1;
68
+ color: $text-muted;
69
+ }
70
+ SettingsScreen .fielddesc {
71
+ padding: 0 1 1 32;
72
+ color: $text-muted;
73
+ text-style: italic;
74
+ }
75
+ SettingsScreen .field Input, SettingsScreen .field Select {
76
+ width: 1fr;
77
+ }
78
+ SettingsScreen .field Switch {
79
+ margin: 0 1;
80
+ }
81
+ SettingsScreen .field TextArea {
82
+ width: 1fr;
83
+ height: 5;
84
+ }
85
+ SettingsScreen .field TextArea.-editing {
86
+ border: tall $accent;
87
+ }
88
+ SettingsScreen .plugin-row {
89
+ height: auto;
90
+ }
91
+ SettingsScreen .plugin-row Switch {
92
+ margin: 0 1;
93
+ }
94
+ SettingsScreen .plugin-always {
95
+ width: 8;
96
+ padding: 1 1;
97
+ color: $text-muted;
98
+ }
99
+ SettingsScreen .plugin-name {
100
+ padding: 1 1;
101
+ }
102
+ SettingsScreen .plugin-divider {
103
+ padding: 1 1 0 1;
104
+ color: $text-muted;
105
+ text-style: italic;
106
+ }
107
+ SettingsScreen .plugin-cfg {
108
+ margin-left: 2;
109
+ }
110
+ SettingsScreen #errors {
111
+ display: none;
112
+ margin-top: 1;
113
+ padding: 0 1;
114
+ background: $error 20%;
115
+ }
116
+ SettingsScreen #errors.-visible {
117
+ display: block;
118
+ }
119
+ SettingsScreen .buttons {
120
+ height: auto;
121
+ align-horizontal: right;
122
+ padding: 1;
123
+ }
124
+ SettingsScreen .buttons Button {
125
+ margin-left: 2;
126
+ }
127
+ """
128
+
129
+ BINDINGS = [
130
+ Binding("escape", "back", "back"),
131
+ Binding("ctrl+s", "save", "save"),
132
+ Binding("ctrl+e", "toggle_all", "expand/collapse all"),
133
+ Binding("up", "nav_prev", "prev", show=False, priority=True),
134
+ Binding("down", "nav_next", "next", show=False, priority=True),
135
+ Binding("enter", "edit_field", "edit", show=False, priority=True),
136
+ ]
137
+
138
+ def __init__(self, project: Path, doc: PolicyDoc):
139
+ super().__init__()
140
+ self._project = project
141
+ self._path = project / POLICY_FILE
142
+ self._doc = doc
143
+ # The field/section list is generated from the TOML schema registry, not
144
+ # hardcoded: core sections plus a PluginInfo for every discovered plugin
145
+ # (its enable state, whether it is trust-gated, and its settings fields).
146
+ # Enable state comes from the policy under edit; an invalid doc still
147
+ # renders the core schema (on_mount then surfaces the validation error).
148
+ try:
149
+ pol = policy_mod.loads(doc.dumps())
150
+ except policy_mod.PolicyError:
151
+ pol = policy_mod.Policy()
152
+ self._registry = build_registry(project, pol)
153
+ # The TextArea currently in cursor-edit mode (None = navigation mode).
154
+ self._editing: Widget | None = None
155
+
156
+ # ------------------------------------------------------------- compose
157
+
158
+ def compose(self) -> ComposeResult:
159
+ yield Header()
160
+ with VerticalScroll(id="form"):
161
+ yield Static(
162
+ Text(
163
+ f"{self._path}\nrunning engines snapshot policy at start — "
164
+ "changes apply to new runs and resumes",
165
+ style="dim",
166
+ ),
167
+ id="note",
168
+ )
169
+ for section in self._registry.sections:
170
+ with Collapsible(title=section.title, collapsed=True):
171
+ for spec in section.fields:
172
+ yield from self._compose_field(spec)
173
+ if self._registry.plugins:
174
+ with Collapsible(title="Plugins", collapsed=True, id="plugins"):
175
+ yield from self._compose_plugins()
176
+ yield Static(id="errors")
177
+ with Horizontal(classes="buttons"):
178
+ yield Button("save", variant="primary", id="save")
179
+ yield Button("cancel", id="cancel")
180
+ yield Footer()
181
+
182
+ def _compose_field(self, spec: SettingSpec) -> ComposeResult:
183
+ raw = self._doc.get(spec.section, spec.key)
184
+ with Horizontal(classes="field"):
185
+ yield Label(spec.label or spec.key, classes="fieldname")
186
+ if spec.kind == "select":
187
+ yield Select(
188
+ [(o, o) for o in spec.options],
189
+ prompt=f"default: {spec.default or 'auto'}",
190
+ value=str(raw) if raw in spec.options else Select.NULL,
191
+ id=spec.widget_id,
192
+ )
193
+ elif spec.kind in ("int", "float"):
194
+ yield Input(
195
+ value=None if raw is None else str(raw),
196
+ placeholder=spec.placeholder or f"default: {spec.default}",
197
+ type="integer" if spec.kind == "int" else "number",
198
+ validators=[Number(minimum=spec.minimum, maximum=spec.maximum)],
199
+ valid_empty=True,
200
+ id=spec.widget_id,
201
+ )
202
+ elif spec.kind == "str":
203
+ yield Input(
204
+ value=None if raw is None else str(raw),
205
+ placeholder=spec.placeholder,
206
+ id=spec.widget_id,
207
+ )
208
+ elif spec.kind == "switch":
209
+ yield Switch(
210
+ value=bool(spec.default) if raw is None else bool(raw),
211
+ id=spec.widget_id,
212
+ )
213
+ elif spec.kind == "lines":
214
+ yield TextArea(
215
+ "\n".join(str(c) for c in raw) if raw else "",
216
+ soft_wrap=False,
217
+ id=spec.widget_id,
218
+ )
219
+ elif spec.kind == "args":
220
+ override = raw is not None
221
+ yield Switch(value=override, id=f"{spec.widget_id}-override", classes="argswitch")
222
+ yield Input(
223
+ value=shlex.join(str(a) for a in raw) if override else None,
224
+ placeholder="profile default flags — toggle to override",
225
+ disabled=not override,
226
+ id=spec.widget_id,
227
+ )
228
+ if spec.description:
229
+ yield Static(spec.description, classes="fielddesc")
230
+
231
+ def _compose_plugins(self) -> ComposeResult:
232
+ """The Plugins section: a scannable roster of every discovered plugin with
233
+ an enable toggle (trust-gated) or an always-on note (data-only), then —
234
+ below the roster — a settings sub-collapsible per plugin that contributes
235
+ settings. A plugin's settings are revealed only while it is active (data-
236
+ only always; trust-gated once enabled); inactive ones are hidden by
237
+ on_mount and toggled live by on_switch_changed, so the roster stays clean."""
238
+ for info in self._registry.plugins:
239
+ with Horizontal(classes="plugin-row"):
240
+ if info.trust_needed:
241
+ yield Switch(
242
+ value=info.enabled,
243
+ id=f"plugin-enabled-{info.name}",
244
+ classes="plugin-enable",
245
+ )
246
+ else:
247
+ yield Static("on", classes="plugin-always")
248
+ label = f"{info.name} — {info.description}" if info.description else info.name
249
+ yield Static(label, classes="plugin-name")
250
+ configurable = [p for p in self._registry.plugins if p.fields]
251
+ if configurable:
252
+ yield Static("configure", classes="plugin-divider")
253
+ for info in configurable:
254
+ title = f"{info.name} — {info.description}" if info.description else info.name
255
+ with Collapsible(
256
+ title=title, collapsed=True, id=f"plugin-cfg-{info.name}", classes="plugin-cfg"
257
+ ):
258
+ for spec in info.fields:
259
+ yield from self._compose_field(spec)
260
+
261
+ def _plugin_active(self, info: PluginInfo) -> bool:
262
+ """A data-only plugin is always active; a trust-gated one is active only
263
+ while its enable toggle is on (its module is what trust gates)."""
264
+ if not info.trust_needed:
265
+ return True
266
+ return self.query_one(f"#plugin-enabled-{info.name}", Switch).value
267
+
268
+ def on_mount(self) -> None:
269
+ # Hide the settings of every plugin that isn't active yet, so a fresh open
270
+ # shows only the roster plus the active plugins' settings.
271
+ for info in self._registry.plugins:
272
+ if info.fields and not self._plugin_active(info):
273
+ self.query_one(f"#plugin-cfg-{info.name}", Collapsible).display = False
274
+ self._show_errors(self._doc.validate(self._registry.plugin_schemas))
275
+
276
+ # ------------------------------------------------------------ keyboard
277
+
278
+ def check_action(self, action: str, parameters: tuple[object, ...]) -> bool | None:
279
+ # While editing a TextArea, let its own up/down move the cursor instead
280
+ # of moving focus, and let escape exit edit mode rather than the screen.
281
+ if action in ("nav_next", "nav_prev"):
282
+ if self._editing is not None and self.focused is self._editing:
283
+ return False # TextArea cursor owns up/down in edit mode
284
+ if any(select.expanded for select in self.query(Select)):
285
+ return False # an open dropdown owns up/down to pick an option
286
+ return True
287
+ if action == "edit_field":
288
+ # Only intercept Enter to start cursor-editing a TextArea; for every
289
+ # other widget Enter keeps its native meaning (open Select, toggle
290
+ # Switch, submit Input) and for a TextArea already editing it inserts
291
+ # a newline.
292
+ return isinstance(self.focused, TextArea) and self._editing is None
293
+ if action == "back":
294
+ return self._editing is None
295
+ return True
296
+
297
+ def action_nav_next(self) -> None:
298
+ self._exit_edit()
299
+ self.focus_next()
300
+
301
+ def action_nav_prev(self) -> None:
302
+ self._exit_edit()
303
+ self.focus_previous()
304
+
305
+ def action_edit_field(self) -> None:
306
+ area = self.focused
307
+ if isinstance(area, TextArea):
308
+ self._editing = area
309
+ area.add_class("-editing")
310
+
311
+ def action_toggle_all(self) -> None:
312
+ self._exit_edit()
313
+ sections = list(self.query(Collapsible))
314
+ target = any(not c.collapsed for c in sections) # something open -> collapse all
315
+ for c in sections:
316
+ c.collapsed = target
317
+
318
+ def on_key(self, event: events.Key) -> None:
319
+ if event.key == "escape" and self._editing is not None:
320
+ self._exit_edit()
321
+ event.stop()
322
+
323
+ def _exit_edit(self) -> None:
324
+ if self._editing is not None:
325
+ self._editing.remove_class("-editing")
326
+ self._editing = None
327
+
328
+ # -------------------------------------------------------------- events
329
+
330
+ def on_switch_changed(self, event: Switch.Changed) -> None:
331
+ if event.switch.has_class("argswitch") and event.switch.id:
332
+ target = event.switch.id.removesuffix("-override")
333
+ self.query_one(f"#{target}", Input).disabled = not event.value
334
+ elif event.switch.has_class("plugin-enable") and event.switch.id:
335
+ # Reveal/hide the plugin's settings live (a plugin with no settings has
336
+ # no config collapsible). Expand on reveal so they're immediately seen.
337
+ name = event.switch.id.removeprefix("plugin-enabled-")
338
+ if self.query(f"#plugin-cfg-{name}"):
339
+ cfg = self.query_one(f"#plugin-cfg-{name}", Collapsible)
340
+ cfg.display = event.value
341
+ if event.value:
342
+ cfg.collapsed = False
343
+
344
+ def on_button_pressed(self, event: Button.Pressed) -> None:
345
+ if event.button.id == "save":
346
+ self.action_save()
347
+ else:
348
+ self.action_back()
349
+
350
+ def action_back(self) -> None:
351
+ self.app.pop_screen()
352
+
353
+ # ---------------------------------------------------------------- save
354
+
355
+ def _collect(self, spec: SettingSpec, problems: list[str]) -> object:
356
+ """Widget state -> raw policy value; None means unset (delete key)."""
357
+ ident = f"{spec.section}.{spec.key}"
358
+ if spec.kind == "select":
359
+ select = self.query_one(f"#{spec.widget_id}", Select)
360
+ return None if select.is_blank() else str(select.value)
361
+ if spec.kind in ("int", "float"):
362
+ box = self.query_one(f"#{spec.widget_id}", Input)
363
+ value = box.value.strip()
364
+ if not value:
365
+ return None
366
+ result = box.validate(value)
367
+ if result is not None and not result.is_valid:
368
+ problems.append(f"{ident}: {'; '.join(result.failure_descriptions)}")
369
+ return _INVALID
370
+ try:
371
+ return int(value) if spec.kind == "int" else float(value)
372
+ except ValueError:
373
+ problems.append(f"{ident}: not a valid number")
374
+ return _INVALID
375
+ if spec.kind == "str":
376
+ return self.query_one(f"#{spec.widget_id}", Input).value.strip() or None
377
+ if spec.kind == "switch":
378
+ return self.query_one(f"#{spec.widget_id}", Switch).value
379
+ if spec.kind == "lines":
380
+ text = self.query_one(f"#{spec.widget_id}", TextArea).text
381
+ return [line.strip() for line in text.splitlines() if line.strip()]
382
+ if spec.kind == "args":
383
+ if not self.query_one(f"#{spec.widget_id}-override", Switch).value:
384
+ return None
385
+ try:
386
+ return shlex.split(self.query_one(f"#{spec.widget_id}", Input).value)
387
+ except ValueError as e:
388
+ problems.append(f"{ident}: {e}")
389
+ return _INVALID
390
+ raise AssertionError(spec.kind)
391
+
392
+ @staticmethod
393
+ def _changed(spec: SettingSpec, current: object, desired: object) -> bool:
394
+ if desired is None:
395
+ # adapter.model = "" means "CLI default" just like an absent key;
396
+ # don't rewrite the template line for an untouched empty input
397
+ return current is not None and not (spec.kind == "str" and current == "")
398
+ if current is None:
399
+ # an unset key already behaves like the default — don't bake it in
400
+ if spec.kind == "switch" and desired == spec.default:
401
+ return False
402
+ if spec.kind == "lines" and desired == []:
403
+ return False
404
+ return True
405
+ if isinstance(desired, list):
406
+ # `current` pairs with a list `desired` here; its static type is the
407
+ # heterogeneous `object` setting supertype, but at runtime it is a list.
408
+ return [
409
+ str(item) for item in current # pyright: ignore[reportGeneralTypeIssues]
410
+ ] != desired
411
+ return current != desired
412
+
413
+ def _save_enabled(self) -> None:
414
+ """Reconcile [plugins] enabled with the enable toggles. Only trust-gated
415
+ plugins carry a toggle; any other name already in the list (a data-only
416
+ plugin, or a plugin this checkout can't discover) is preserved so the
417
+ save never silently drops trust the UI doesn't manage. An absent key
418
+ stays absent when nothing is enabled (unset = nothing trusted)."""
419
+ toggles = {p.name for p in self._registry.plugins if p.trust_needed}
420
+ on = [
421
+ p.name
422
+ for p in self._registry.plugins
423
+ if p.trust_needed and self.query_one(f"#plugin-enabled-{p.name}", Switch).value
424
+ ]
425
+ current = self._doc.get("plugins", "enabled")
426
+ current_names = None if current is None else [str(x) for x in current]
427
+ preserved = [n for n in (current_names or []) if n not in toggles]
428
+ desired = preserved + on
429
+ if current_names is None:
430
+ if desired:
431
+ self._doc.set("plugins", "enabled", desired)
432
+ elif current_names != desired:
433
+ self._doc.set("plugins", "enabled", desired)
434
+
435
+ def _save_specs(self) -> list[SettingSpec]:
436
+ """Core fields plus only the *active* plugins' fields — a disabled plugin's
437
+ settings are never collected, so its defaults are not baked in and any
438
+ existing [plugins.<name>] table is left untouched (preserved as data)."""
439
+ specs = [f for s in self._registry.sections for f in s.fields]
440
+ for info in self._registry.plugins:
441
+ if self._plugin_active(info):
442
+ specs.extend(info.fields)
443
+ return specs
444
+
445
+ def action_save(self) -> None:
446
+ problems: list[str] = []
447
+ desired = {spec: self._collect(spec, problems) for spec in self._save_specs()}
448
+ if problems:
449
+ self._show_errors("\n".join(problems))
450
+ return
451
+ for spec, value in desired.items():
452
+ if self._changed(spec, self._doc.get(spec.section, spec.key), value):
453
+ self._doc.set(spec.section, spec.key, value)
454
+ self._save_enabled()
455
+ # Pass the project so the round-trip also runs every enabled in-process
456
+ # plugin's coupling check (e.g. unity editor_mode↔scm.isolation), blocking
457
+ # an incompatible save the same way the engine fails fast at startup.
458
+ error = self._doc.validate(self._registry.plugin_schemas, project=self._project)
459
+ if error:
460
+ self._show_errors(error)
461
+ return
462
+ try:
463
+ self._doc.save(self._path, confine_root=self._project)
464
+ except OSError as e:
465
+ self._show_errors(f"save failed: {e}")
466
+ return
467
+ self.app.notify(f"policy saved: {self._path}")
468
+ self.app.pop_screen()
469
+
470
+ def _show_errors(self, message: str | None) -> None:
471
+ strip = self.query_one("#errors", Static)
472
+ if message:
473
+ strip.update(Text(f"✗ {message}"))
474
+ strip.add_class("-visible")
475
+ else:
476
+ strip.update("")
477
+ strip.remove_class("-visible")
@@ -0,0 +1,135 @@
1
+ """tomlkit-backed editing model for .froid-loop/policy.toml.
2
+
3
+ Zero textual imports: this is the testable core of the settings editor. The
4
+ form never re-implements policy rules — validate() round-trips the document
5
+ through policy.loads(), so policy.py stays the single source of truth.
6
+ tomlkit preserves comments and key order; a missing file starts from
7
+ POLICY_TEMPLATE so the first save carries the full inline documentation.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ import tomlkit
16
+
17
+ from .. import policy as policy_mod
18
+ from ..platform_util import atomic_write_text_confined
19
+
20
+ # STAGES now lives in policy.py (#679) so the settings schema can reach it
21
+ # without importing this tomlkit-backed module. Re-exported here because
22
+ # `settings.STAGES` is this module's historical name for it; the pin is
23
+ # load-bearing — without it ruff F401 autofix deletes the re-export.
24
+ from ..policy import STAGES # noqa: F401 — re-export
25
+
26
+
27
+ class PolicyDoc:
28
+ """A policy.toml document under edit. Sections may be dotted
29
+ ("adapter.dev") to address the per-stage override tables."""
30
+
31
+ def __init__(self, doc: tomlkit.TOMLDocument):
32
+ self._doc = doc
33
+
34
+ @classmethod
35
+ def load(cls, path: Path, *, template_when_missing: bool = True) -> PolicyDoc:
36
+ """``template_when_missing=False`` starts a missing file from an empty
37
+ document instead of POLICY_TEMPLATE — for callers that write a single
38
+ section (e.g. dashboard geometry) and must not materialise every
39
+ default setting into a fresh policy.toml."""
40
+ if path.is_file():
41
+ text = path.read_text(encoding="utf-8")
42
+ else:
43
+ text = policy_mod.POLICY_TEMPLATE if template_when_missing else ""
44
+ return cls(tomlkit.parse(text))
45
+
46
+ def _table(self, section: str, create: bool) -> Any | None:
47
+ node: Any = self._doc
48
+ for part in section.split("."):
49
+ if part not in node:
50
+ if not create:
51
+ return None
52
+ node[part] = tomlkit.table()
53
+ node = node[part]
54
+ return node
55
+
56
+ def get(self, section: str, key: str) -> Any | None:
57
+ """Raw value from the document, or None when the key is unset."""
58
+ table = self._table(section, create=False)
59
+ if table is None or key not in table:
60
+ return None
61
+ return table[key]
62
+
63
+ def set(self, section: str, key: str, value: Any | None) -> None:
64
+ """Set a key; None deletes it. A per-stage adapter table emptied by a
65
+ delete is dropped entirely, restoring 'unset = inherit'."""
66
+ if value is None:
67
+ table = self._table(section, create=False)
68
+ if table is not None and key in table:
69
+ del table[key]
70
+ parent, _, stage = section.partition(".")
71
+ if stage and table is not None and len(table) == 0:
72
+ del self._doc[parent][stage]
73
+ return
74
+ # _table(create=True) never returns None (it creates missing tables), but
75
+ # its return type is Any | None.
76
+ self._table(section, create=True)[key] = value # pyright: ignore[reportOptionalSubscript]
77
+
78
+ def validate(
79
+ self,
80
+ plugin_schemas: dict[str, Any] | None = None,
81
+ project: Path | None = None,
82
+ ) -> str | None:
83
+ """Authoritative validation via policy.loads(); None when valid.
84
+
85
+ ``plugin_schemas`` (plugin name -> setting specs) lets the round-trip
86
+ also type-check any [plugins.<name>] tables the screen rendered. When
87
+ ``project`` is given, every enabled in-process plugin additionally
88
+ self-validates against the parsed policy (Plugin.validate) — the same
89
+ coupling check the engine runs at startup (e.g. the Unity plugin's
90
+ editor_mode↔scm.isolation rule) — so an incompatible combination is
91
+ caught at save time rather than mid-run. Building the registry imports
92
+ only the plugins already trusted in [plugins] enabled."""
93
+ try:
94
+ pol = policy_mod.loads(self.dumps(), plugin_schemas=plugin_schemas)
95
+ except policy_mod.PolicyError as e:
96
+ return str(e)
97
+ if project is not None:
98
+ from ..plugins.model import PluginError
99
+ from ..plugins.registry import PluginRegistry
100
+
101
+ try:
102
+ PluginRegistry.build(project, pol).validate(pol)
103
+ except (policy_mod.PolicyError, PluginError) as e:
104
+ return str(e)
105
+ return None
106
+
107
+ def dumps(self) -> str:
108
+ return tomlkit.dumps(self._doc)
109
+
110
+ def save(self, path: Path, *, confine_root: Path) -> None:
111
+ """#363: via the helper, which removes its temp on any raise. The
112
+ hand-rolled temp was the fixed name `.froid-loop/policy.toml.tmp` —
113
+ gitignored by nothing, and byte-identical to the one
114
+ `policy.write_mux_backend` built, so the settings editor and the mux
115
+ writer raced on one name. Raises rather than degrades: the screen catches
116
+ OSError to show "save failed", which requires the raise to reach it —
117
+ and `UnconfinedWriteError` is an OSError, so a refusal arrives there too.
118
+
119
+ `confine_root` is a REQUIRED keyword rather than a defaulted one, and
120
+ this document does not remember one: a `PolicyDoc` is parsed text with no
121
+ path of its own — `load` and `save` are each handed one per call — so
122
+ there is nothing here to derive a root from. Requiring it makes a caller
123
+ that has not decided what tree this policy file belongs to a type error
124
+ rather than an unconfined write (#593). `runsetup` documents
125
+ `.froid-loop/policy.toml` as a path a driven session may write, so the
126
+ directories above it are exactly the ones worth anchoring.
127
+
128
+ `require_writable_target=True` (#597): policy.toml is hand-edited config,
129
+ and an operator who marks it read-only gets the `PermissionError` a bare
130
+ `Path.write_text` raised before this write went atomic — surfaced by the
131
+ same "save failed" the screen already shows."""
132
+ path.parent.mkdir(parents=True, exist_ok=True)
133
+ atomic_write_text_confined(
134
+ path, self.dumps(), confine_root=confine_root, require_writable_target=True
135
+ )