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.
- froid_loop/__init__.py +11 -0
- froid_loop/__main__.py +12 -0
- froid_loop/adapters/__init__.py +3 -0
- froid_loop/adapters/base.py +254 -0
- froid_loop/adapters/entrypoints.py +63 -0
- froid_loop/adapters/env_fault.py +290 -0
- froid_loop/adapters/generic.py +2013 -0
- froid_loop/adapters/mock.py +49 -0
- froid_loop/adapters/multiplexer.py +914 -0
- froid_loop/adapters/opencode_http.py +1687 -0
- froid_loop/adapters/profile.py +650 -0
- froid_loop/adapters/psmux_backend.py +1428 -0
- froid_loop/adapters/registry.py +322 -0
- froid_loop/adapters/tmux_backend.py +35 -0
- froid_loop/adapters/tmux_base.py +630 -0
- froid_loop/checks.py +187 -0
- froid_loop/cli.py +5041 -0
- froid_loop/data/__init__.py +0 -0
- froid_loop/data/froid_loop_hook.py +228 -0
- froid_loop/data/froid_loop_probe_hook.py +88 -0
- froid_loop/data/plugins/example/plugin.toml +21 -0
- froid_loop/data/plugins/tea/plugin.toml +184 -0
- froid_loop/data/plugins/tea/tea_plugin.py +258 -0
- froid_loop/data/plugins/unity/plugin.toml +140 -0
- froid_loop/data/plugins/unity/unity_assets/FroidLoop.Unity.Editor.asmdef +16 -0
- froid_loop/data/plugins/unity/unity_assets/FroidLoop.Unity.Editor.asmdef.meta +7 -0
- froid_loop/data/plugins/unity/unity_assets/SceneAutoSaveGuard.cs +221 -0
- froid_loop/data/plugins/unity/unity_assets/SceneAutoSaveGuard.cs.meta +11 -0
- froid_loop/data/plugins/unity/unity_assets/_folders/Editor.meta +8 -0
- froid_loop/data/plugins/unity/unity_assets/_folders/FroidLoop.meta +8 -0
- froid_loop/data/plugins/unity/unity_cleanup.py +125 -0
- froid_loop/data/plugins/unity/unity_dialog_probe.py +239 -0
- froid_loop/data/plugins/unity/unity_facts.md +17 -0
- froid_loop/data/plugins/unity/unity_plugin.py +415 -0
- froid_loop/data/plugins/unity/unity_quiesce.py +234 -0
- froid_loop/data/plugins/unity/unity_ready.py +230 -0
- froid_loop/data/plugins/unity/unity_seed_assets.py +298 -0
- froid_loop/data/plugins/unity/unity_setup.py +551 -0
- froid_loop/data/plugins/unity/unity_teardown.py +362 -0
- froid_loop/data/profiles/antigravity.toml +52 -0
- froid_loop/data/profiles/claude.toml +85 -0
- froid_loop/data/profiles/codex.toml +22 -0
- froid_loop/data/profiles/copilot.toml +52 -0
- froid_loop/data/profiles/gemini.toml +26 -0
- froid_loop/data/profiles/opencode.toml +54 -0
- froid_loop/data/settings/core.toml +458 -0
- froid_loop/data/skills/README.md +93 -0
- froid_loop/data/skills/froid-loop-resolve/SKILL.md +288 -0
- froid_loop/data/skills/froid-loop-setup/SKILL.md +161 -0
- froid_loop/data/skills/froid-loop-setup/assets/module-help.csv +3 -0
- froid_loop/data/skills/froid-loop-setup/assets/module.yaml +19 -0
- froid_loop/data/skills/froid-loop-sweep/SKILL.md +100 -0
- froid_loop/data/skills/froid-loop-sweep/automation-mode.md +127 -0
- froid_loop/data/skills/froid-loop-sweep/deferred-work-format.md +302 -0
- froid_loop/data/skills/froid-loop-sweep/migration-mode.md +86 -0
- froid_loop/decisions.py +202 -0
- froid_loop/deferredwork.py +2282 -0
- froid_loop/devcontract.py +892 -0
- froid_loop/diagnostics.py +1104 -0
- froid_loop/documents.py +532 -0
- froid_loop/engine.py +7732 -0
- froid_loop/envvars.py +111 -0
- froid_loop/escalation.py +225 -0
- froid_loop/events.py +266 -0
- froid_loop/fences.py +103 -0
- froid_loop/froidconfig.py +226 -0
- froid_loop/frontmatter.py +526 -0
- froid_loop/gates.py +133 -0
- froid_loop/install.py +2936 -0
- froid_loop/journal.py +178 -0
- froid_loop/machine.py +148 -0
- froid_loop/model.py +898 -0
- froid_loop/operatoractions.py +474 -0
- froid_loop/platform_util.py +1490 -0
- froid_loop/plugins/__init__.py +64 -0
- froid_loop/plugins/bus.py +259 -0
- froid_loop/plugins/context.py +319 -0
- froid_loop/plugins/loader.py +145 -0
- froid_loop/plugins/manifest.py +279 -0
- froid_loop/plugins/model.py +296 -0
- froid_loop/plugins/registry.py +245 -0
- froid_loop/plugins/trust.py +75 -0
- froid_loop/policy.py +1569 -0
- froid_loop/probe.py +1044 -0
- froid_loop/process_host.py +408 -0
- froid_loop/recovery_flow.py +1561 -0
- froid_loop/resolve.py +283 -0
- froid_loop/runs.py +4715 -0
- froid_loop/runsetup.py +1293 -0
- froid_loop/sanitize.py +593 -0
- froid_loop/settings_schema.py +276 -0
- froid_loop/signals.py +160 -0
- froid_loop/sprintstatus.py +609 -0
- froid_loop/statemachine.py +57 -0
- froid_loop/stories.py +615 -0
- froid_loop/stories_engine.py +796 -0
- froid_loop/sweep.py +1892 -0
- froid_loop/tokens.py +196 -0
- froid_loop/tui/__init__.py +11 -0
- froid_loop/tui/app.py +1584 -0
- froid_loop/tui/data.py +840 -0
- froid_loop/tui/launch.py +1003 -0
- froid_loop/tui/screens/__init__.py +1 -0
- froid_loop/tui/screens/dashboard.py +1071 -0
- froid_loop/tui/screens/modals.py +943 -0
- froid_loop/tui/screens/settings_screen.py +477 -0
- froid_loop/tui/settings.py +135 -0
- froid_loop/tui/widgets.py +981 -0
- froid_loop/verify.py +4545 -0
- froid_loop/workspace.py +320 -0
- froid_loop/worktree_flow.py +2301 -0
- froid_loop-0.11.1.dist-info/METADATA +728 -0
- froid_loop-0.11.1.dist-info/RECORD +116 -0
- froid_loop-0.11.1.dist-info/WHEEL +4 -0
- froid_loop-0.11.1.dist-info/entry_points.txt +2 -0
- 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
|
+
)
|