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,892 @@
1
+ """Translate the generic `froid-build-auto` skill's output into the orchestrator's
2
+ result.json contract.
3
+
4
+ Alex Verhovsky's upstream `froid-build-auto` skill (Froid Plane PR #2500) is a
5
+ decoupled autonomous-coding primitive: it writes NO result.json. Its outcome
6
+ lives in the spec it produced — `status:` in the frontmatter (the machine-
7
+ consumable signal) plus an appended `## Auto Run Result` prose section (intended
8
+ for an LLM deciding how to handle failure). This module is the thin Python shim
9
+ that turns that on-disk spec into the legacy result dict that verify.py /
10
+ escalation.py already consume, so the rest of the pipeline stays unchanged.
11
+
12
+ DOCTRINE — never trust prose for a gate. The frontmatter `status:` read straight
13
+ off disk is authoritative; the `## Auto Run Result` prose is only used to route
14
+ the blocked→PAUSE decision and to carry a human-readable detail. Where the two
15
+ disagree we surface it (`status_consistent=False`) so the caller can fail safe
16
+ (treat a mismatch as a retry rather than silently proceeding). Every real
17
+ deterministic gate (git baseline, worktree-changed, sprint advancement, dw_id
18
+ match) still runs in verify.py against actual on-disk state.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import hashlib
24
+ import re
25
+ from collections.abc import Mapping, Sequence, Set
26
+ from dataclasses import dataclass
27
+ from pathlib import Path
28
+ from typing import Any
29
+
30
+ from . import deferredwork
31
+ from .fences import fenced as _fenced
32
+ from .frontmatter import _edit_frontmatter_block, auto_dev_baseline_of, status_of
33
+ from .platform_util import atomic_write_bytes, atomic_write_bytes_confined
34
+ from .verify import DEV_WORKFLOW, operator_actions_of, read_frontmatter
35
+
36
+ # The section the skill appends on EVERY terminal path (success and blocked),
37
+ # per its step-02/03/04 finalize instructions. Its presence is our completion
38
+ # marker on the spec-watch fallback; the `Status:` line within it is the only
39
+ # field we parse structurally — everything else is free prose.
40
+ AUTO_RUN_HEADING_RE = re.compile(r"^##\s+Auto Run Result\s*$", re.MULTILINE)
41
+ # `Status:` possibly bulleted ("- Status: blocked") / bolded ("**Status:** done"),
42
+ # case-insensitive on the label, value is the first token on the line.
43
+ STATUS_LINE_RE = re.compile(
44
+ r"^\s*(?:[-*]\s*)?(?:\*\*)?status(?:\*\*)?\s*:\s*(?:\*\*)?\s*([A-Za-z-]+)",
45
+ re.IGNORECASE | re.MULTILINE,
46
+ )
47
+
48
+ # Terminal frontmatter statuses the skill can leave behind.
49
+ DONE = "done"
50
+ BLOCKED = "blocked"
51
+ # The story's agent-doable work is finished, but its acceptance criteria include
52
+ # external actions only a human can perform (#335). Terminal beside DONE and
53
+ # BLOCKED, and deliberately NOT rendered as an escalation: BLOCKED means the
54
+ # session could not proceed and the run must halt, while a park means it finished
55
+ # everything an agent could and the run should move on. Synthesizing a CRITICAL
56
+ # here would collapse that distinction into the pause channel — exactly the
57
+ # failure the state exists to avoid.
58
+ AWAITING_OPERATOR = "awaiting-operator"
59
+
60
+ # The status a plan-halt dispatch leaves behind: under folder+id dispatch a
61
+ # `Halt after planning.` directive makes the skill HALT right after the
62
+ # Ready-for-Development gate, at `ready-for-dev`. This is a *successful* terminal
63
+ # outcome for that leg (the plan is done, awaiting human review / implementation)
64
+ # — but ONLY when the caller asked for the halt. Without the directive the same
65
+ # status is a died-mid-flight non-terminal (it stays in RECONCILABLE_FROM), so
66
+ # the seam is gated on `plan_halt`, never on the status alone.
67
+ PLAN_HALT_STATUS = "ready-for-dev"
68
+
69
+ # Frontmatter statuses a half-finalized generic spec may be reconciled FROM when
70
+ # its prose terminal `## Auto Run Result` Status is `done`. Deliberately an
71
+ # allowlist: anything else (already-`done`, `blocked`, or an unknown custom token)
72
+ # is left untouched, so reconciliation can never override a status the skill set on
73
+ # purpose. `""` covers a blank or missing frontmatter `status:` — `status_of`
74
+ # normalizes a YAML-null value to `""` alongside the missing key (#358), and
75
+ # `reset_spec_status` fills/inserts the line in either case. `in-review` is included because step-04 sets
76
+ # it transiently at the start of a review pass; the skill self-finalizes to `done`,
77
+ # so a spec left AT `in-review` with a prose `done` result is a mid-review interrupt
78
+ # safe to reconcile forward. The intent-gap patch-restore re-drive (Froid Plane
79
+ # #2564) deliberately re-arms the spec TO `in-review` before a session (so step-01
80
+ # routes straight to step-04 on the restored diff) — but that is the pre-session
81
+ # status the re-driven skill then advances past; it never LEAVES a spec at
82
+ # `in-review` as a terminal, so the reconcile allowlist semantics are unchanged.
83
+ RECONCILABLE_FROM = frozenset({"", "draft", "ready-for-dev", "in-progress", "in-review"})
84
+
85
+ # The leading `---\n …frontmatter… \n---` block, captured in three parts so the
86
+ # body can be rewritten while the fences stay byte-identical.
87
+ _FRONTMATTER_RE = re.compile(r"\A(---\r?\n)(.*?\r?\n)(---[ \t]*\r?\n)", re.DOTALL)
88
+ # A frontmatter `status:` line, preserving indent, the `: ` gap, optional quotes,
89
+ # and any trailing inline comment. Only the value token is rewritten. The value is
90
+ # `*` (not `+`) so a present-but-empty status (`status:` / `status: ""`) is matched
91
+ # and filled — a froid-build-auto template can leave it blank.
92
+ _FM_STATUS_RE = re.compile(
93
+ r"^(?P<pre>[ \t]*status[ \t]*:[ \t]*)(?P<q>['\"]?)(?P<val>[A-Za-z-]*)(?P=q)(?P<rest>.*)$",
94
+ re.MULTILINE,
95
+ )
96
+
97
+ # The skill's no-spec fallback artifact (HALT when {spec_file} is unknown/missing):
98
+ # `{implementation_artifacts}/<skill>-result-<slug-or-timestamp>.md`. It carries a
99
+ # terminal frontmatter `status:` but no `## Auto Run Result` heading. BOTH eras are
100
+ # listed and matched unconditionally: the artifact is named after whichever skill
101
+ # wrote it, and a run can read an artifact left by the other era (a resume across an
102
+ # upstream upgrade, or a project mid-migration), so this must never be keyed on the
103
+ # skill name resolved on disk today.
104
+ FALLBACK_RESULT_PREFIXES = ("froid-build-auto-result-", "froid-dev-auto-result-")
105
+
106
+
107
+ @dataclass(frozen=True)
108
+ class AutoRunResult:
109
+ """Parsed `## Auto Run Result` section. `present` is False when the spec has
110
+ no such section yet (the session has not reached a terminal step)."""
111
+
112
+ present: bool
113
+ status: str # lowercased Status: value, or "" when absent/unparsed
114
+ detail: str # the prose body after the heading, trimmed (human-readable)
115
+
116
+
117
+ def _section_headings(
118
+ text: str, pattern: re.Pattern[str] = AUTO_RUN_HEADING_RE
119
+ ) -> list[re.Match[str]]:
120
+ """`pattern` matches that are real section headings. A heading quoted inside
121
+ a fenced code block (a frozen intent showing an example of the terminal
122
+ section, a log excerpt) is documentation, not structure — treating it as
123
+ terminal would let such a spec read as a result artifact from the agent's
124
+ first save (#52).
125
+
126
+ `pattern` is a parameter rather than a hard-coded `AUTO_RUN_HEADING_RE`
127
+ because the ``## Operator Confirmation`` section needs the identical fence
128
+ reading for the identical reason, and a second copy of `_fenced`'s
129
+ open-marker walk is exactly the kind of near-duplicate that drifts."""
130
+ return [m for m in pattern.finditer(text) if not _fenced(text, m.start())]
131
+
132
+
133
+ def _next_heading_start(text: str, offset: int) -> int:
134
+ """Offset of the first non-fenced same-level (`## `) heading at/after
135
+ `offset`, or end-of-text — the shared section boundary. Fenced `## ` lines
136
+ inside the section (quoted shell comments, log output) are content, not
137
+ boundaries."""
138
+ for nxt in re.finditer(r"^##\s", text, re.MULTILINE):
139
+ if nxt.start() >= offset and not _fenced(text, nxt.start()):
140
+ return nxt.start()
141
+ return len(text)
142
+
143
+
144
+ def parse_auto_run_result(text: str) -> AutoRunResult:
145
+ """Tolerantly extract the trailing `## Auto Run Result` section from a spec.
146
+
147
+ Reads the LAST real (non-fenced) such heading (the finalize step appends; a
148
+ re-derivation loop could in principle append more than one — the last is the
149
+ live outcome) and pulls its `Status:` value plus the remaining prose as
150
+ detail, spanning to the next real same-level heading.
151
+ """
152
+ matches = _section_headings(text)
153
+ if not matches:
154
+ return AutoRunResult(present=False, status="", detail="")
155
+ last = matches[-1]
156
+ body = text[last.end() : _next_heading_start(text, last.end())]
157
+ status_m = STATUS_LINE_RE.search(body)
158
+ status = status_m.group(1).strip().lower() if status_m else ""
159
+ return AutoRunResult(present=True, status=status, detail=body.strip())
160
+
161
+
162
+ # ------------------------------------------------ deferred review findings
163
+ #
164
+ # Since Froid Plane #2640 the dev primitive records findings triaged as `defer`
165
+ # in its spec's frontmatter. The parser stays content-keyed so an older skill
166
+ # (field absent) and a newer skill with no findings are indistinguishable.
167
+ DEFERRED_FIELD = "deferred"
168
+ _SUMMARY_LIMIT = 200
169
+ _EVIDENCE_LIMIT = 1000
170
+ _LOCATION_LIMIT = 200
171
+
172
+
173
+ @dataclass(frozen=True)
174
+ class DeferredFinding:
175
+ """One well-formed deferred finding, normalized for a ledger entry."""
176
+
177
+ summary: str
178
+ evidence: str
179
+ location: str
180
+ severity: str
181
+ fingerprint: str
182
+
183
+
184
+ def harvest_fingerprint(*parts: str) -> str:
185
+ """Return a stable short identity over NUL-separated ledger values.
186
+
187
+ A NUL inside a value is indistinguishable from the separator, so refuse it
188
+ rather than assigning two distinct findings the same structural identity.
189
+ The frontmatter parser reports such values as malformed before calling this
190
+ helper; the guard also keeps direct callers from creating an ambiguous key.
191
+ """
192
+ if any("\0" in part for part in parts):
193
+ raise ValueError("fingerprint parts must not contain NUL characters")
194
+ return hashlib.sha1(
195
+ "\0".join(parts).encode("utf-8"),
196
+ usedforsecurity=False,
197
+ ).hexdigest()[:12]
198
+
199
+
200
+ def _flatten(value: Any, limit: int) -> str:
201
+ """Collapse a YAML scalar to one clamped line.
202
+
203
+ Strip after clamping because the cut can land on a join space. Keeping that
204
+ cleanup here makes the value written to the ledger and the value used in its
205
+ fingerprint identical.
206
+ """
207
+ if value is None:
208
+ return ""
209
+ return " ".join(str(value).split())[:limit].strip()
210
+
211
+
212
+ def _is_yaml_scalar(value: Any) -> bool:
213
+ """Whether PyYAML's loaded value came from a scalar-shaped node."""
214
+ # str/bytes are Sequences in Python but scalar values in YAML. SafeLoader's
215
+ # collection nodes otherwise become mappings, sequences, or sets.
216
+ return isinstance(value, (str, bytes)) or not isinstance(value, (Mapping, Sequence, Set))
217
+
218
+
219
+ def _contains_nul(value: Any) -> bool:
220
+ """Whether a YAML text scalar contains a NUL before normalization/clamping."""
221
+ return (isinstance(value, str) and "\0" in value) or (
222
+ isinstance(value, bytes) and b"\0" in value
223
+ )
224
+
225
+
226
+ def parse_deferred_findings(fm: dict[str, Any]) -> tuple[list[DeferredFinding], list[str]]:
227
+ """Parse a frontmatter ``deferred:`` list into findings and malformed notes.
228
+
229
+ Missing, null, and empty lists mean no findings. Malformed items cost only
230
+ themselves; well-formed siblings still parse. Text fields accept YAML
231
+ scalars (numeric and boolean scalars are string-normalized), never
232
+ collections or embedded NULs. This observation path never raises for the
233
+ YAML shapes it accepts.
234
+ """
235
+ raw = fm.get(DEFERRED_FIELD)
236
+ if raw is None:
237
+ return [], []
238
+ if not isinstance(raw, list):
239
+ return [], [f"`{DEFERRED_FIELD}:` is not a list (got {type(raw).__name__})"]
240
+
241
+ findings: list[DeferredFinding] = []
242
+ malformed: list[str] = []
243
+ for i, item in enumerate(raw, start=1):
244
+ if not isinstance(item, dict):
245
+ malformed.append(f"item {i}: not a mapping (got {type(item).__name__})")
246
+ continue
247
+ summary_raw = item.get("summary")
248
+ if not _is_yaml_scalar(summary_raw):
249
+ malformed.append(
250
+ f"item {i}: `summary` is not a scalar (got {type(summary_raw).__name__})"
251
+ )
252
+ continue
253
+ bad_optional = next(
254
+ (field for field in ("evidence", "location") if not _is_yaml_scalar(item.get(field))),
255
+ None,
256
+ )
257
+ if bad_optional is not None:
258
+ value = item[bad_optional]
259
+ malformed.append(
260
+ f"item {i}: `{bad_optional}` is not a scalar (got {type(value).__name__})"
261
+ )
262
+ continue
263
+ raw_text_values = {
264
+ "summary": summary_raw,
265
+ "evidence": item.get("evidence"),
266
+ "location": item.get("location"),
267
+ }
268
+ bad_nul = next(
269
+ (field for field, value in raw_text_values.items() if _contains_nul(value)), None
270
+ )
271
+ if bad_nul is not None:
272
+ malformed.append(f"item {i}: `{bad_nul}` contains a NUL character")
273
+ continue
274
+ summary = _flatten(summary_raw, _SUMMARY_LIMIT)
275
+ if not summary:
276
+ malformed.append(f"item {i}: no usable `summary`")
277
+ continue
278
+ evidence = _flatten(item.get("evidence"), _EVIDENCE_LIMIT)
279
+ location = _flatten(item.get("location"), _LOCATION_LIMIT)
280
+ findings.append(
281
+ DeferredFinding(
282
+ summary=summary,
283
+ evidence=evidence,
284
+ location=location,
285
+ severity=deferredwork.SEVERITY_ALIASES.get(
286
+ str(item.get("severity", "")).strip().lower(), ""
287
+ ),
288
+ fingerprint=harvest_fingerprint(summary, location),
289
+ )
290
+ )
291
+ return findings, malformed
292
+
293
+
294
+ @dataclass(frozen=True)
295
+ class SynthResult:
296
+ """A synthesized result.json plus the cross-check signal. `result_json` is
297
+ None when the spec has not terminated yet (no `## Auto Run Result` and no
298
+ terminal frontmatter status), i.e. nothing to translate."""
299
+
300
+ result_json: dict[str, Any] | None
301
+ status_consistent: bool
302
+
303
+
304
+ def _read_text_or_empty(path: Path) -> str:
305
+ """Read a spec on the *read-back* path, degrading an unreadable file to "".
306
+
307
+ An absent, binary/truncated, or unreadable spec carries no parseable result
308
+ section, so it reads exactly like a spec that has not terminated yet — the
309
+ caller then waits, nudges, or keeps its verdict. Never a crash: this runs on
310
+ the observation path, where the orchestrator's job is to classify what it
311
+ finds, not to trust it. (UnicodeDecodeError is a ValueError, not an OSError.)
312
+
313
+ The *repair* path deliberately does the opposite — `reset_spec_status` and
314
+ `strip_auto_run_result` let an unreadable spec raise, because silently
315
+ skipping a rewrite leaves the spec in a state the caller believes it fixed.
316
+ """
317
+ try:
318
+ return path.read_text(encoding="utf-8")
319
+ except (OSError, UnicodeDecodeError):
320
+ return ""
321
+
322
+
323
+ def synthesize_result(
324
+ spec_path: Path,
325
+ *,
326
+ story_key: str | None,
327
+ dw_ids: list[str] | None = None,
328
+ plan_halt: bool = False,
329
+ ) -> SynthResult:
330
+ """Build the legacy result dict from the generic skill's on-disk spec.
331
+
332
+ Returns ``SynthResult(None, True)`` when the spec carries no terminal signal
333
+ yet (caller should keep waiting / treat the session as not-yet-complete).
334
+ The dict's ``workflow`` is forged to ``auto-dev`` so verify.py's anti-wrong-
335
+ skill guard passes; ``baseline_commit`` is taken from the skill's
336
+ ``baseline_revision`` frontmatter (its name for the same thing). A blocked
337
+ outcome is rendered as a single CRITICAL escalation so ``decide_dev`` PAUSEs
338
+ unchanged — the generic skill has no severity tiers, and per the integration
339
+ decision every block maps to PAUSE.
340
+
341
+ ``plan_halt`` is the stories-mode expected-terminal seam: on the first leg of
342
+ a ``spec_checkpoint`` dispatch the caller sends ``Halt after planning.`` and
343
+ the skill HALTs at ``ready-for-dev``. Passing ``plan_halt=True`` treats that
344
+ status as a *successful* terminal — the returned dict carries
345
+ ``status="ready-for-dev"``, no escalation, and a ``plan_halt=True`` marker so
346
+ verify/engine expect a planned (not implemented) spec. Without ``plan_halt``
347
+ the default is unchanged: ``ready-for-dev`` is non-terminal (died mid-flight)
348
+ and returns ``SynthResult(None, True)``. This composes with the engine's
349
+ ``_reconcile_generic_terminal_status`` — that path only reconciles a spec
350
+ whose prose ``## Auto Run Result`` says ``done`` while the frontmatter lags,
351
+ so a plan-halt ``ready-for-dev`` (no such prose) is never reconciled to
352
+ ``done`` and this leg's success outcome is not clobbered.
353
+ """
354
+ try:
355
+ fm = read_frontmatter(spec_path)
356
+ except OSError:
357
+ # Same degrade as `_read_text_or_empty` below, for the same reason: this is
358
+ # the read-back path. An unreadable spec is not evidence a session
359
+ # finished, so treat it exactly like one that has not terminated yet — the
360
+ # caller keeps polling, and a fault that persists past the grace window
361
+ # lands as a stall/timeout verdict that `_post_kill_reconcile` can still
362
+ # rescue. Crashing here would take the whole run down for a spec the CLI
363
+ # merely had open for writing.
364
+ return SynthResult(result_json=None, status_consistent=True)
365
+ # Through `status_of`, never a local `str(...)`: it normalizes a YAML-null
366
+ # `status:` (the blank line a froid-build-auto template legitimately leaves — see
367
+ # `_FM_STATUS_RE`) to `""` alongside the missing key. A local stringification
368
+ # made that `"none"`, which is truthy, so the prose fallback below never fired
369
+ # and a blank-frontmatter/prose-`done` spec synthesized `status="none"` with
370
+ # `status_consistent=False` — the exact shape `_post_kill_reconcile` documents
371
+ # as rescuable (#369).
372
+ fm_status = status_of(fm)
373
+ arr = parse_auto_run_result(_read_text_or_empty(spec_path))
374
+
375
+ terminal = (
376
+ (DONE, BLOCKED, AWAITING_OPERATOR, PLAN_HALT_STATUS)
377
+ if plan_halt
378
+ else (DONE, BLOCKED, AWAITING_OPERATOR)
379
+ )
380
+ # Not terminal yet: no result section AND frontmatter not at a terminal state.
381
+ if not arr.present and fm_status not in terminal:
382
+ return SynthResult(result_json=None, status_consistent=True)
383
+
384
+ # Authoritative status = frontmatter (read off disk). Prose status only
385
+ # cross-checks it. When the prose is present and disagrees, flag it.
386
+ status = fm_status or arr.status
387
+ consistent = (not arr.present) or (not arr.status) or (arr.status == status)
388
+
389
+ # One reader, shared with verify's baseline-match gate, so the two halves of
390
+ # the contract cannot disagree about which key wins (#716).
391
+ baseline = auto_dev_baseline_of(fm)
392
+
393
+ escalations: list[dict[str, Any]] = []
394
+ if status == BLOCKED or arr.status == BLOCKED:
395
+ detail = arr.detail or "generic dev session reported a blocked outcome"
396
+ escalations.append({"type": "blocked", "severity": "CRITICAL", "detail": detail[:2000]})
397
+
398
+ result: dict[str, Any] = {
399
+ "workflow": DEV_WORKFLOW,
400
+ "story_key": story_key,
401
+ "spec_file": str(spec_path),
402
+ "baseline_commit": baseline,
403
+ "status": status,
404
+ "escalations": escalations,
405
+ }
406
+ if dw_ids:
407
+ result["dw_ids"] = list(dw_ids)
408
+ # froid-build-auto (Froid Plane PR #2505) self-reviews inline and, on a `done`
409
+ # exit, sets `followup_review_recommended: true` when its review-driven
410
+ # changes warrant an independent second-opinion pass. The skill never sets it
411
+ # on a blocked exit, so only carry it through on `done`.
412
+ if status == DONE:
413
+ result["followup_review_recommended"] = bool(fm.get("followup_review_recommended", False))
414
+ # A park's obligations travel with its result so the engine never has to
415
+ # re-read the spec to learn them. Folded on the awaiting-operator status
416
+ # ONLY: an `operator_actions:` list left on a `done` or `blocked` spec is
417
+ # not a park, and carrying it would let a story register obligations the
418
+ # verify gates never held it to. Malformed shapes read as [] here and are
419
+ # refused by verify's non-empty gate, which owns the retry + feedback.
420
+ if status == AWAITING_OPERATOR:
421
+ result["operator_actions"] = list(operator_actions_of(fm))
422
+ # Mark the clean plan-halt success so verify/engine expect a planned spec
423
+ # (status ready-for-dev, no implementation work). Never marked when a block
424
+ # escalation is present — that routes to PAUSE, not a plan-review pause.
425
+ if plan_halt and status == PLAN_HALT_STATUS and not escalations:
426
+ result["plan_halt"] = True
427
+ return SynthResult(result_json=result, status_consistent=consistent)
428
+
429
+
430
+ def find_result_artifact(impl_artifacts: Path, *, since_ns: int) -> Path | None:
431
+ """Spec-watch fallback: locate THIS session's output artifact.
432
+
433
+ This is how the GenericDevAdapter acquires its result: the generic skill
434
+ writes no result.json, so on the session's Stop event we locate the spec it
435
+ produced. The common case is a `spec-*.md` carrying a terminal `## Auto Run
436
+ Result` section (appended by the skill's HALT on success AND blocked, when a
437
+ spec exists). The skill's no-spec fallback — `froid-build-auto-result-*.md`,
438
+ or `froid-dev-auto-result-*.md` pre-rename, written when intent was too
439
+ unclear to even create a spec — carries a terminal frontmatter `status:` but
440
+ NO `## Auto Run Result` heading, so it is matched by filename instead. Both
441
+ spellings match: see `FALLBACK_RESULT_PREFIXES`. Scans `impl_artifacts` for
442
+ the most-recently-modified qualifying markdown modified at/after `since_ns`
443
+ (the session launch floor, so a stale prior artifact can't be mistaken for
444
+ this run's output).
445
+ Returns None when nothing qualifies.
446
+ """
447
+ if not impl_artifacts.is_dir():
448
+ return None
449
+ best: tuple[int, Path] | None = None
450
+ for path in impl_artifacts.glob("*.md"):
451
+ try:
452
+ mtime_ns = path.stat().st_mtime_ns
453
+ except OSError:
454
+ continue
455
+ if not is_result_artifact(path, since_ns=since_ns):
456
+ continue
457
+ if best is None or mtime_ns > best[0]:
458
+ best = (mtime_ns, path)
459
+ return best[1] if best else None
460
+
461
+
462
+ def is_result_artifact(path: Path, *, since_ns: int) -> bool:
463
+ """Whether ONE file qualifies as a session's result artifact — the per-path
464
+ predicate `find_result_artifact` applies to each glob hit, factored out so a
465
+ caller that already KNOWS which spec the session owed (the orchestrator hands
466
+ the review session its path in the prompt) can test that one file instead of
467
+ scanning a directory shared with every concurrent run (#261).
468
+
469
+ Qualifies when the file was modified at/after ``since_ns`` (the session-launch
470
+ floor) AND either is the by-name no-spec fallback (`froid-build-auto-result-*`,
471
+ or `froid-dev-auto-result-*` pre-rename — it carries no heading by design, and
472
+ both spellings match unconditionally) or carries a real, non-fenced
473
+ ``## Auto Run Result`` heading — a fence-quoted example must not qualify the
474
+ spec (#52). An unreadable/undecodable candidate cannot be SHOWN to carry a
475
+ terminal section, so it does not qualify (UnicodeDecodeError is a ValueError,
476
+ so a bare ``except OSError`` let a torn mid-write spec crash the scan)."""
477
+ try:
478
+ if path.stat().st_mtime_ns < since_ns:
479
+ return False
480
+ except OSError:
481
+ return False
482
+ if path.name.startswith(FALLBACK_RESULT_PREFIXES):
483
+ return True
484
+ try:
485
+ text = path.read_text(encoding="utf-8")
486
+ except (OSError, UnicodeDecodeError):
487
+ return False
488
+ return bool(_section_headings(text))
489
+
490
+
491
+ def find_frontmatter_candidates(impl_artifacts: Path, *, since_ns: int) -> list[Path]:
492
+ """Missing-marker fallback scan (#224): specs this session finalized to a
493
+ terminal frontmatter ``status:`` WITHOUT appending the ``## Auto Run Result``
494
+ marker `find_result_artifact` keys on. The skill's HALT instructions make the
495
+ append unconditional, but compliance is intermittent — without this scan such
496
+ a spec is invisible to the harvest and a finished story rides stall-nudges to
497
+ timeout, then loses its work to a retry/DEFER cycle.
498
+
499
+ A candidate must be modified at/after `since_ns` (same session-launch floor
500
+ as the marker scan), carry ZERO real (non-fenced) marker headings, not be the
501
+ no-spec fallback file (that one is already matched by name on the normal
502
+ path), and have a terminal frontmatter ``status:`` (``done``, ``blocked``, or
503
+ ``awaiting-operator``). Returns ALL matches, most-recent first — the caller refuses to guess between several
504
+ and must apply its own stability fingerprint before synthesizing, because a
505
+ terminal frontmatter under a live window is weaker evidence than the marker.
506
+ """
507
+ if not impl_artifacts.is_dir():
508
+ return []
509
+ found: list[tuple[int, Path]] = []
510
+ for path in impl_artifacts.glob("*.md"):
511
+ try:
512
+ mtime_ns = path.stat().st_mtime_ns
513
+ except OSError:
514
+ continue
515
+ if not is_frontmatter_candidate(path, since_ns=since_ns):
516
+ continue
517
+ found.append((mtime_ns, path))
518
+ found.sort(key=lambda t: t[0], reverse=True)
519
+ return [p for _, p in found]
520
+
521
+
522
+ def is_frontmatter_candidate(path: Path, *, since_ns: int) -> bool:
523
+ """Whether ONE file qualifies for the missing-marker fallback — the per-path
524
+ predicate `find_frontmatter_candidates` applies to each glob hit, factored out
525
+ for the same reason as `is_result_artifact`: a caller holding the spec path the
526
+ session actually owed can test that file alone instead of a shared directory
527
+ (#261).
528
+
529
+ Qualifies when the file is modified at/after ``since_ns``, is NOT the no-spec
530
+ fallback (already matched by name on the marker path), carries ZERO real
531
+ (non-fenced) marker headings — one would put it in `find_result_artifact`'s
532
+ territory — and has a terminal frontmatter ``status:`` (``done``,
533
+ ``blocked``, or ``awaiting-operator``). Any
534
+ unreadable/undecodable read degrades to False, never an exception."""
535
+ if path.name.startswith(FALLBACK_RESULT_PREFIXES):
536
+ return False
537
+ try:
538
+ if path.stat().st_mtime_ns < since_ns:
539
+ return False
540
+ except OSError:
541
+ return False
542
+ try:
543
+ text = path.read_text(encoding="utf-8")
544
+ except (OSError, UnicodeDecodeError):
545
+ return False
546
+ if _section_headings(text):
547
+ return False # carries a real marker — the normal scan's territory
548
+ try:
549
+ fm = read_frontmatter(path)
550
+ except OSError:
551
+ return False
552
+ return status_of(fm) in (DONE, BLOCKED, AWAITING_OPERATOR)
553
+
554
+
555
+ def _atomic_write_spec(spec_path: Path, text: str, *, confine_root: Path) -> None:
556
+ """Rewrite ``spec_path`` with ``text`` via a same-directory temp file + atomic
557
+ rename, so an interrupted / short / disk-full write can never truncate the
558
+ canonical spec — a failed repair must lose no work (fault injection on the old
559
+ truncating ``write_text`` reduced a 46-byte spec to 12). Bytes are written
560
+ verbatim (``atomic_write_bytes``, not the text sibling): every caller here has
561
+ already captured and preserved the file's own line endings, and text mode's
562
+ ``newline=None`` default would re-translate ``\\n``→``\\r\\n`` on Windows. The
563
+ temp ends in ``.tmp`` (not ``.md``), so the ``*.md`` artifact scans never see
564
+ it. On any failure the temp file is removed and the error re-raised — the
565
+ callers impose best-effort, the writer never swallows.
566
+
567
+ Now a thin name over `platform_util`'s atomic byte writers (#379) rather than
568
+ a hand-rolled temp: same shape, plus an fsync before the replace and a temp
569
+ name unique per write instead of a fixed ``<spec>.md.tmp`` two concurrent
570
+ writers would collide on.
571
+
572
+ Which writer is the spec-writer chokepoint rule (#593), stated once in
573
+ `frontmatter.set_frontmatter_status` and implemented identically here and in
574
+ `verify.set_frontmatter_field`: a spec under ``confine_root`` is written
575
+ through the anchored, component-walked helper; a spec in an artifacts folder
576
+ configured outside the checkout keeps the plain ``follow_symlinks=False``
577
+ write. That no-follow keeps the name-replacing semantics the `atomic_replace`
578
+ here always had — the spec path comes from a session-driven scan, so writing
579
+ THROUGH a planted link would hand that session a host-side write wherever it
580
+ chose — and the confined arm needs no flag, because it never follows
581
+ anything. ``require_writable_target=True`` on both arms restores the
582
+ `PermissionError` an operator's read-only spec used to earn (#597).
583
+
584
+ ``confine_root`` is a REQUIRED keyword here and on all four public writers
585
+ that reach this one, so a caller that has not decided which checkout the spec
586
+ belongs to is a pyright error rather than an unconfined write. The two
587
+ `frontmatter`-side writers of these same files land on the identical pair of
588
+ calls (#379); this wrapper stays for its callers' ``str``-in signature and
589
+ this docstring."""
590
+ payload = text.encode("utf-8")
591
+ if spec_path.is_relative_to(confine_root):
592
+ atomic_write_bytes_confined(
593
+ spec_path, payload, confine_root=confine_root, require_writable_target=True
594
+ )
595
+ else:
596
+ atomic_write_bytes(spec_path, payload, follow_symlinks=False, require_writable_target=True)
597
+
598
+
599
+ def _render_status_line(line: str, m: re.Match[str], value: str) -> str:
600
+ """`reset_spec_status`'s replacement for a matched ``status:`` line: the
601
+ value moves, its quote style and any trailing inline comment stay.
602
+
603
+ Deliberately different from `frontmatter._replace_value`, which carries the
604
+ comment through but drops the quotes — that writer's callers read the result
605
+ back as a bare ``status: done``, this one's tests pin the quote style.
606
+ Sharing the VERIFICATION is the point; the formatting each already had
607
+ right."""
608
+ # Guarantee `key: value` spacing: a bare `status:` (no trailing space) would
609
+ # otherwise fill to `status:done` — invalid YAML, the key is lost.
610
+ pre = m.group("pre")
611
+ if not pre.endswith((" ", "\t")):
612
+ pre += " "
613
+ # When the value was blank with a trailing inline comment, `rest` begins at
614
+ # the `#`; abutting the value (`status: done# c`) makes the `#` part of the
615
+ # scalar instead of a comment. Re-insert a separating space.
616
+ rest = m.group("rest")
617
+ if rest.startswith("#"):
618
+ rest = " " + rest
619
+ q = m.group("q")
620
+ return f"{pre}{q}{value}{q}{rest}" + ("\n" if line.endswith("\n") else "")
621
+
622
+
623
+ def reset_spec_status(spec_path: Path, new_status: str, *, confine_root: Path) -> bool:
624
+ """Rewrite the frontmatter ``status:`` value of a spec in place.
625
+
626
+ Used by the generic-skill repair path: froid-build-auto self-finalizes a spec to
627
+ ``done``/``in-review``, and its step-01 routes such a spec to "ingest as
628
+ context, do not resume" — so to repair in place the orchestrator must re-open
629
+ the spec by flipping its status back to ``in-progress``. A minimal line edit
630
+ (not a YAML round-trip): preserves quote style and any trailing inline comment,
631
+ and touches ONLY the first frontmatter block — never a ``Status:`` line in the
632
+ prose body (e.g. the ``## Auto Run Result`` section). A present-but-empty status
633
+ is filled, and a frontmatter block with NO ``status:`` line at all gets one
634
+ inserted before the closing fence (the skill's template can leave it blank or
635
+ absent).
636
+
637
+ Returns True on a real change, False when the spec is absent, has no
638
+ frontmatter block, or is already at ``new_status``. Raises
639
+ `FrontmatterWriteError` when the reader can see a status the line edit cannot
640
+ safely move: `_FM_STATUS_RE` reads only a ``[A-Za-z-]*`` value, so it used to
641
+ fill ``status: |`` to ``status: done|`` and ``status: 123`` to
642
+ ``status: done123`` — silently, with a True return — and it rewrote a nested
643
+ ``meta.status:`` in preference to the real key. The shared
644
+ `frontmatter._edit_frontmatter_block` re-parses each trial edit before keeping
645
+ it, so those become a loud refusal instead of a corrupted spec. Only
646
+ `engine._reconcile_generic_terminal_status` reads the return; the raise is what
647
+ reaches the other three call sites, which treat it as a repair write."""
648
+ if not spec_path.is_file():
649
+ return False
650
+ # Raw read (not read_text), like the append/strip siblings: universal-newline
651
+ # translation would hand this writer an all-LF copy of a CRLF spec and
652
+ # `_atomic_write_spec` would then persist it, relaying every line ending in
653
+ # the file from a repair contracted to move one value.
654
+ #
655
+ # Reading the bytes as authored makes one asymmetry with
656
+ # `frontmatter.set_frontmatter_status` observable, and it is pinned rather
657
+ # than fixed: that writer splits with `splitlines`, which treats a bare `\r`
658
+ # as a line break, so a CR-only spec rewrites fine there. `_FRONTMATTER_RE`
659
+ # and `_FM_STATUS_RE` are line-oriented on `\r?\n`, so a CR-only spec finds
660
+ # no frontmatter block here and returns False. Widening them to `\r` would
661
+ # change what counts as a line for every reader keyed on these patterns
662
+ # (`^` in MULTILINE still would not follow), which is a larger contract than
663
+ # this fix owns — no FROID tool authors CR-only files.
664
+ text = spec_path.read_bytes().decode("utf-8")
665
+ fm = _FRONTMATTER_RE.match(text)
666
+ if not fm:
667
+ return False
668
+ head, body, tail = fm.group(1), fm.group(2), fm.group(3)
669
+ new_body = _edit_frontmatter_block(
670
+ body,
671
+ "status",
672
+ new_status,
673
+ pattern=_FM_STATUS_RE,
674
+ render=_render_status_line,
675
+ insert=True,
676
+ )
677
+ if new_body is None:
678
+ return False
679
+ _atomic_write_spec(
680
+ spec_path, head + new_body + tail + text[fm.end() :], confine_root=confine_root
681
+ )
682
+ return True
683
+
684
+
685
+ def strip_auto_run_result(spec_path: Path, *, confine_root: Path) -> bool:
686
+ """Remove every ``## Auto Run Result`` section from a spec, in place.
687
+
688
+ Companion to `reset_spec_status` on the re-drive path: re-opening a spec by
689
+ flipping only its frontmatter would leave the stale terminal section behind,
690
+ and `find_result_artifact` keys on that heading — the re-driven session's
691
+ very first save of the spec would then qualify as a terminal result. Each
692
+ section spans its heading to the next same-level heading (the shared
693
+ `parse_auto_run_result` boundary) or end-of-file; headings quoted inside
694
+ fenced code blocks are ignored on both ends. Returns True when a section was
695
+ removed, False when the spec is absent or no section was present.
696
+
697
+ Only an absent spec is guarded (a clean no-op, mirroring
698
+ `verify.set_frontmatter_status`); a present-but-unreadable spec or a failing
699
+ write is left to raise. Silently skipping the strip after the caller has
700
+ already flipped the frontmatter status would leave the re-opened spec carrying
701
+ its stale terminal section — the exact state that makes the re-driven session's
702
+ first save read as a result — so that failure must surface, not be swallowed."""
703
+ if not spec_path.is_file():
704
+ return False
705
+ # Raw read (not read_text) for the append's reason, in reverse: the append
706
+ # preserves the spec's endings, so a strip that read through universal
707
+ # newlines would not round-trip its own sibling's output — it would return a
708
+ # CRLF spec relaid to LF. An undecodable spec still raises UnicodeDecodeError
709
+ # (repair-write doctrine). A CR-only spec is invisible to
710
+ # `AUTO_RUN_HEADING_RE`'s MULTILINE `^` and no-ops — the same pinned
711
+ # asymmetry `reset_spec_status` documents above.
712
+ text = spec_path.read_bytes().decode("utf-8")
713
+ matches = _section_headings(text)
714
+ if not matches:
715
+ return False
716
+ kept: list[str] = []
717
+ pos = 0
718
+ for m in matches:
719
+ if m.start() < pos:
720
+ continue # heading inside a section already being removed
721
+ kept.append(text[pos : m.start()])
722
+ pos = _next_heading_start(text, m.end())
723
+ kept.append(text[pos:])
724
+ _atomic_write_spec(spec_path, "".join(kept), confine_root=confine_root)
725
+ return True
726
+
727
+
728
+ # Provenance stamped into a synthesized `## Auto Run Result` section so a human
729
+ # (or a later re-derivation) can tell an orchestrator-repaired marker from one
730
+ # the skill wrote itself. Single line (no internal newlines) so the writer's
731
+ # line-ending detection governs every break in the appended block.
732
+ ORCHESTRATOR_SYNTH_NOTE = (
733
+ "_Appended by the froid-loop orchestrator (missing-marker repair, #224): the "
734
+ "session finalized this spec's frontmatter without its `## Auto Run Result` "
735
+ "marker, so the orchestrator synthesized the result from the frontmatter and "
736
+ "appended this section._"
737
+ )
738
+
739
+
740
+ # The heading `froid-loop confirm` writes to record a human's out-of-band sign-off,
741
+ # and the pattern that reads it back. Both spellings live here because the section
742
+ # is now STRUCTURE, not just prose: `confirm` resumes an interrupted confirmation
743
+ # by detecting this heading, so the writer's literal and the reader's pattern must
744
+ # agree or a resumed story is confirmed twice — or never. The writer interpolates
745
+ # the constant; a round-trip test pins the two against each other.
746
+ OPERATOR_CONFIRM_HEADING = "## Operator Confirmation"
747
+ OPERATOR_CONFIRM_HEADING_RE = re.compile(r"^##\s+Operator Confirmation\s*$", re.MULTILINE)
748
+
749
+ # Provenance for the operator-confirmation section, mirroring
750
+ # ORCHESTRATOR_SYNTH_NOTE: says who wrote the section and on whose word, so a
751
+ # reader never mistakes an out-of-band human sign-off for something a dev
752
+ # session concluded on its own. Single line, for the same reason as its sibling.
753
+ OPERATOR_CONFIRM_NOTE = (
754
+ "_Appended by the froid-loop orchestrator (`froid-loop confirm`, #335): a human "
755
+ "confirmed these external actions out of band, and the story was advanced from "
756
+ "`awaiting-operator` to `done`._"
757
+ )
758
+
759
+
760
+ def append_auto_run_result(
761
+ spec_path: Path, status: str, *, confine_root: Path, detail: str = ""
762
+ ) -> bool:
763
+ """Append a synthesized ``## Auto Run Result`` marker section — the inverse of
764
+ `strip_auto_run_result`.
765
+
766
+ The missing-marker fallback (#224) synthesizes a session's result from a spec
767
+ that carries a terminal frontmatter ``status:`` but never got the marker the
768
+ skill owed; this writer brings that spec back into contract. Before the append
769
+ a marker-less terminal spec sits in `find_frontmatter_candidates`' territory
770
+ (it requires ZERO real headings); after it the spec carries exactly one real
771
+ ``## Auto Run Result`` heading and moves into `find_result_artifact`'s
772
+ (requires >= 1), so a later re-read is harvested on the normal marker path
773
+ instead of the fallback scan, and the next review launch strips it exactly
774
+ like a skill-written marker (#160).
775
+
776
+ Returns False when the spec is absent, or when a REAL (non-fenced)
777
+ ``## Auto Run Result`` heading is already present — idempotence, and the same
778
+ #52 symmetry the strip honors: a fence-quoted heading (a frozen intent's
779
+ example) is documentation, not a marker, so it does NOT block the append. A
780
+ present-but-unreadable spec RAISES rather than no-ops (the repair-write
781
+ doctrine `strip_auto_run_result` and `reset_spec_status` share — the CALLER
782
+ imposes best-effort); silently skipping would leave the spec in a state the
783
+ caller believes it repaired.
784
+
785
+ Newline handling follows `reset_spec_status`'s intent: the file's line ending
786
+ is detected (CRLF vs LF) and reused for the appended block, and a missing
787
+ trailing newline is added so the heading can never glue onto the last body line
788
+ (``...body## Auto Run Result``). The spec is read as raw bytes rather than via
789
+ `read_text`, whose universal-newline translation would both hide a CRLF file's
790
+ ending and silently rewrite its whole body to LF — an in-place repair must not
791
+ mutate line endings it did not author. The section is ``## Auto Run Result`` /
792
+ blank / ``Status: <status>`` / blank / the provenance note (plus an optional
793
+ detail paragraph). ``status`` is normalized lowercase and MUST be the spec's
794
+ own frontmatter ``status`` — the caller passes exactly that — so
795
+ `synthesize_result`'s ``consistent`` cross-check holds on every later re-read."""
796
+ if not spec_path.is_file():
797
+ return False
798
+ # Raw read (not read_text): preserve the file's exact line endings, and let an
799
+ # undecodable spec raise UnicodeDecodeError like the strip's read (repair-write
800
+ # doctrine — the caller imposes best-effort, never the writer).
801
+ text = spec_path.read_bytes().decode("utf-8")
802
+ if _section_headings(text):
803
+ return False # a real marker is already present — idempotent
804
+ status = status.strip().lower()
805
+ nl = "\r\n" if "\r\n" in text else "\n"
806
+ # Ensure the heading lands after a newline the scan can recognize. A file
807
+ # already ending in "\n" (LF, or the "\n" of a CRLF) is left byte-for-byte
808
+ # intact, so a strip of this exact append round-trips. A file ending in a BARE
809
+ # "\r" is the trap: it reads as "already terminated", but AUTO_RUN_HEADING_RE's
810
+ # "^" (MULTILINE) only matches after "\n" — a heading glued directly after "\r"
811
+ # is invisible to the scan. Complete such a bare CR to CRLF (preserving the
812
+ # authored CR) so the heading begins on a recognized line boundary.
813
+ if text.endswith("\r"):
814
+ text += "\n"
815
+ elif text and not text.endswith("\n"):
816
+ text += nl
817
+ section = f"## Auto Run Result{nl}{nl}Status: {status}{nl}{nl}{ORCHESTRATOR_SYNTH_NOTE}{nl}"
818
+ if detail:
819
+ section += f"{nl}{detail.strip()}{nl}"
820
+ _atomic_write_spec(spec_path, text + section, confine_root=confine_root)
821
+ return True
822
+
823
+
824
+ def append_operator_confirmation(
825
+ spec_path: Path, actions: Sequence[str], *, date: str, confine_root: Path
826
+ ) -> bool:
827
+ """Append the ``## Operator Confirmation`` audit section `froid-loop confirm`
828
+ writes when a human signs off a parked story (#335).
829
+
830
+ This section is the whole audit trail for the part of a story that happened
831
+ OUTSIDE the repository. The commit shows what the agent did; nothing in git
832
+ can show that someone bought the domain or published the DNS record, so the
833
+ spec has to say it, next to the ``operator_actions:`` it is answering. The
834
+ actions are restated rather than referenced because the frontmatter list is
835
+ the *claim* and this is the *acknowledgment* — a later reader comparing them
836
+ can see whether the spec was edited between the park and the confirmation.
837
+
838
+ Unlike `append_auto_run_result` this does NOT no-op on a heading that is
839
+ already there. A repeated confirmation is a real event (a spec reverted to
840
+ the park status and confirmed again), and dropping the second record would
841
+ make the audit trail claim there was only ever one. Sections accumulate,
842
+ newest last.
843
+
844
+ Repair-write doctrine, as the neighbouring writers: an absent spec returns
845
+ False (the caller decides whether that is fatal); a present-but-unreadable
846
+ spec, or a failing write, RAISES — `confirm` is about to move the board to
847
+ `done`, and it must not do that believing it recorded something it did not.
848
+
849
+ Line endings are handled exactly as in `append_auto_run_result`: raw byte
850
+ read, detected CRLF/LF reused for every break, bare-CR completed so the
851
+ heading starts on a recognized line boundary."""
852
+ if not spec_path.is_file():
853
+ return False
854
+ text = spec_path.read_bytes().decode("utf-8")
855
+ nl = "\r\n" if "\r\n" in text else "\n"
856
+ if text.endswith("\r"):
857
+ text += "\n"
858
+ elif text and not text.endswith("\n"):
859
+ text += nl
860
+ # A blank line before the heading, not merely a terminated last line: this
861
+ # section is written to be READ, and every markdown renderer (and linter)
862
+ # wants a heading separated from the paragraph above it.
863
+ if text and not text.endswith(nl * 2):
864
+ text += nl
865
+ # One line per action, in the order they were declared and acknowledged.
866
+ items = "".join(f"- {a}{nl}" for a in actions)
867
+ section = (
868
+ f"{OPERATOR_CONFIRM_HEADING}{nl}{nl}"
869
+ f"Confirmed {date}: the external actions this story owed were carried out.{nl}{nl}"
870
+ f"{items}{nl}{OPERATOR_CONFIRM_NOTE}{nl}"
871
+ )
872
+ _atomic_write_spec(spec_path, text + section, confine_root=confine_root)
873
+ return True
874
+
875
+
876
+ def has_operator_confirmation(spec_path: Path) -> bool:
877
+ """Whether a spec already carries a REAL ``## Operator Confirmation`` section
878
+ — the on-disk acknowledgment `froid-loop confirm` reads to tell an interrupted
879
+ confirmation from a fresh one (#335).
880
+
881
+ Fence-aware for the same reason `_section_headings` is (#52), and the stakes
882
+ here are higher than on the auto-run path: a frozen intent that shows an
883
+ example ``## Operator Confirmation`` block inside a code fence must not let
884
+ `confirm` conclude a human already signed off and skip straight to advancing
885
+ the board. The section on disk IS the acknowledgment, so a quoted one would
886
+ finish a story nobody confirmed.
887
+
888
+ Reads on the *observation* path and degrades accordingly (`_read_text_or_empty`):
889
+ an absent or unreadable spec cannot be SHOWN to carry an acknowledgment, so it
890
+ reads as False and the caller takes the ordinary path, which does its own
891
+ reading and reports the real fault."""
892
+ return bool(_section_headings(_read_text_or_empty(spec_path), OPERATOR_CONFIRM_HEADING_RE))