froid-loop 0.11.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. froid_loop/__init__.py +11 -0
  2. froid_loop/__main__.py +12 -0
  3. froid_loop/adapters/__init__.py +3 -0
  4. froid_loop/adapters/base.py +254 -0
  5. froid_loop/adapters/entrypoints.py +63 -0
  6. froid_loop/adapters/env_fault.py +290 -0
  7. froid_loop/adapters/generic.py +2013 -0
  8. froid_loop/adapters/mock.py +49 -0
  9. froid_loop/adapters/multiplexer.py +914 -0
  10. froid_loop/adapters/opencode_http.py +1687 -0
  11. froid_loop/adapters/profile.py +650 -0
  12. froid_loop/adapters/psmux_backend.py +1428 -0
  13. froid_loop/adapters/registry.py +322 -0
  14. froid_loop/adapters/tmux_backend.py +35 -0
  15. froid_loop/adapters/tmux_base.py +630 -0
  16. froid_loop/checks.py +187 -0
  17. froid_loop/cli.py +5041 -0
  18. froid_loop/data/__init__.py +0 -0
  19. froid_loop/data/froid_loop_hook.py +228 -0
  20. froid_loop/data/froid_loop_probe_hook.py +88 -0
  21. froid_loop/data/plugins/example/plugin.toml +21 -0
  22. froid_loop/data/plugins/tea/plugin.toml +184 -0
  23. froid_loop/data/plugins/tea/tea_plugin.py +258 -0
  24. froid_loop/data/plugins/unity/plugin.toml +140 -0
  25. froid_loop/data/plugins/unity/unity_assets/FroidLoop.Unity.Editor.asmdef +16 -0
  26. froid_loop/data/plugins/unity/unity_assets/FroidLoop.Unity.Editor.asmdef.meta +7 -0
  27. froid_loop/data/plugins/unity/unity_assets/SceneAutoSaveGuard.cs +221 -0
  28. froid_loop/data/plugins/unity/unity_assets/SceneAutoSaveGuard.cs.meta +11 -0
  29. froid_loop/data/plugins/unity/unity_assets/_folders/Editor.meta +8 -0
  30. froid_loop/data/plugins/unity/unity_assets/_folders/FroidLoop.meta +8 -0
  31. froid_loop/data/plugins/unity/unity_cleanup.py +125 -0
  32. froid_loop/data/plugins/unity/unity_dialog_probe.py +239 -0
  33. froid_loop/data/plugins/unity/unity_facts.md +17 -0
  34. froid_loop/data/plugins/unity/unity_plugin.py +415 -0
  35. froid_loop/data/plugins/unity/unity_quiesce.py +234 -0
  36. froid_loop/data/plugins/unity/unity_ready.py +230 -0
  37. froid_loop/data/plugins/unity/unity_seed_assets.py +298 -0
  38. froid_loop/data/plugins/unity/unity_setup.py +551 -0
  39. froid_loop/data/plugins/unity/unity_teardown.py +362 -0
  40. froid_loop/data/profiles/antigravity.toml +52 -0
  41. froid_loop/data/profiles/claude.toml +85 -0
  42. froid_loop/data/profiles/codex.toml +22 -0
  43. froid_loop/data/profiles/copilot.toml +52 -0
  44. froid_loop/data/profiles/gemini.toml +26 -0
  45. froid_loop/data/profiles/opencode.toml +54 -0
  46. froid_loop/data/settings/core.toml +458 -0
  47. froid_loop/data/skills/README.md +93 -0
  48. froid_loop/data/skills/froid-loop-resolve/SKILL.md +288 -0
  49. froid_loop/data/skills/froid-loop-setup/SKILL.md +161 -0
  50. froid_loop/data/skills/froid-loop-setup/assets/module-help.csv +3 -0
  51. froid_loop/data/skills/froid-loop-setup/assets/module.yaml +19 -0
  52. froid_loop/data/skills/froid-loop-sweep/SKILL.md +100 -0
  53. froid_loop/data/skills/froid-loop-sweep/automation-mode.md +127 -0
  54. froid_loop/data/skills/froid-loop-sweep/deferred-work-format.md +302 -0
  55. froid_loop/data/skills/froid-loop-sweep/migration-mode.md +86 -0
  56. froid_loop/decisions.py +202 -0
  57. froid_loop/deferredwork.py +2282 -0
  58. froid_loop/devcontract.py +892 -0
  59. froid_loop/diagnostics.py +1104 -0
  60. froid_loop/documents.py +532 -0
  61. froid_loop/engine.py +7732 -0
  62. froid_loop/envvars.py +111 -0
  63. froid_loop/escalation.py +225 -0
  64. froid_loop/events.py +266 -0
  65. froid_loop/fences.py +103 -0
  66. froid_loop/froidconfig.py +226 -0
  67. froid_loop/frontmatter.py +526 -0
  68. froid_loop/gates.py +133 -0
  69. froid_loop/install.py +2936 -0
  70. froid_loop/journal.py +178 -0
  71. froid_loop/machine.py +148 -0
  72. froid_loop/model.py +898 -0
  73. froid_loop/operatoractions.py +474 -0
  74. froid_loop/platform_util.py +1490 -0
  75. froid_loop/plugins/__init__.py +64 -0
  76. froid_loop/plugins/bus.py +259 -0
  77. froid_loop/plugins/context.py +319 -0
  78. froid_loop/plugins/loader.py +145 -0
  79. froid_loop/plugins/manifest.py +279 -0
  80. froid_loop/plugins/model.py +296 -0
  81. froid_loop/plugins/registry.py +245 -0
  82. froid_loop/plugins/trust.py +75 -0
  83. froid_loop/policy.py +1569 -0
  84. froid_loop/probe.py +1044 -0
  85. froid_loop/process_host.py +408 -0
  86. froid_loop/recovery_flow.py +1561 -0
  87. froid_loop/resolve.py +283 -0
  88. froid_loop/runs.py +4715 -0
  89. froid_loop/runsetup.py +1293 -0
  90. froid_loop/sanitize.py +593 -0
  91. froid_loop/settings_schema.py +276 -0
  92. froid_loop/signals.py +160 -0
  93. froid_loop/sprintstatus.py +609 -0
  94. froid_loop/statemachine.py +57 -0
  95. froid_loop/stories.py +615 -0
  96. froid_loop/stories_engine.py +796 -0
  97. froid_loop/sweep.py +1892 -0
  98. froid_loop/tokens.py +196 -0
  99. froid_loop/tui/__init__.py +11 -0
  100. froid_loop/tui/app.py +1584 -0
  101. froid_loop/tui/data.py +840 -0
  102. froid_loop/tui/launch.py +1003 -0
  103. froid_loop/tui/screens/__init__.py +1 -0
  104. froid_loop/tui/screens/dashboard.py +1071 -0
  105. froid_loop/tui/screens/modals.py +943 -0
  106. froid_loop/tui/screens/settings_screen.py +477 -0
  107. froid_loop/tui/settings.py +135 -0
  108. froid_loop/tui/widgets.py +981 -0
  109. froid_loop/verify.py +4545 -0
  110. froid_loop/workspace.py +320 -0
  111. froid_loop/worktree_flow.py +2301 -0
  112. froid_loop-0.11.1.dist-info/METADATA +728 -0
  113. froid_loop-0.11.1.dist-info/RECORD +116 -0
  114. froid_loop-0.11.1.dist-info/WHEEL +4 -0
  115. froid_loop-0.11.1.dist-info/entry_points.txt +2 -0
  116. froid_loop-0.11.1.dist-info/licenses/LICENSE +30 -0
@@ -0,0 +1,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"