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
|
@@ -0,0 +1,526 @@
|
|
|
1
|
+
"""Pure spec-frontmatter parsing: read the YAML ``---``…``---`` block, normalize
|
|
2
|
+
the status token, and rewrite ``status:`` in place.
|
|
3
|
+
|
|
4
|
+
No git dependency, so a pure domain module can read spec status without importing
|
|
5
|
+
``verify`` and dragging in its whole git surface (assessment finding F-1).
|
|
6
|
+
``stories`` still collects on that: ``import froid_loop.stories`` genuinely leaves
|
|
7
|
+
``froid_loop.verify`` out of ``sys.modules``. ``devcontract`` no longer does — it
|
|
8
|
+
imports ``verify`` directly for `read_frontmatter` and `operator_actions_of` — so
|
|
9
|
+
the rule now has one beneficiary, not the two it was written for. ``verify``
|
|
10
|
+
re-exports these names either way, so every existing ``verify.<name>`` /
|
|
11
|
+
``from .verify import <name>`` call site stays valid.
|
|
12
|
+
|
|
13
|
+
That rule is about ``verify``, NOT about ``subprocess``: this module imports
|
|
14
|
+
``platform_util`` (#379), which pulls ``subprocess`` in, and both named modules
|
|
15
|
+
already paid that cost anyway — ``devcontract`` imports it directly and
|
|
16
|
+
``stories`` reaches it through ``deferredwork``. So the writes here are atomic as
|
|
17
|
+
well as byte-verbatim: ``read_bytes().decode`` in, ``atomic_write_bytes`` out.
|
|
18
|
+
The byte path is load-bearing on its own — ``read_text``'s universal-newline
|
|
19
|
+
translation would hand the writer an all-LF copy of a CRLF spec, and every line
|
|
20
|
+
ending in the file would be relaid on the way back out.
|
|
21
|
+
|
|
22
|
+
READER AND WRITER DEGRADE IN OPPOSITE DIRECTIONS, deliberately. `read_frontmatter`
|
|
23
|
+
turns an unparseable or undecodable block into ``{}``: it runs on the observation
|
|
24
|
+
path, where the orchestrator's job is to classify what it finds, and every status
|
|
25
|
+
gate then reads ``""`` and answers with a clean retry. `set_frontmatter_status`
|
|
26
|
+
runs on the *repair* path and does the opposite — when it can see a status it
|
|
27
|
+
cannot safely rewrite, it RAISES `FrontmatterWriteError`. That is AGENTS.md's
|
|
28
|
+
"observation may degrade, repair writes must raise", and it is what makes a
|
|
29
|
+
``False`` return mean one thing only: there was nothing to change.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import re
|
|
35
|
+
from collections.abc import Callable
|
|
36
|
+
from pathlib import Path
|
|
37
|
+
from typing import Any
|
|
38
|
+
|
|
39
|
+
import yaml
|
|
40
|
+
|
|
41
|
+
from .platform_util import atomic_write_bytes, atomic_write_bytes_confined
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _split_frontmatter(text: str) -> tuple[str, str, str] | None:
|
|
45
|
+
"""Split a document into ``(before, block, after)`` around its YAML
|
|
46
|
+
frontmatter, where ``before + block + after == text`` exactly.
|
|
47
|
+
|
|
48
|
+
The opening and closing ``---`` are recognized ONLY as standalone delimiter
|
|
49
|
+
lines (``line.rstrip() == "---"``), so a ``---`` substring inside a scalar
|
|
50
|
+
value (e.g. ``title: 'restore --- review'``) is never mistaken for the
|
|
51
|
+
closing boundary — the flaw a plain ``text.split("---", 2)`` has. ``before``
|
|
52
|
+
is the opening delimiter line, ``block`` is the YAML content between the
|
|
53
|
+
delimiters, and ``after`` begins with the closing delimiter line; callers
|
|
54
|
+
rewrite ``block`` and reconstruct the file byte-for-byte. Returns ``None``
|
|
55
|
+
when the text has no opening delimiter line or no closing delimiter line.
|
|
56
|
+
"""
|
|
57
|
+
lines = text.splitlines(keepends=True) # "".join(lines) == text
|
|
58
|
+
if not lines or lines[0].rstrip() != "---":
|
|
59
|
+
return None
|
|
60
|
+
for i in range(1, len(lines)):
|
|
61
|
+
if lines[i].rstrip() == "---":
|
|
62
|
+
return lines[0], "".join(lines[1:i]), "".join(lines[i:])
|
|
63
|
+
return None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def parse_frontmatter(text: str) -> dict[str, Any]:
|
|
67
|
+
"""The frontmatter mapping ``text`` carries, or ``{}`` when it carries none.
|
|
68
|
+
|
|
69
|
+
Split out of `read_frontmatter` so a spec that never touches the filesystem — a
|
|
70
|
+
blob read back out of git with `verify.file_bytes_at_revision`, say — parses
|
|
71
|
+
through the SAME reader as a live file rather than through a second copy free to
|
|
72
|
+
drift from it. Degrades rather than raising on every shape: no frontmatter block,
|
|
73
|
+
unparseable YAML, or a document that is not a mapping. Callers tell "absent" from
|
|
74
|
+
"present but empty" by asking the `*_of` readers, never by inspecting this.
|
|
75
|
+
"""
|
|
76
|
+
split = _split_frontmatter(text)
|
|
77
|
+
if split is None:
|
|
78
|
+
return {}
|
|
79
|
+
try:
|
|
80
|
+
doc = yaml.safe_load(split[1])
|
|
81
|
+
except yaml.YAMLError:
|
|
82
|
+
return {}
|
|
83
|
+
return doc if isinstance(doc, dict) else {}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def read_frontmatter(path: Path) -> dict[str, Any]:
|
|
87
|
+
if not path.is_file():
|
|
88
|
+
return {}
|
|
89
|
+
try:
|
|
90
|
+
text = path.read_text(encoding="utf-8")
|
|
91
|
+
except UnicodeDecodeError:
|
|
92
|
+
# A non-UTF-8 file carries no readable frontmatter — degrade exactly like the
|
|
93
|
+
# unparseable-YAML arm in `parse_frontmatter` above. Every status gate then
|
|
94
|
+
# reads status "" and returns a clean retry/repair outcome instead of crashing
|
|
95
|
+
# mid-verify (UnicodeDecodeError is a ValueError, so it slipped past callers'
|
|
96
|
+
# except-OSError guards).
|
|
97
|
+
return {}
|
|
98
|
+
return parse_frontmatter(text)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def status_of(fm: dict[str, Any]) -> str:
|
|
102
|
+
"""Normalized spec status from a frontmatter dict: stripped + lowercased.
|
|
103
|
+
|
|
104
|
+
The single point all spec-frontmatter status gates read through, so casing
|
|
105
|
+
never decides a gate — the spec template and sprint-status tokens are
|
|
106
|
+
lowercase, so a stray ``Done``/``In-Review`` from a hand-edited spec still
|
|
107
|
+
matches. (``devcontract`` reads its frontmatter status through here too; the
|
|
108
|
+
separate lowercasing it keeps is for the skill-written *prose* ``Status:``
|
|
109
|
+
line, where casing genuinely varies.)
|
|
110
|
+
|
|
111
|
+
A YAML-null status (a bare ``status:`` line, or ``status: null``) reads as
|
|
112
|
+
``""`` — the same as a missing key — because a spec template may legitimately
|
|
113
|
+
leave the value blank (see the comment on ``devcontract._FM_STATUS_RE``, whose
|
|
114
|
+
writer side fills exactly that shape). Without the guard ``str(None)`` would
|
|
115
|
+
make it the token ``"none"``, which every allowlist then treats as a
|
|
116
|
+
deliberate custom status (#358). A *literal* ``status: none`` is the string
|
|
117
|
+
``"none"`` — PyYAML resolves only ``~``/``null``/``Null``/``NULL``/empty as
|
|
118
|
+
null — so a hand-written token still reads back exactly as written.
|
|
119
|
+
"""
|
|
120
|
+
raw = fm.get("status", "")
|
|
121
|
+
return ("" if raw is None else str(raw)).strip().lower()
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def operator_actions_of(fm: dict[str, Any]) -> tuple[str, ...]:
|
|
125
|
+
"""The external, human-only actions a spec's ``operator_actions:`` frontmatter
|
|
126
|
+
declares — normalized, order-preserving, deduped.
|
|
127
|
+
|
|
128
|
+
Lives here rather than in ``devcontract`` because both sides of the park
|
|
129
|
+
contract need the same reading and ``verify`` cannot import ``devcontract``
|
|
130
|
+
(the dependency runs the other way).
|
|
131
|
+
|
|
132
|
+
Strict about the container, lenient about each scalar item — the
|
|
133
|
+
``closes_deferred`` reading (:func:`deferredwork.parse_declaration`), for the
|
|
134
|
+
same reason: a bare ``operator_actions: buy the domain`` is iterable, so a
|
|
135
|
+
lenient container reading would silently turn one instruction into a list of
|
|
136
|
+
characters. Items that are themselves containers are dropped rather than
|
|
137
|
+
stringified: ``[{action: ..., check: ...}]`` is the deliberate v2 shape (a
|
|
138
|
+
per-action verification command), and ``str()``-ing it would hand a human a
|
|
139
|
+
line of Python repr as their instruction. ``None`` items drop for the same
|
|
140
|
+
reason — ``str(None)`` is the word "None", not an action.
|
|
141
|
+
|
|
142
|
+
Every malformed shape therefore collapses to ``()``, which the verify gates
|
|
143
|
+
read as "declared nothing" and answer with one fixable retry naming the
|
|
144
|
+
expected shape — a park is *defined* by owing at least one action, so an
|
|
145
|
+
empty reading can never be mistaken for a valid park.
|
|
146
|
+
"""
|
|
147
|
+
raw = fm.get("operator_actions")
|
|
148
|
+
if not isinstance(raw, list):
|
|
149
|
+
return ()
|
|
150
|
+
items = (str(x).strip() for x in raw if x is not None and not isinstance(x, (list, dict)))
|
|
151
|
+
return tuple(dict.fromkeys(a for a in items if a))
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
# The two frontmatter keys a froid-build-auto spec can carry a dev baseline under,
|
|
155
|
+
# in precedence order. `baseline_revision` is what the skill's step-03 actually
|
|
156
|
+
# stamps; `baseline_commit` is the legacy spelling (the name the orchestrator's
|
|
157
|
+
# synthesized result.json uses) kept readable for specs written before the rename.
|
|
158
|
+
_BASELINE_KEYS = ("baseline_revision", "baseline_commit")
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def auto_dev_baseline_of(fm: dict[str, Any]) -> str:
|
|
162
|
+
"""The dev baseline a froid-build-auto spec CLAIMS: the first non-empty value
|
|
163
|
+
among ``baseline_revision`` then ``baseline_commit``, stripped; ``""`` when the
|
|
164
|
+
spec claims neither.
|
|
165
|
+
|
|
166
|
+
Deliberately not the bare ``baseline_of`` the siblings' naming would suggest.
|
|
167
|
+
``status_of`` and ``operator_actions_of`` are field-generic — they read one key
|
|
168
|
+
and normalize it — whereas this precedence belongs to the froid-build-auto
|
|
169
|
+
contract specifically: the skill stamps ``baseline_revision``, the orchestrator's
|
|
170
|
+
own result.json says ``baseline_commit``, and a spec re-armed by
|
|
171
|
+
``runs.rearm_escalation`` can carry both. A bare ``baseline_of`` would read as
|
|
172
|
+
universal when it is not (#716).
|
|
173
|
+
|
|
174
|
+
``baseline_revision`` WINS whenever it is non-empty, even against a
|
|
175
|
+
``baseline_commit`` that would have matched. Both consumers — the dev
|
|
176
|
+
devcontract's synthesized result and verify's baseline-match gate — read
|
|
177
|
+
through here so the two cannot drift, and the two byte-identical
|
|
178
|
+
``fm.get("baseline_commit", fm.get("baseline_revision", ""))`` expressions
|
|
179
|
+
this replaces had the precedence the other way round. That flip is deliberate
|
|
180
|
+
and tightening: the legacy key is a leftover the re-arm never removes, so
|
|
181
|
+
ranking it first let a stale sha silently outrank the fresh value the skill
|
|
182
|
+
had just written, killing the gate on an attempt that did everything right.
|
|
183
|
+
|
|
184
|
+
An EMPTY legacy key is skipped rather than returned. ``dict.get``'s default
|
|
185
|
+
only fires on a MISSING key, so ``baseline_commit: ''`` used to be selected and
|
|
186
|
+
yield ``""`` — which every consumer reads as "no claim" and which therefore
|
|
187
|
+
disabled the baseline-match gate outright.
|
|
188
|
+
|
|
189
|
+
A YAML-null value (a bare ``baseline_commit:`` line, or ``: null``) is treated
|
|
190
|
+
as absent for the same reason ``status_of`` guards it: ``str(None)`` is the
|
|
191
|
+
token ``"None"``, which is not a sha but IS non-empty, so it would flow into
|
|
192
|
+
the gate as a claim and fail an attempt that never made one (#358). A YAML
|
|
193
|
+
BOOLEAN is the same trap class and is skipped with it: PyYAML resolves ``no``,
|
|
194
|
+
``off`` and ``false`` to ``False`` (``yes``/``on``/``true`` to ``True``), and
|
|
195
|
+
``str(False)`` is the token ``"False"`` — again not a sha, again non-empty.
|
|
196
|
+
Worse than null: because the truthiness test is on the STRINGIFIED value, a
|
|
197
|
+
bool on ``baseline_revision`` outranks and SHADOWS a correct ``baseline_commit``
|
|
198
|
+
sitting right beside it, refusing an attempt whose legacy claim was good.
|
|
199
|
+
"""
|
|
200
|
+
for key in _BASELINE_KEYS:
|
|
201
|
+
raw = fm.get(key)
|
|
202
|
+
if raw is None or isinstance(raw, bool):
|
|
203
|
+
continue
|
|
204
|
+
value = str(raw).strip()
|
|
205
|
+
if value:
|
|
206
|
+
return value
|
|
207
|
+
return ""
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
class FrontmatterWriteError(Exception):
|
|
211
|
+
"""A frontmatter block carries a key the reader can see but no minimal line
|
|
212
|
+
edit can safely rewrite.
|
|
213
|
+
|
|
214
|
+
Raised, not returned, because ``False`` already means "nothing to change" and
|
|
215
|
+
no caller reads the return value — a ``False`` refusal would be invisible at
|
|
216
|
+
every call site, which is the failure this exists to stop rather than
|
|
217
|
+
relocate. `runs.rearm_escalation` translates it to `RearmError` (already
|
|
218
|
+
surfaced by the CLI and the TUI); `cli.cmd_confirm` catches it and names the
|
|
219
|
+
recoverable state; anything else lands on `cli.main`'s backstop as a clean
|
|
220
|
+
one-liner."""
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
# A frontmatter ``<key>:`` line. Anchored, so only indentation may precede it —
|
|
224
|
+
# a `#` comment line and a `- ` list item are structurally excluded rather than
|
|
225
|
+
# excluded by a special case. Matching quotes around the key (`"status": x`),
|
|
226
|
+
# and whitespace before the colon (`status : x`), because YAML accepts both and
|
|
227
|
+
# the old `lstrip().startswith("status:")` scan silently skipped them. The
|
|
228
|
+
# `status_note:` exclusion is structural too: after the key the pattern demands
|
|
229
|
+
# a quote-or-whitespace-or-colon, and `_` is none of those.
|
|
230
|
+
def _key_line_re(key: str) -> re.Pattern[str]:
|
|
231
|
+
return re.compile(rf"^[ \t]*(?P<q>['\"]?){re.escape(key)}(?P=q)[ \t]*:")
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
_STATUS_KEY_RE = _key_line_re("status")
|
|
235
|
+
|
|
236
|
+
# Builds the replacement for a matched key line, line ending included. A hook
|
|
237
|
+
# rather than one fixed rendering because the two writers on this helper preserve
|
|
238
|
+
# deliberately different things: `_replace_value` drops the value's quotes (its
|
|
239
|
+
# callers read the result back as a bare `status: done`), while
|
|
240
|
+
# `devcontract.reset_spec_status` keeps them (its own tests pin them). Both carry
|
|
241
|
+
# a trailing inline comment through. What they share is the VERIFICATION, which
|
|
242
|
+
# is the part that was wrong in all three writers — not the formatting, which
|
|
243
|
+
# each already had right.
|
|
244
|
+
LineRenderer = Callable[[str, "re.Match[str]", str], str]
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
# What follows a key line's colon when the remainder is a bare scalar token and
|
|
248
|
+
# nothing else but a trailing inline comment. Only `sep` and `comment` are data;
|
|
249
|
+
# `q` and `val` are GATES — they certify that the scalar ends exactly where the
|
|
250
|
+
# render assumes it does, which is the one fact a `#` on the line cannot tell you
|
|
251
|
+
# by itself.
|
|
252
|
+
#
|
|
253
|
+
# NEVER WIDEN `val` TO `[^#]*?`. `_verified` cannot backstop this. Against
|
|
254
|
+
# `status: "a # b"` a widened pattern matches with `val` = `"a` and renders
|
|
255
|
+
# `status: done # b"` — which `yaml.safe_load` reads as a clean `status: done`,
|
|
256
|
+
# because it strips comments BEFORE the oracle compares. All three `_verified`
|
|
257
|
+
# gates pass and a fabricated comment lands in the spec. The conservative token
|
|
258
|
+
# class is therefore the gate and `_verified` is only the backstop, the reverse
|
|
259
|
+
# of everywhere else in this module. `[A-Za-z0-9._-]` is every shape a status
|
|
260
|
+
# token and the ordinary scalar frontmatter values have; anything richer than
|
|
261
|
+
# that falls back to the full-drop render, which is merely lossy, never wrong.
|
|
262
|
+
#
|
|
263
|
+
# `sprintstatus._set_mapping_value` solves the same problem with a wider value
|
|
264
|
+
# class than this one accepts, and the asymmetry is deliberate rather than an
|
|
265
|
+
# oversight: it writes the orchestrator-owned board, whose `last_updated` is a
|
|
266
|
+
# bare scalar WITH SPACES that this token class would refuse outright, while this
|
|
267
|
+
# writes a hand-authored spec where a value richer than a token is exactly the
|
|
268
|
+
# shape whose boundary cannot be trusted. Since #366 the two agree on the part
|
|
269
|
+
# that matters — neither guesses where a QUOTED scalar ends — and they part only
|
|
270
|
+
# on how much of an unquoted one they will read. Do not "unify" them by widening
|
|
271
|
+
# this one back; the token class is this side's whole gate.
|
|
272
|
+
_VALUE_COMMENT_RE = re.compile(
|
|
273
|
+
r"^[ \t]*(?P<q>['\"]?)(?P<val>[A-Za-z0-9._-]*)(?P=q)(?P<sep>[ \t]+)(?P<comment>#.*)$"
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _replace_value(line: str, m: re.Match[str], value: str) -> str:
|
|
278
|
+
"""Keep everything through the colon verbatim — indent, a quoted key,
|
|
279
|
+
whitespace before the colon — then ``<space><value>``, then any trailing
|
|
280
|
+
inline comment the old value carried, then THIS LINE'S OWN terminator.
|
|
281
|
+
|
|
282
|
+
The gap after the colon is normalized rather than preserved because a bare
|
|
283
|
+
``status:`` has none to preserve, and filling it would write ``status:done``,
|
|
284
|
+
which is not the key at all.
|
|
285
|
+
|
|
286
|
+
The comment is carried only when `_VALUE_COMMENT_RE` can certify where the
|
|
287
|
+
scalar ends, and its separating whitespace comes through as authored. When it
|
|
288
|
+
cannot — a quoted value containing a ``#``, a ``#`` abutting the scalar
|
|
289
|
+
(``done#x``, where it is part of the value, not a comment), a value richer
|
|
290
|
+
than a bare token — the whole remainder is dropped, which is what this
|
|
291
|
+
renderer did for every input before. Lossy, never wrong.
|
|
292
|
+
|
|
293
|
+
The value's own quotes are still dropped, and that non-preservation is
|
|
294
|
+
load-bearing rather than pending: `conftest.write_spec` writes
|
|
295
|
+
``status: '<v>'`` and three tests read the result back as an unquoted
|
|
296
|
+
``status: done`` (tests/test_runs.py, tests/test_stories_e2e.py).
|
|
297
|
+
|
|
298
|
+
The terminator is carried rather than re-emitted as ``"\\n"``: a CRLF spec
|
|
299
|
+
would otherwise come back with exactly one bare-LF line — the status line the
|
|
300
|
+
writer was asked to touch — leaving the file mixed. ``rstrip`` is safe here
|
|
301
|
+
because the caller splits with ``splitlines(keepends=True)``, so a line
|
|
302
|
+
carries at most one terminator; ``\\r\\n``, ``\\n``, a bare ``\\r`` and a
|
|
303
|
+
final line with no terminator at all each round-trip as authored."""
|
|
304
|
+
stripped = line.rstrip("\r\n")
|
|
305
|
+
nl = line[len(stripped) :]
|
|
306
|
+
cm = _VALUE_COMMENT_RE.match(stripped[m.end() :])
|
|
307
|
+
if cm is not None:
|
|
308
|
+
return f"{line[: m.end()]} {value}{cm['sep']}{cm['comment']}{nl}"
|
|
309
|
+
return f"{line[: m.end()]} {value}{nl}"
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _last_line_ending(block: str) -> str:
|
|
313
|
+
"""The terminator of ``block``'s last line — what an INSERTED line must carry
|
|
314
|
+
so an append does not introduce a foreign ending into the file.
|
|
315
|
+
|
|
316
|
+
The same per-line reading `_replace_value` does, for the branch that has no
|
|
317
|
+
line to copy from: an inserted key goes directly after the block's last line,
|
|
318
|
+
so that line's ending is the one it should share. Falls back to ``"\\n"`` for
|
|
319
|
+
an empty block (``---``/``---`` with nothing between) and for a last line
|
|
320
|
+
carrying no terminator at all — neither can happen through
|
|
321
|
+
`_split_frontmatter`, whose block is always followed by the closing delimiter
|
|
322
|
+
line, but an appended key that started on the previous line would be a
|
|
323
|
+
corruption rather than a formatting nit, so it is guarded rather than
|
|
324
|
+
reasoned about."""
|
|
325
|
+
lines = block.splitlines(keepends=True)
|
|
326
|
+
if not lines:
|
|
327
|
+
return "\n"
|
|
328
|
+
last = lines[-1]
|
|
329
|
+
return last[len(last.rstrip("\r\n")) :] or "\n"
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _edit_frontmatter_block(
|
|
333
|
+
block: str,
|
|
334
|
+
key: str,
|
|
335
|
+
value: str,
|
|
336
|
+
*,
|
|
337
|
+
pattern: re.Pattern[str] | None = None,
|
|
338
|
+
render: LineRenderer = _replace_value,
|
|
339
|
+
insert: bool = False,
|
|
340
|
+
) -> str | None:
|
|
341
|
+
"""Rewrite ``<key>:`` inside a frontmatter block, verifying the edit MEANS
|
|
342
|
+
what it was supposed to mean. Returns the new block, None when there is
|
|
343
|
+
nothing to change, and raises `FrontmatterWriteError` otherwise.
|
|
344
|
+
|
|
345
|
+
Enumerating the shapes a line scan must not touch is a losing game — a flow
|
|
346
|
+
mapping, a block scalar, a value continued on the next line, an anchor
|
|
347
|
+
another key aliases, a nested key of the same name, the key quoted inside
|
|
348
|
+
ANOTHER key's literal block. Each of those had the old scanner either write
|
|
349
|
+
nothing or corrupt the spec, and every enumeration written for this (mine and
|
|
350
|
+
both reviewers') missed shapes the next one caught.
|
|
351
|
+
|
|
352
|
+
So this does not widen a pattern. It makes the trial edit, re-parses it with
|
|
353
|
+
``yaml.safe_load`` as an ORACLE, and keeps it only if the block still parses
|
|
354
|
+
as a mapping, its top-level ``key`` is exactly ``value``, and **every other
|
|
355
|
+
key is unchanged**. YAML is never used as a serializer — the edit stays the
|
|
356
|
+
formatting-preserving single-line replacement — so one gate replaces six and
|
|
357
|
+
it also rejects shapes nobody has enumerated. The other-keys comparison is
|
|
358
|
+
the half no pattern-widening design has: it is what catches an edit with the
|
|
359
|
+
right effect on ``key`` and a wrong effect somewhere else.
|
|
360
|
+
|
|
361
|
+
Candidates are ITERATED, not broken on at the first match. That is what fixes
|
|
362
|
+
the wrong-target write: a decoy line inside another key's literal block fails
|
|
363
|
+
verification and the real key is still reached.
|
|
364
|
+
|
|
365
|
+
The parse is of the block the READER would see, so the two agree on what is
|
|
366
|
+
there. An unparseable block raises rather than returning None: the reader
|
|
367
|
+
degrades it to ``{}`` because observation may, but a writer that concluded
|
|
368
|
+
"no status here" from a block it could not read would report success for a
|
|
369
|
+
spec it never touched.
|
|
370
|
+
|
|
371
|
+
``insert`` adds the key as the block's last line when the reader sees no such
|
|
372
|
+
top-level key — what `verify.set_frontmatter_field` and
|
|
373
|
+
`devcontract.reset_spec_status` need and `set_frontmatter_status` must never
|
|
374
|
+
do. It is gated on the READER's view rather than on a scan miss, which is the
|
|
375
|
+
other half of the same defect: a scan that missed a quoted key then appended
|
|
376
|
+
a SECOND one, and the file ended up with two. The inserted line takes the
|
|
377
|
+
ending of the line it follows (`_last_line_ending`), so an insert cannot
|
|
378
|
+
introduce a foreign line ending any more than a replacement can."""
|
|
379
|
+
pattern = _key_line_re(key) if pattern is None else pattern
|
|
380
|
+
try:
|
|
381
|
+
original = yaml.safe_load(block)
|
|
382
|
+
except yaml.YAMLError as e:
|
|
383
|
+
raise FrontmatterWriteError(
|
|
384
|
+
f"the frontmatter block does not parse as YAML, so a {key!r} edit "
|
|
385
|
+
f"cannot be verified ({e.__class__.__name__}: {e})"
|
|
386
|
+
) from e
|
|
387
|
+
rest = {k: v for k, v in original.items() if k != key} if isinstance(original, dict) else {}
|
|
388
|
+
if not isinstance(original, dict) or key not in original:
|
|
389
|
+
if not insert:
|
|
390
|
+
return None # the reader sees no such top-level key — nothing to change
|
|
391
|
+
return _verified(block + f"{key}: {value}{_last_line_ending(block)}", key, value, rest)
|
|
392
|
+
if original[key] == value:
|
|
393
|
+
return None # already at the target — idempotent no-op, no write
|
|
394
|
+
lines = block.splitlines(keepends=True)
|
|
395
|
+
for i, line in enumerate(lines):
|
|
396
|
+
m = pattern.match(line)
|
|
397
|
+
if m is None:
|
|
398
|
+
continue
|
|
399
|
+
trial = list(lines)
|
|
400
|
+
trial[i] = render(line, m, value)
|
|
401
|
+
candidate = _verified("".join(trial), key, value, rest)
|
|
402
|
+
if candidate is not None:
|
|
403
|
+
return candidate
|
|
404
|
+
raise FrontmatterWriteError(
|
|
405
|
+
f"the frontmatter carries {key!r} in a shape no in-place line edit can "
|
|
406
|
+
f"safely rewrite to {value!r} (a flow mapping, a block scalar, a value "
|
|
407
|
+
f"continued on the next line, or an anchor another key aliases) — set it "
|
|
408
|
+
f"as a plain `{key}: <value>` line and re-run"
|
|
409
|
+
)
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def _verified(candidate: str, key: str, value: str, rest: dict[str, Any]) -> str | None:
|
|
413
|
+
"""``candidate`` if it means what the edit intended, else None.
|
|
414
|
+
|
|
415
|
+
Three conditions, and the third is the one no pattern-widening design has:
|
|
416
|
+
the block still parses as a mapping, its top-level ``key`` is exactly
|
|
417
|
+
``value``, and every OTHER key is unchanged. Without the last one an edit
|
|
418
|
+
with the right effect on ``key`` and a wrong effect elsewhere passes — a
|
|
419
|
+
``status`` merged in from an anchor block is only reachable by rewriting the
|
|
420
|
+
anchor, which is shared state the story does not own."""
|
|
421
|
+
try:
|
|
422
|
+
parsed = yaml.safe_load(candidate)
|
|
423
|
+
except yaml.YAMLError:
|
|
424
|
+
return None # the edit broke the block — this line is not a scalar key
|
|
425
|
+
if not isinstance(parsed, dict) or parsed.get(key) != value:
|
|
426
|
+
return None # edited something that is not the key the reader resolves
|
|
427
|
+
if {k: v for k, v in parsed.items() if k != key} != rest:
|
|
428
|
+
return None # right key, collateral damage — e.g. another key's block
|
|
429
|
+
return candidate
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def set_frontmatter_status(path: Path, status: str, *, confine_root: Path) -> bool:
|
|
433
|
+
"""Rewrite the `status:` field in a spec's `---`…`---` frontmatter block.
|
|
434
|
+
|
|
435
|
+
A minimal in-place line replacement (not a YAML round-trip) so the spec's
|
|
436
|
+
formatting, comments, and field order survive — only the status value
|
|
437
|
+
changes, and the edit is verified by re-parsing before it lands (see
|
|
438
|
+
`_edit_frontmatter_block`).
|
|
439
|
+
|
|
440
|
+
That includes the file's LINE ENDINGS, every one of them: the read is
|
|
441
|
+
``read_bytes().decode`` and the write is ``write_bytes``, so a CRLF spec
|
|
442
|
+
stays CRLF and a mixed-ending spec keeps each line's own terminator. Going
|
|
443
|
+
through ``read_text``/``write_text`` instead relaid the whole file — CRLF in,
|
|
444
|
+
LF out on POSIX, and every LF out as CRLF on Windows — which is the largest
|
|
445
|
+
violation a writer contracted to "only the status value changes" can commit.
|
|
446
|
+
|
|
447
|
+
The rewrite is also atomic (#379): the bytes go out through
|
|
448
|
+
`platform_util.atomic_write_bytes`, which is byte-verbatim on the same terms
|
|
449
|
+
as `write_bytes` and additionally leaves either the old file or the whole new
|
|
450
|
+
one. That matters most here, where the layout is ``before + edited + after``
|
|
451
|
+
— a truncating write that faults after the frontmatter has landed leaves
|
|
452
|
+
intact frontmatter saying ``status: done`` over a decapitated body, a spec
|
|
453
|
+
that lies and that the loop then commits. `devcontract._atomic_write_spec`
|
|
454
|
+
reached the same conclusion on the same files, with fault injection: it cut a
|
|
455
|
+
46-byte spec to 12.
|
|
456
|
+
|
|
457
|
+
The write is CONFINED, and this is the canonical statement of the rule the
|
|
458
|
+
three spec writers share (`verify.set_frontmatter_field` and
|
|
459
|
+
`devcontract._atomic_write_spec` restate it by reference):
|
|
460
|
+
|
|
461
|
+
* A spec path under ``confine_root`` goes through
|
|
462
|
+
`platform_util.atomic_write_bytes_confined`, which walks the components
|
|
463
|
+
below that root ``O_NOFOLLOW`` and writes through the descriptor the walk
|
|
464
|
+
produced. Refusing a link at the FINAL component was never enough (#593):
|
|
465
|
+
`mkstemp(dir=...)` and `os.replace`'s destination still looked every
|
|
466
|
+
DIRECTORY above the spec up by name, so a link planted at the artifacts
|
|
467
|
+
folder landed both the temp and the published spec wherever it pointed —
|
|
468
|
+
and the callers' own ``mkdir(parents=True, exist_ok=True)`` accepts a
|
|
469
|
+
symlinked directory, so a planted parent survives the setup step.
|
|
470
|
+
* A spec path OUTSIDE ``confine_root`` keeps the plain no-follow write. An
|
|
471
|
+
artifacts folder configured outside the checkout is real, supported
|
|
472
|
+
configuration (`froidconfig` resolves one; `verify.spec_within_roots` trusts
|
|
473
|
+
it), and a confined writer cannot vouch for a tree it was not given — so
|
|
474
|
+
refusing there would break working setups rather than close a hole.
|
|
475
|
+
|
|
476
|
+
``follow_symlinks=False`` on that second arm matches the name-replacing
|
|
477
|
+
`atomic_replace` this writer's `devcontract` sibling always had. A spec path
|
|
478
|
+
is handed to this writer from a session-driven scan, so honouring a link
|
|
479
|
+
planted there would aim a host-side write wherever that session chose. The
|
|
480
|
+
confined arm needs no such flag — it never follows anything.
|
|
481
|
+
|
|
482
|
+
``confine_root`` is a REQUIRED keyword: a caller that has not decided which
|
|
483
|
+
checkout the spec belongs to is a pyright error rather than an unconfined
|
|
484
|
+
write, which is how every call site of this and its two siblings was found.
|
|
485
|
+
|
|
486
|
+
``require_writable_target=True`` on both arms (#597): a spec is
|
|
487
|
+
operator-editable, and a temp-and-replace write needs write permission on the
|
|
488
|
+
PARENT DIRECTORY, never on the entry it replaces — so before this a spec an
|
|
489
|
+
operator had marked ``0444`` was rewritten anyway and, where the mode was
|
|
490
|
+
inherited, came back reading ``0444`` with nothing in the permission bits to
|
|
491
|
+
record it. The kernel's `PermissionError` is what a bare ``write_bytes``
|
|
492
|
+
raised, and it is what this raises again.
|
|
493
|
+
|
|
494
|
+
Returns True when the file was rewritten. Returns False for **nothing to
|
|
495
|
+
change** only: no file, no frontmatter block, no top-level `status` for
|
|
496
|
+
`read_frontmatter` to see, or already at the target. Raises
|
|
497
|
+
`FrontmatterWriteError` when the reader CAN see a status the edit cannot
|
|
498
|
+
safely move — `False` never means "I failed".
|
|
499
|
+
|
|
500
|
+
A trailing inline comment on the status line survives too, when the render
|
|
501
|
+
can certify where the scalar ends (`_VALUE_COMMENT_RE`); a value it cannot
|
|
502
|
+
read as a bare token drops the comment rather than guessing at one.
|
|
503
|
+
|
|
504
|
+
ONE deliberate non-preservation remains, pinned in tests/test_frontmatter.py:
|
|
505
|
+
the value's own quotes are dropped (`status: 'x'` -> `status: done`), because
|
|
506
|
+
the standard fixture shape is quoted and callers read the result back
|
|
507
|
+
unquoted.
|
|
508
|
+
"""
|
|
509
|
+
if not path.is_file():
|
|
510
|
+
return False
|
|
511
|
+
text = path.read_bytes().decode("utf-8")
|
|
512
|
+
split = _split_frontmatter(text)
|
|
513
|
+
if split is None:
|
|
514
|
+
return False
|
|
515
|
+
before, block, after = split
|
|
516
|
+
edited = _edit_frontmatter_block(block, "status", status, pattern=_STATUS_KEY_RE)
|
|
517
|
+
if edited is None:
|
|
518
|
+
return False
|
|
519
|
+
payload = (before + edited + after).encode("utf-8")
|
|
520
|
+
if path.is_relative_to(confine_root):
|
|
521
|
+
atomic_write_bytes_confined(
|
|
522
|
+
path, payload, confine_root=confine_root, require_writable_target=True
|
|
523
|
+
)
|
|
524
|
+
else:
|
|
525
|
+
atomic_write_bytes(path, payload, follow_symlinks=False, require_writable_target=True)
|
|
526
|
+
return True
|
froid_loop/gates.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""Gate evaluation and human notification (desktop + ATTENTION file)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import shutil
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
import time
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from .policy import Policy
|
|
13
|
+
|
|
14
|
+
ATTENTION_FILE = "ATTENTION"
|
|
15
|
+
|
|
16
|
+
# The untrusted notification title/message (story keys, `str(e)` error tails) are
|
|
17
|
+
# handed to osascript/PowerShell through these environment variables rather than
|
|
18
|
+
# interpolated into the command text, so quotes/newlines/AppleScript-or-PowerShell
|
|
19
|
+
# metacharacters cannot break out of the string. notify-send takes them as argv,
|
|
20
|
+
# which is already injection-safe.
|
|
21
|
+
_TITLE_ENV = "FROID_LOOP_NOTIFY_TITLE"
|
|
22
|
+
_MESSAGE_ENV = "FROID_LOOP_NOTIFY_MESSAGE"
|
|
23
|
+
|
|
24
|
+
# WinRT ToastNotificationManager — the dependency-free toast path on Windows 10+
|
|
25
|
+
# (works under both pwsh and powershell.exe). Reads title/message from $env, so no
|
|
26
|
+
# PowerShell-string interpolation of user text.
|
|
27
|
+
_WIN_TOAST_PS = (
|
|
28
|
+
"$ErrorActionPreference='Stop';"
|
|
29
|
+
"[Windows.UI.Notifications.ToastNotificationManager,Windows.UI.Notifications,"
|
|
30
|
+
"ContentType=WindowsRuntime]|Out-Null;"
|
|
31
|
+
"$t=[Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent("
|
|
32
|
+
"[Windows.UI.Notifications.ToastTemplateType]::ToastText02);"
|
|
33
|
+
"$x=$t.GetElementsByTagName('text');"
|
|
34
|
+
"$x.Item(0).AppendChild($t.CreateTextNode($env:FROID_LOOP_NOTIFY_TITLE))|Out-Null;"
|
|
35
|
+
"$x.Item(1).AppendChild($t.CreateTextNode($env:FROID_LOOP_NOTIFY_MESSAGE))|Out-Null;"
|
|
36
|
+
"$n=[Windows.UI.Notifications.ToastNotification]::new($t);"
|
|
37
|
+
# Windows only shows a toast for a *registered* AppUserModelID; 'froid-loop' has
|
|
38
|
+
# no Start-menu shortcut carrying it, so that toast would be silently dropped.
|
|
39
|
+
# Reuse Windows PowerShell's own default-registered AUMID instead (the toast is
|
|
40
|
+
# attributed to "Windows PowerShell"). Raw strings: the AUMID's backslashes must
|
|
41
|
+
# stay literal — `\v1.0` would otherwise be parsed as a vertical tab.
|
|
42
|
+
r"[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier("
|
|
43
|
+
r"'{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}\WindowsPowerShell\v1.0\powershell.exe')"
|
|
44
|
+
r".Show($n)"
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def desktop_notifier_kind() -> str | None:
|
|
49
|
+
"""The desktop notifier available on THIS platform, or ``None``. Read-only —
|
|
50
|
+
``validate`` and the engine call it to decide whether ``notify.desktop`` can do
|
|
51
|
+
anything here. Gated on ``sys.platform`` first (not ``which`` alone): PowerShell
|
|
52
|
+
Core can exist on Linux/macOS, but only Windows should reach the toast path and
|
|
53
|
+
Linux must keep picking ``notify-send``."""
|
|
54
|
+
if sys.platform == "darwin":
|
|
55
|
+
return "osascript" if shutil.which("osascript") else None
|
|
56
|
+
if sys.platform == "win32":
|
|
57
|
+
return "powershell" if (shutil.which("pwsh") or shutil.which("powershell")) else None
|
|
58
|
+
return "notify-send" if shutil.which("notify-send") else None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _notifier_argv(kind: str, title: str, message: str) -> tuple[list[str], dict[str, str]]:
|
|
62
|
+
"""``(argv, env-overrides)`` for ``kind``. osascript/powershell carry the
|
|
63
|
+
untrusted text via env (never argv); notify-send takes it as argv."""
|
|
64
|
+
if kind == "osascript":
|
|
65
|
+
return (
|
|
66
|
+
[
|
|
67
|
+
"osascript",
|
|
68
|
+
"-e",
|
|
69
|
+
f'display notification (system attribute "{_MESSAGE_ENV}") '
|
|
70
|
+
f'with title (system attribute "{_TITLE_ENV}")',
|
|
71
|
+
],
|
|
72
|
+
{_TITLE_ENV: title, _MESSAGE_ENV: message},
|
|
73
|
+
)
|
|
74
|
+
if kind == "powershell":
|
|
75
|
+
pwsh = shutil.which("pwsh") or shutil.which("powershell") or "powershell"
|
|
76
|
+
return (
|
|
77
|
+
[pwsh, "-NoProfile", "-NonInteractive", "-Command", _WIN_TOAST_PS],
|
|
78
|
+
{_TITLE_ENV: title, _MESSAGE_ENV: message},
|
|
79
|
+
)
|
|
80
|
+
# `--` ends GLib option parsing: an untrusted title/message beginning with
|
|
81
|
+
# `-`/`--` (e.g. a plugin veto reason of `--help`) is then taken as positional
|
|
82
|
+
# SUMMARY/BODY text, not parsed as a notify-send option.
|
|
83
|
+
return (["notify-send", "--app-name=froid-loop", "--", title, message], {})
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def notify(policy: Policy, run_dir: Path, title: str, message: str) -> None:
|
|
87
|
+
"""Best-effort human notification: append the ATTENTION file (if notify.file)
|
|
88
|
+
and fire a native desktop notification (if notify.desktop). Never raises — a
|
|
89
|
+
failing notifier must not crash the run. Headless CI cannot observe a real
|
|
90
|
+
notification (no macOS job), so the native macOS/Windows paths are unit-tested
|
|
91
|
+
at the command-construction level; verify visual delivery manually per OS."""
|
|
92
|
+
if policy.notify.file:
|
|
93
|
+
stamp = time.strftime("%Y-%m-%d %H:%M:%S")
|
|
94
|
+
try:
|
|
95
|
+
with (run_dir / ATTENTION_FILE).open("a", encoding="utf-8") as f:
|
|
96
|
+
f.write(f"[{stamp}] {title}: {message}\n")
|
|
97
|
+
except OSError:
|
|
98
|
+
# observe-degrade: an unwritable ATTENTION file is observability,
|
|
99
|
+
# never a reason to break the loop (the _write_heartbeat doctrine,
|
|
100
|
+
# already applied to this same call in the adapter budget guards).
|
|
101
|
+
# Without it the "never raises" contract above was false for the
|
|
102
|
+
# file half, and an unwritable run dir turned an advisory notice
|
|
103
|
+
# into a run crash at every record-a-decision site. The journal
|
|
104
|
+
# entry each caller writes first stays the durable record.
|
|
105
|
+
pass
|
|
106
|
+
# Native desktop notification per platform: osascript (macOS), a best-effort
|
|
107
|
+
# WinRT PowerShell toast (Windows), notify-send (Linux). None → silently skip;
|
|
108
|
+
# `validate` and run start warn separately when notify.desktop is inert here.
|
|
109
|
+
if policy.notify.desktop:
|
|
110
|
+
kind = desktop_notifier_kind()
|
|
111
|
+
if kind:
|
|
112
|
+
argv, env = _notifier_argv(kind, title, message)
|
|
113
|
+
try:
|
|
114
|
+
subprocess.run(
|
|
115
|
+
argv,
|
|
116
|
+
timeout=10,
|
|
117
|
+
capture_output=True,
|
|
118
|
+
env={**os.environ, **env} if env else None,
|
|
119
|
+
)
|
|
120
|
+
except (subprocess.SubprocessError, OSError, ValueError):
|
|
121
|
+
# best-effort: a failing notifier must never crash the run. ValueError
|
|
122
|
+
# covers an embedded NUL in the untrusted title/message, which reaches
|
|
123
|
+
# argv (notify-send) or an env value (osascript/PowerShell) and makes
|
|
124
|
+
# subprocess.run raise `ValueError: embedded null byte`.
|
|
125
|
+
pass
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def pause_at_epic_boundary(policy: Policy) -> bool:
|
|
129
|
+
return policy.gates.mode in ("per-epic", "per-story-spec-approval")
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def pause_after_spec(policy: Policy) -> bool:
|
|
133
|
+
return policy.gates.mode == "per-story-spec-approval"
|