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