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,609 @@
|
|
|
1
|
+
"""Model of sprint-status.yaml — the single source of workflow truth.
|
|
2
|
+
|
|
3
|
+
The dev primitive `froid-build-auto` deliberately does not touch sprint-status
|
|
4
|
+
("the orchestrator's business"), so the orchestrator is the single writer via
|
|
5
|
+
:func:`advance` — idempotent, never-regress, epic-lift. The orchestrator
|
|
6
|
+
otherwise only re-reads this file to pick the next story and verify what a
|
|
7
|
+
session claims.
|
|
8
|
+
|
|
9
|
+
Concurrency (#286/#469): being the sole writer is not on its own mutual
|
|
10
|
+
exclusion — a second orchestrator process (another `froid-loop run`, a sweep, the
|
|
11
|
+
TUI) runs the same sole writer, and :func:`advance` is a read-modify-write of the
|
|
12
|
+
whole board, so two of them would both read, both edit, and let the last atomic
|
|
13
|
+
write win. :func:`advance` therefore serializes itself cross-process on the
|
|
14
|
+
board's state-root sidecar lock, and holds it across every read that decides the
|
|
15
|
+
PUBLISHED BYTES as well as the write itself. That invariant is deliberately
|
|
16
|
+
narrower than "every read": one advisory pre-lock probe may answer a
|
|
17
|
+
read-dependent no-op — an absent row, or a row already at or past target —
|
|
18
|
+
without acquiring at all (#736), because such a call publishes nothing and so has
|
|
19
|
+
no bytes for the hold to protect. Readers stay lock-free: the publish is an
|
|
20
|
+
atomic replace, so a reader sees either the old board entire or the new one.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import re
|
|
26
|
+
import tempfile
|
|
27
|
+
from collections.abc import Iterator
|
|
28
|
+
from contextlib import contextmanager
|
|
29
|
+
from dataclasses import dataclass
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
|
|
32
|
+
import yaml
|
|
33
|
+
|
|
34
|
+
from .platform_util import atomic_write_bytes, file_lock
|
|
35
|
+
|
|
36
|
+
EPIC_RE = re.compile(r"^epic-(\d+)$")
|
|
37
|
+
RETRO_RE = re.compile(r"^epic-(\d+)-retrospective$")
|
|
38
|
+
RETRO_ITEM_RE = re.compile(r"^epic-(\d+)-retro-item-(\d+)-(.+)$")
|
|
39
|
+
# The story number may carry a single lowercase split suffix (2-6a / 2-6b —
|
|
40
|
+
# the shape FROID produces when an oversized story is split, see issue #144).
|
|
41
|
+
STORY_RE = re.compile(r"^(\d+)-(\d+)([a-z]?)-(.+)$")
|
|
42
|
+
SHORT_REF_RE = re.compile(r"^(\d+)[-.](\d+)([a-z]?)$") # short story ref: 3-1, 3.1, 3-1a
|
|
43
|
+
BARE_NUM_RE = re.compile(r"^(\d+)([a-z]?)$") # a lone story number, needs --epic
|
|
44
|
+
|
|
45
|
+
# Lifecycle order, earliest -> latest. `advance` never moves a story backward
|
|
46
|
+
# through this sequence (matches sync-sprint-status's "never regress"), and it is
|
|
47
|
+
# the only ordering any caller may use — a token absent from it cannot be ordered
|
|
48
|
+
# at all, so every consumer treats "unknown" conservatively rather than guessing.
|
|
49
|
+
# `awaiting-operator` sits immediately before `done`: parking is the last stop on
|
|
50
|
+
# the way to finished, so confirming a parked story is a legal forward advance
|
|
51
|
+
# through the sole writer, while nothing can ever regress `done` back into it.
|
|
52
|
+
STATUS_ORDER = (
|
|
53
|
+
"backlog",
|
|
54
|
+
"ready-for-dev",
|
|
55
|
+
"in-progress",
|
|
56
|
+
"review",
|
|
57
|
+
"awaiting-operator",
|
|
58
|
+
"done",
|
|
59
|
+
)
|
|
60
|
+
LEGACY_STORY_STATUSES = {"drafted": "ready-for-dev"}
|
|
61
|
+
# Statuses a story may be PICKED UP from. `awaiting-operator` is deliberately
|
|
62
|
+
# absent: the story's agent-doable work is already committed, so re-driving it
|
|
63
|
+
# would redo finished work while the human's external actions stay outstanding.
|
|
64
|
+
ACTIONABLE_STATUSES = {"backlog", "ready-for-dev"}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class SprintStatusError(Exception):
|
|
68
|
+
pass
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass(frozen=True)
|
|
72
|
+
class Story:
|
|
73
|
+
key: str
|
|
74
|
+
epic: int
|
|
75
|
+
num: int
|
|
76
|
+
slug: str
|
|
77
|
+
status: str
|
|
78
|
+
suffix: str = "" # split-story letter ("a" in 2-6a), "" for a whole story
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass(frozen=True)
|
|
82
|
+
class RetroItem:
|
|
83
|
+
"""A retrospective action item tracked in sprint-status under the
|
|
84
|
+
RETRO ACTION ITEMS section: ``epic-{epic}-retro-item-{num}-{slug}``.
|
|
85
|
+
|
|
86
|
+
Recognized so they no longer fall into ``unknown_keys``; the orchestrator
|
|
87
|
+
does not yet drive them as work (see roadmap: retro-item automation).
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
key: str
|
|
91
|
+
epic: int
|
|
92
|
+
num: int
|
|
93
|
+
slug: str
|
|
94
|
+
status: str
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@dataclass(frozen=True)
|
|
98
|
+
class SprintStatus:
|
|
99
|
+
path: Path
|
|
100
|
+
epics: dict[int, str]
|
|
101
|
+
stories: tuple[Story, ...]
|
|
102
|
+
retros: dict[int, str]
|
|
103
|
+
retro_items: tuple[RetroItem, ...]
|
|
104
|
+
unknown_keys: tuple[str, ...]
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def load(path: Path) -> SprintStatus:
|
|
108
|
+
if not path.is_file():
|
|
109
|
+
raise SprintStatusError(f"sprint status file not found: {path}")
|
|
110
|
+
try:
|
|
111
|
+
doc = yaml.safe_load(path.read_text(encoding="utf-8"))
|
|
112
|
+
except yaml.YAMLError as e:
|
|
113
|
+
raise SprintStatusError(f"sprint status is not valid YAML: {path}: {e}") from e
|
|
114
|
+
if not isinstance(doc, dict):
|
|
115
|
+
raise SprintStatusError(f"sprint status has no top-level mapping: {path}")
|
|
116
|
+
dev = doc.get("development_status")
|
|
117
|
+
if not isinstance(dev, dict):
|
|
118
|
+
raise SprintStatusError(f"sprint status missing development_status map: {path}")
|
|
119
|
+
|
|
120
|
+
epics: dict[int, str] = {}
|
|
121
|
+
stories: list[Story] = []
|
|
122
|
+
retros: dict[int, str] = {}
|
|
123
|
+
retro_items: list[RetroItem] = []
|
|
124
|
+
unknown: list[str] = []
|
|
125
|
+
for key, raw_status in dev.items():
|
|
126
|
+
key = str(key)
|
|
127
|
+
status = str(raw_status).strip()
|
|
128
|
+
if m := RETRO_ITEM_RE.match(key):
|
|
129
|
+
retro_items.append(
|
|
130
|
+
RetroItem(
|
|
131
|
+
key=key,
|
|
132
|
+
epic=int(m.group(1)),
|
|
133
|
+
num=int(m.group(2)),
|
|
134
|
+
slug=m.group(3),
|
|
135
|
+
status=status,
|
|
136
|
+
)
|
|
137
|
+
)
|
|
138
|
+
elif m := RETRO_RE.match(key):
|
|
139
|
+
retros[int(m.group(1))] = status
|
|
140
|
+
elif m := EPIC_RE.match(key):
|
|
141
|
+
epics[int(m.group(1))] = status
|
|
142
|
+
elif m := STORY_RE.match(key):
|
|
143
|
+
status = LEGACY_STORY_STATUSES.get(status, status)
|
|
144
|
+
stories.append(
|
|
145
|
+
Story(
|
|
146
|
+
key=key,
|
|
147
|
+
epic=int(m.group(1)),
|
|
148
|
+
num=int(m.group(2)),
|
|
149
|
+
slug=m.group(4),
|
|
150
|
+
status=status,
|
|
151
|
+
suffix=m.group(3),
|
|
152
|
+
)
|
|
153
|
+
)
|
|
154
|
+
else:
|
|
155
|
+
unknown.append(key)
|
|
156
|
+
|
|
157
|
+
return SprintStatus(
|
|
158
|
+
path=path,
|
|
159
|
+
epics=epics,
|
|
160
|
+
stories=tuple(stories),
|
|
161
|
+
retros=retros,
|
|
162
|
+
retro_items=tuple(retro_items),
|
|
163
|
+
unknown_keys=tuple(unknown),
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def next_actionable(
|
|
168
|
+
ss: SprintStatus, skip: set[str] | None = None, *, epic: int | None = None
|
|
169
|
+
) -> Story | None:
|
|
170
|
+
"""First story in file order whose status allows starting work. When
|
|
171
|
+
``epic`` is given, only stories of that epic are considered — the caller
|
|
172
|
+
uses this to exhaust the current epic before advancing to another."""
|
|
173
|
+
skip = skip or set()
|
|
174
|
+
for story in ss.stories:
|
|
175
|
+
if story.key in skip:
|
|
176
|
+
continue
|
|
177
|
+
if epic is not None and story.epic != epic:
|
|
178
|
+
continue
|
|
179
|
+
if story.status in ACTIONABLE_STATUSES:
|
|
180
|
+
return story
|
|
181
|
+
return None
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def story_status(path: Path, key: str) -> str | None:
|
|
185
|
+
"""Fresh re-read of one story's status, for post-session verification."""
|
|
186
|
+
ss = load(path)
|
|
187
|
+
for story in ss.stories:
|
|
188
|
+
if story.key == key:
|
|
189
|
+
return story.status
|
|
190
|
+
return None
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
# Stage 2 of the value/comment split, applied to the remainder after the key's
|
|
194
|
+
# colon and its gap. Which one runs is decided by the remainder's FIRST
|
|
195
|
+
# character, because that is the only place the scalar's own boundary is
|
|
196
|
+
# knowable from a line edit: a quote opens a scalar that owns every `#` to its
|
|
197
|
+
# right, an unquoted scalar cedes the first whitespace-preceded one.
|
|
198
|
+
#
|
|
199
|
+
# `_QUOTED_VALUE_RE` recognizes NO comment (there is no `rest` group to carry):
|
|
200
|
+
# the whole remainder is the value. `_UNQUOTED_VALUE_RE`'s `val` is lazy, so the
|
|
201
|
+
# FIRST ` #` wins rather than the last — the split is where YAML puts it, not
|
|
202
|
+
# wherever the line happens to end. Both arms demand a trailing `\S`, so a line
|
|
203
|
+
# carrying anything after the value that neither arm can account for — trailing
|
|
204
|
+
# whitespace, no comment — is refused whole rather than silently rewritten
|
|
205
|
+
# without it. Line terminators are excluded before either scalar matcher runs
|
|
206
|
+
# and carried separately per line, so CRLF's `\r` is never mistaken for trailing
|
|
207
|
+
# scalar whitespace (#576).
|
|
208
|
+
_QUOTED_VALUE_RE = re.compile(r"^(?P<val>['\"](?:.*\S)?)$")
|
|
209
|
+
_UNQUOTED_VALUE_RE = re.compile(r"^(?P<val>\S(?:.*?\S)?)(?P<rest>[ \t]+#.*)?$")
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _set_mapping_value(lines: list[str], key: str, new_value: str) -> bool:
|
|
213
|
+
"""In-place replace the value of the first `key:` line, preserving
|
|
214
|
+
indentation and any trailing ` # comment`. Returns True on a real change. A
|
|
215
|
+
minimal line edit (not a YAML round-trip) so the file's comments and
|
|
216
|
+
structure — STATUS DEFINITIONS, WORKFLOW NOTES — survive verbatim.
|
|
217
|
+
|
|
218
|
+
The split between value and comment is two-stage: the key prefix is matched
|
|
219
|
+
first and the whole remainder captured, then that remainder decides for
|
|
220
|
+
itself. An unquoted value keeps the wide class this board needs — it
|
|
221
|
+
legitimately contains spaces (`last_updated: 01-06-2026 10:00`), which is why
|
|
222
|
+
it cannot borrow `frontmatter._VALUE_COMMENT_RE`'s conservative token gate —
|
|
223
|
+
and cedes an inline comment only at whitespace, as YAML does. A remainder
|
|
224
|
+
that OPENS WITH A QUOTE is taken whole and no comment is recognized in it at
|
|
225
|
+
all: a fused pattern would guess the boundary from the last ` #` on the line
|
|
226
|
+
and turn `status: "a # b"` into `status: done # b"`, promoting scalar text
|
|
227
|
+
into a comment the board never had (#366). Nothing here can tell where a
|
|
228
|
+
quoted scalar ends — the closing quote may be escaped, or on another line —
|
|
229
|
+
so a comment sitting after one is dropped rather than guessed at. Lossy,
|
|
230
|
+
never wrong, and only a hand-edit reaches it: the writer replaces such a
|
|
231
|
+
value with a bare token on the next advance.
|
|
232
|
+
|
|
233
|
+
A remainder neither arm can read leaves the line alone, exactly like a key
|
|
234
|
+
that never matched — `advance` reports the unchanged status rather than
|
|
235
|
+
claiming a write it did not make. Each line's terminator is excluded from
|
|
236
|
+
the scalar match and then reattached exactly as authored (#576)."""
|
|
237
|
+
key_pat = re.compile(rf"^(?P<indent>\s*){re.escape(key)}:(?P<gap>[ \t]+)(?P<body>\S.*)$")
|
|
238
|
+
for i, line in enumerate(lines):
|
|
239
|
+
stripped = line.rstrip("\r\n")
|
|
240
|
+
m = key_pat.match(stripped)
|
|
241
|
+
if not m:
|
|
242
|
+
continue
|
|
243
|
+
body = m.group("body")
|
|
244
|
+
value_pat = _QUOTED_VALUE_RE if body[0] in "'\"" else _UNQUOTED_VALUE_RE
|
|
245
|
+
vm = value_pat.match(body)
|
|
246
|
+
if not vm:
|
|
247
|
+
continue # unreadable remainder — leave the line as authored
|
|
248
|
+
if vm.group("val") == new_value:
|
|
249
|
+
return False # already at target — idempotent no-op
|
|
250
|
+
rest = vm.groupdict().get("rest") or ""
|
|
251
|
+
nl = line[len(stripped) :]
|
|
252
|
+
lines[i] = f"{m.group('indent')}{key}:{m.group('gap')}{new_value}{rest}" + nl
|
|
253
|
+
return True
|
|
254
|
+
return False
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
@contextmanager
|
|
258
|
+
def _board_lock(path: Path) -> Iterator[None]:
|
|
259
|
+
"""Cross-process mutual exclusion for one sprint-status board (#286/#469).
|
|
260
|
+
|
|
261
|
+
The board's counterpart to :func:`~froid_loop.deferredwork.ledger_lock`, and
|
|
262
|
+
private for the same reason it is narrow: :func:`advance` is the only writer,
|
|
263
|
+
so the only thing that ever needs to hold this is the read-modify-write below.
|
|
264
|
+
Held around file I/O only — never across a subprocess, a coding-CLI session,
|
|
265
|
+
or an operator pause (#286).
|
|
266
|
+
|
|
267
|
+
The import of :mod:`~froid_loop.runs` is lazy and has to stay lazy: ``runs``
|
|
268
|
+
imports ``verify``, which imports this module, so a top-level import would
|
|
269
|
+
close the cycle.
|
|
270
|
+
"""
|
|
271
|
+
from . import runs
|
|
272
|
+
|
|
273
|
+
with file_lock(runs.lock_path_for(path)):
|
|
274
|
+
yield
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _row_at_or_past(current: str, target: str) -> bool:
|
|
278
|
+
"""Is a row at ``current`` already at or past ``target`` in :data:`STATUS_ORDER`?
|
|
279
|
+
|
|
280
|
+
The never-regress comparison :func:`_advance_locked` makes, factored out so
|
|
281
|
+
that :func:`advance`'s advisory pre-lock probe and the authoritative locked
|
|
282
|
+
decision run one body and cannot drift apart (#736). A probe that answered
|
|
283
|
+
this question even slightly differently from the writer would either skip a
|
|
284
|
+
write the board needed or take a lock it did not.
|
|
285
|
+
|
|
286
|
+
Deliberately NOT :func:`~froid_loop.engine._at_or_past`, the reader-side twin:
|
|
287
|
+
that one counts an exact match OUTSIDE ``STATUS_ORDER`` as reached, which is
|
|
288
|
+
right for reading what :func:`advance` RETURNED and wrong as input to its
|
|
289
|
+
WRITE decision. An off-order status equal to ``target`` is a no-op owned by
|
|
290
|
+
:func:`_set_mapping_value` under the lock — it refuses a value it already
|
|
291
|
+
holds — and routing it through this predicate instead would hand the answer
|
|
292
|
+
to a pre-lock probe on a comparison the writer does not make.
|
|
293
|
+
"""
|
|
294
|
+
return (
|
|
295
|
+
current in STATUS_ORDER
|
|
296
|
+
and target in STATUS_ORDER
|
|
297
|
+
and STATUS_ORDER.index(current) >= STATUS_ORDER.index(target)
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def advance(path: Path, story_key: str, target: str, *, now: str | None = None) -> str | None:
|
|
302
|
+
"""Advance a story's sprint-status to `target` for the generic-skill path.
|
|
303
|
+
|
|
304
|
+
Mirrors sync-sprint-status.md: skip when the file is missing or the story is
|
|
305
|
+
absent (returns None); never regress (returns the current status unchanged
|
|
306
|
+
when it is already at or past `target` in STATUS_ORDER); lift a `backlog`
|
|
307
|
+
parent epic to `in-progress` only when advancing a story to `in-progress`;
|
|
308
|
+
refresh `last_updated` when `now` is given. Comments/structure are preserved
|
|
309
|
+
via line edits. Returns the story's status after the call (== `target` on a
|
|
310
|
+
write), or None when nothing was eligible.
|
|
311
|
+
|
|
312
|
+
The rewrite is atomic and symlink-following (#379), and every existing CRLF,
|
|
313
|
+
LF, bare CR, or mixed per-line terminator is preserved (#576). The board is
|
|
314
|
+
read as raw UTF-8 bytes, each edited line carries its own terminator, and
|
|
315
|
+
`atomic_write_bytes` publishes the byte-exact result. This is a
|
|
316
|
+
read-modify-rewrite of the board, and a truncating write that faults partway
|
|
317
|
+
through corrupts it SILENTLY: YAML cut at a line boundary is still a valid
|
|
318
|
+
mapping, just a smaller one, so the epics past the tear cease to exist rather
|
|
319
|
+
than raising. AGENTS.md makes this the orchestrator's sole write path to
|
|
320
|
+
sprint-status.yaml, so nothing downstream would contradict the shortened
|
|
321
|
+
board — the run would simply walk off the end of the sprint. The atomic
|
|
322
|
+
helper keeps the file entire: either the old contents or the whole new ones,
|
|
323
|
+
never a prefix.
|
|
324
|
+
|
|
325
|
+
Symlinks are FOLLOWED (the helper's default), which is what the old
|
|
326
|
+
truncating write did too — the board is an operator-curated file at a
|
|
327
|
+
project-relative path, and a repo that symlinks it somewhere must keep being
|
|
328
|
+
a symlink. That rules out the confined writers, which are no-follow by
|
|
329
|
+
construction: this site takes the #597 flag and nothing else.
|
|
330
|
+
|
|
331
|
+
``require_writable_target=True`` is that flag, and it restores what going
|
|
332
|
+
atomic silently dropped: `os.replace` needs write permission on the parent
|
|
333
|
+
DIRECTORY, never on the entry it replaces, so a board an operator had marked
|
|
334
|
+
read-only was rewritten anyway — and because the mode is inherited it came
|
|
335
|
+
back reading ``0444``, leaving nothing in the permission bits to record that
|
|
336
|
+
it changed (#597). The truncating `write_bytes` this replaced raised
|
|
337
|
+
`PermissionError` there as a side effect of opening the file; that refusal is
|
|
338
|
+
a property worth keeping deliberately, because AGENTS.md makes this the
|
|
339
|
+
orchestrator's SOLE write path to the board — a read-only board is the only
|
|
340
|
+
way an operator can say "stop rewriting this", and it has to mean something.
|
|
341
|
+
|
|
342
|
+
Serialized cross-process (#286/#469) on the board's advisory lock — the
|
|
343
|
+
state-root sidecar :func:`~froid_loop.runs.lock_path_for` names for it, not a
|
|
344
|
+
sibling of the board itself, because the board is a tracked file and the
|
|
345
|
+
engine's own ``git add -A`` would commit a sidecar beside it. The hold spans
|
|
346
|
+
the whole read-modify-write and nothing else: three reads (the status probe,
|
|
347
|
+
the raw bytes, the epic-lift ``load``) and the one atomic write, with no
|
|
348
|
+
subprocess, session, or operator pause inside it (#286). That also closes the
|
|
349
|
+
intra-call TOCTOU, since the never-regress decision and the bytes it is
|
|
350
|
+
applied to now come from one hold rather than from two independent reads.
|
|
351
|
+
|
|
352
|
+
Two answers are reached BEFORE the lock. The missing-board check runs first,
|
|
353
|
+
so asking about a board that does not exist leaves no sidecar behind. Then an
|
|
354
|
+
ADVISORY probe (#736) reads the row once and answers the two cases in which
|
|
355
|
+
this call would write nothing at all: an absent row (``None``) and a row
|
|
356
|
+
already at or past ``target`` (the current status, via :func:`_row_at_or_past`
|
|
357
|
+
— the same predicate the locked body applies). Acquiring for those was the
|
|
358
|
+
defect: an idempotent replay — ``froid-loop confirm`` against a story the board
|
|
359
|
+
already records as done is a designed path, not an error
|
|
360
|
+
(:meth:`~froid_loop.model.ParkedStory.resumable` accepts it), as is
|
|
361
|
+
``_carry_board_advance``'s routine no-op on a tracked board — could fail on
|
|
362
|
+
lock contention, or on a :class:`~froid_loop.runs.StateRootError` from
|
|
363
|
+
:func:`~froid_loop.runs.lock_path_for`, for work it was never going to do.
|
|
364
|
+
|
|
365
|
+
The probe is advisory in the strict sense: only a "would write nothing"
|
|
366
|
+
answer is acted on, and such a call simply linearizes at the probe's read
|
|
367
|
+
rather than at an acquisition. Every other outcome — including ANY exception
|
|
368
|
+
raised while probing — falls through to the locked path, which re-reads,
|
|
369
|
+
re-decides authoritatively and raises on the channel it always did. So the
|
|
370
|
+
probe can neither authorize a write nor add a failure mode the hold lacks: a
|
|
371
|
+
malformed board still raises :class:`SprintStatusError` from under the lock.
|
|
372
|
+
``now`` needs no handling here, because both no-op arms of
|
|
373
|
+
:func:`_advance_locked` return before the ``last_updated`` write; a
|
|
374
|
+
probe-satisfied early-out is write-equivalent to the locked answer.
|
|
375
|
+
|
|
376
|
+
Acquisition failure — for the calls that do reach the lock — surfaces as
|
|
377
|
+
``OSError`` (or :class:`~froid_loop.runs.StateRootError` when no state root can
|
|
378
|
+
be derived) on the channel callers already route this function's raises
|
|
379
|
+
through — the engine's crash/escalation handling, the CLI's failure exit — so
|
|
380
|
+
a board that could not be serialized fails loudly rather than being rewritten
|
|
381
|
+
unlocked. :func:`advanced_bytes` deliberately does NOT come through
|
|
382
|
+
here: it calls :func:`_advance_locked` against a private throwaway copy, so it
|
|
383
|
+
neither contends on the real board's sidecar nor mints one of its own.
|
|
384
|
+
"""
|
|
385
|
+
if not path.is_file():
|
|
386
|
+
return None # no board, nothing to serialize against — take no lock
|
|
387
|
+
try:
|
|
388
|
+
current = story_status(path, story_key)
|
|
389
|
+
if current is None:
|
|
390
|
+
return None # absent row — nothing this call would write
|
|
391
|
+
if _row_at_or_past(current, target):
|
|
392
|
+
return current # already at or past target — never regress, no write
|
|
393
|
+
except Exception: # nosec B110 - ADVISORY probe: a fault here must decide nothing
|
|
394
|
+
# Broad by design, and the swallow is the point: narrowing the catch would
|
|
395
|
+
# let the probe invent a failure mode the locked path does not have. An
|
|
396
|
+
# unreadable board decides nothing here — the path below re-reads,
|
|
397
|
+
# re-decides, and raises on the channel callers already route this
|
|
398
|
+
# function's raises through.
|
|
399
|
+
pass
|
|
400
|
+
with _board_lock(path):
|
|
401
|
+
return _advance_locked(path, story_key, target, now=now)
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def _advance_locked(
|
|
405
|
+
path: Path, story_key: str, target: str, *, now: str | None = None
|
|
406
|
+
) -> str | None:
|
|
407
|
+
""":func:`advance`'s read-modify-write, run with the board's lock already held.
|
|
408
|
+
|
|
409
|
+
Split out so the hold is exactly the file I/O and so every read inside it sees
|
|
410
|
+
one board. The reads below repeat work the caller's pre-lock answers may
|
|
411
|
+
already have done, and deliberately: those answers are taken without
|
|
412
|
+
exclusion, so a delete can land between the ``is_file`` check and the
|
|
413
|
+
acquisition, and the advisory probe's row (#736) can be stale by the time the
|
|
414
|
+
lock is held. Only what this function reads decides the published bytes."""
|
|
415
|
+
if not path.is_file():
|
|
416
|
+
return None
|
|
417
|
+
current = story_status(path, story_key)
|
|
418
|
+
if current is None:
|
|
419
|
+
return None
|
|
420
|
+
if _row_at_or_past(current, target):
|
|
421
|
+
return current # already at or past target — never regress
|
|
422
|
+
|
|
423
|
+
text = path.read_bytes().decode("utf-8")
|
|
424
|
+
lines = text.splitlines(keepends=True)
|
|
425
|
+
# story_status() resolves keys via a full YAML parse, but _set_mapping_value
|
|
426
|
+
# rewrites via a line regex that can't touch every shape it finds (quoted or
|
|
427
|
+
# block-scalar keys). If the story line itself wasn't rewritten, report the
|
|
428
|
+
# unchanged status rather than falsely claiming we advanced to target.
|
|
429
|
+
story_changed = _set_mapping_value(lines, story_key, target)
|
|
430
|
+
if not story_changed:
|
|
431
|
+
return current
|
|
432
|
+
changed = story_changed
|
|
433
|
+
|
|
434
|
+
if target == "in-progress":
|
|
435
|
+
m = STORY_RE.match(story_key)
|
|
436
|
+
if m:
|
|
437
|
+
epic_key = f"epic-{int(m.group(1))}"
|
|
438
|
+
ss = load(path)
|
|
439
|
+
if ss.epics.get(int(m.group(1))) == "backlog":
|
|
440
|
+
changed = _set_mapping_value(lines, epic_key, "in-progress") or changed
|
|
441
|
+
|
|
442
|
+
if now is not None:
|
|
443
|
+
changed = _set_mapping_value(lines, "last_updated", now) or changed
|
|
444
|
+
|
|
445
|
+
if changed:
|
|
446
|
+
atomic_write_bytes(path, "".join(lines).encode("utf-8"), require_writable_target=True)
|
|
447
|
+
return target
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def advanced_bytes(source: bytes, story_key: str, target: str) -> bytes | None:
|
|
451
|
+
"""What :func:`advance` would leave behind, given ``source`` as the board's bytes.
|
|
452
|
+
|
|
453
|
+
For the caller that has to know whether a board on disk holds THIS run's advance
|
|
454
|
+
and nothing else, and so needs the intended content recomputed from a baseline it
|
|
455
|
+
trusts rather than read back out of the file it is about to commit.
|
|
456
|
+
|
|
457
|
+
Goes through the real writer's own body (``_advance_locked``), against a
|
|
458
|
+
throwaway copy, rather than reimplementing the edit. Never-regress, the epic lift, and
|
|
459
|
+
``_set_mapping_value``'s quoted-scalar, inline-comment and per-line-terminator
|
|
460
|
+
handling ARE what makes two boards "the same advance" — a second implementation of
|
|
461
|
+
them would drift from the writer silently, and for this caller a silent drift means
|
|
462
|
+
committing somebody else's bytes.
|
|
463
|
+
|
|
464
|
+
No ``now=``: the caller's own carry passes none either, and a ``last_updated`` line
|
|
465
|
+
rewritten here and not there would make every comparison fail.
|
|
466
|
+
|
|
467
|
+
Returns None only when the board's row is absent. The other None — a missing
|
|
468
|
+
file — cannot be reached from here, the shadow being this function's own
|
|
469
|
+
copy. There is then no intended content to compare against, and a caller must not
|
|
470
|
+
read "I could not compute it" as "the tree is mine".
|
|
471
|
+
|
|
472
|
+
Declining to WRITE is a different answer, and it comes back as bytes: a row already
|
|
473
|
+
at or past ``target``, and a row whose line ``_set_mapping_value`` will not rewrite,
|
|
474
|
+
both report the unchanged status and hand ``source`` back byte-identical. A caller
|
|
475
|
+
comparing against that is right to accept an untouched board, because for those rows
|
|
476
|
+
an untouched board IS this run's advance."""
|
|
477
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
478
|
+
shadow = Path(tmp) / "sprint-status.yaml"
|
|
479
|
+
shadow.write_bytes(source)
|
|
480
|
+
# Straight to the locked body, deliberately skipping `advance`'s own
|
|
481
|
+
# acquisition. The shadow is this function's private copy inside a
|
|
482
|
+
# TemporaryDirectory that no other process can name, so there is no
|
|
483
|
+
# second writer to exclude — and taking the lock anyway would mint a
|
|
484
|
+
# state-root sidecar keyed on a path that exists only for this call.
|
|
485
|
+
# `file_lock` never removes a sidecar and the TemporaryDirectory removes
|
|
486
|
+
# only the shadow, so every ownership computation would strand another
|
|
487
|
+
# dead lock file under `<state root>/locks` (#286).
|
|
488
|
+
if _advance_locked(shadow, story_key, target) is None:
|
|
489
|
+
return None
|
|
490
|
+
return shadow.read_bytes()
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def status_in_bytes(source: bytes, story_key: str) -> str | None:
|
|
494
|
+
""":func:`story_status` asked of a board held as BYTES rather than as a file.
|
|
495
|
+
|
|
496
|
+
For the caller comparing a live row against the same row at a git revision,
|
|
497
|
+
where one side is a blob and never a path on disk.
|
|
498
|
+
|
|
499
|
+
Goes through ``story_status`` against a throwaway copy, like
|
|
500
|
+
:func:`advanced_bytes` above and for its reason: the full YAML resolution and the
|
|
501
|
+
``LEGACY_STORY_STATUSES`` folding ARE what makes two rows "the same status", and a
|
|
502
|
+
second reading of them would drift from the one every other caller uses.
|
|
503
|
+
|
|
504
|
+
Returns None when the row is absent. A board that does not parse raises
|
|
505
|
+
``SprintStatusError``, exactly as ``story_status`` does — "I could not read it"
|
|
506
|
+
must not reach a caller spelled as "the row is gone".
|
|
507
|
+
"""
|
|
508
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
509
|
+
shadow = Path(tmp) / "sprint-status.yaml"
|
|
510
|
+
shadow.write_bytes(source)
|
|
511
|
+
return story_status(shadow, story_key)
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
@dataclass(frozen=True)
|
|
515
|
+
class StorySelector:
|
|
516
|
+
"""Resolves a human story reference (``--epic``/``--story``) to the
|
|
517
|
+
stories it selects. Forms accepted by :func:`parse_selector`:
|
|
518
|
+
|
|
519
|
+
* full key ``3-1-user-auth`` — exact match
|
|
520
|
+
* short ref ``3-1`` / ``3.1`` — epic 3, story 1 (any slug)
|
|
521
|
+
* suffixed short ref ``2-6a`` / ``2.6a`` — exactly the ``a`` half of a
|
|
522
|
+
split story; the plain ``2-6`` matches the whole ``2-6a``/``2-6b`` family
|
|
523
|
+
* bare number ``1`` (or ``6a``) with ``--epic 3`` — epic 3, story 1 (or 6a)
|
|
524
|
+
* slug fragment ``user-auth`` / ``auth`` — substring of the slug (must be unique)
|
|
525
|
+
* epic only (``--epic 3``, blank story) — every story in the epic
|
|
526
|
+
"""
|
|
527
|
+
|
|
528
|
+
epic: int | None = None
|
|
529
|
+
num: int | None = None
|
|
530
|
+
key: str | None = None # exact full key
|
|
531
|
+
slug: str | None = None # slug substring
|
|
532
|
+
suffix: str | None = None # split-story letter; None matches any suffix
|
|
533
|
+
|
|
534
|
+
@property
|
|
535
|
+
def is_targeted(self) -> bool:
|
|
536
|
+
"""True when the selector names one intended story rather than just
|
|
537
|
+
an epic-wide (or empty) filter."""
|
|
538
|
+
return any(v is not None for v in (self.key, self.num, self.slug))
|
|
539
|
+
|
|
540
|
+
def matches(self, story: Story) -> bool:
|
|
541
|
+
if self.key is not None:
|
|
542
|
+
return story.key == self.key
|
|
543
|
+
if self.epic is not None and story.epic != self.epic:
|
|
544
|
+
return False
|
|
545
|
+
if self.num is not None and story.num != self.num:
|
|
546
|
+
return False
|
|
547
|
+
if self.suffix is not None and story.suffix != self.suffix:
|
|
548
|
+
return False
|
|
549
|
+
if self.slug is not None and self.slug not in story.slug:
|
|
550
|
+
return False
|
|
551
|
+
return True
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
def parse_selector(epic: int | None, story: str | None) -> StorySelector:
|
|
555
|
+
"""Translate the ``--epic``/``--story`` pair into a :class:`StorySelector`.
|
|
556
|
+
|
|
557
|
+
Raises :class:`SprintStatusError` on bad or ambiguous input.
|
|
558
|
+
"""
|
|
559
|
+
text = (story or "").strip()
|
|
560
|
+
if not text:
|
|
561
|
+
return StorySelector(epic=epic)
|
|
562
|
+
|
|
563
|
+
def _check_epic(parsed_epic: int) -> None:
|
|
564
|
+
if epic is not None and epic != parsed_epic:
|
|
565
|
+
raise SprintStatusError(
|
|
566
|
+
f"--epic {epic} conflicts with story '{text}' (epic {parsed_epic})"
|
|
567
|
+
)
|
|
568
|
+
|
|
569
|
+
# empty suffix group -> None: a plain `2-6` matches the whole split family
|
|
570
|
+
if m := STORY_RE.match(text): # full key 3-1-slug
|
|
571
|
+
e, n = int(m.group(1)), int(m.group(2))
|
|
572
|
+
_check_epic(e)
|
|
573
|
+
return StorySelector(epic=e, num=n, key=text, suffix=m.group(3) or None)
|
|
574
|
+
if m := SHORT_REF_RE.match(text): # 3-1 / 3.1 / 3-1a
|
|
575
|
+
e, n = int(m.group(1)), int(m.group(2))
|
|
576
|
+
_check_epic(e)
|
|
577
|
+
return StorySelector(epic=e, num=n, suffix=m.group(3) or None)
|
|
578
|
+
if m := BARE_NUM_RE.match(text): # bare story number, needs --epic
|
|
579
|
+
if epic is None:
|
|
580
|
+
raise SprintStatusError(
|
|
581
|
+
f"ambiguous story '{text}': use --epic E --story {text}, or E-{text}"
|
|
582
|
+
)
|
|
583
|
+
return StorySelector(epic=epic, num=int(m.group(1)), suffix=m.group(2) or None)
|
|
584
|
+
return StorySelector(epic=epic, slug=text) # slug fragment
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
def select_actionable(ss: SprintStatus, epic: int | None, story: str | None) -> list[Story]:
|
|
588
|
+
"""Stories selected by ``--epic``/``--story`` that are ready to start, in
|
|
589
|
+
file order. Raises :class:`SprintStatusError` with a targeted message when a
|
|
590
|
+
named story is missing, ambiguous, or exists but is not actionable.
|
|
591
|
+
"""
|
|
592
|
+
sel = parse_selector(epic, story)
|
|
593
|
+
matches = [s for s in ss.stories if sel.matches(s)]
|
|
594
|
+
if sel.is_targeted:
|
|
595
|
+
if not matches:
|
|
596
|
+
raise SprintStatusError(f"no story matches '{story}'")
|
|
597
|
+
if sel.slug is not None:
|
|
598
|
+
keys = sorted({s.key for s in matches})
|
|
599
|
+
if len(keys) > 1:
|
|
600
|
+
raise SprintStatusError(
|
|
601
|
+
f"story '{sel.slug}' is ambiguous — matches: {', '.join(keys)}"
|
|
602
|
+
)
|
|
603
|
+
actionable = [s for s in matches if s.status in ACTIONABLE_STATUSES]
|
|
604
|
+
if sel.is_targeted and matches and not actionable:
|
|
605
|
+
s = matches[0]
|
|
606
|
+
raise SprintStatusError(
|
|
607
|
+
f"story {story} matched {s.key} but its status is " f"'{s.status}' (not actionable)"
|
|
608
|
+
)
|
|
609
|
+
return actionable
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Story lifecycle transition table — the single source of truth for legal moves."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .model import Phase, StoryTask
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class IllegalTransition(Exception):
|
|
9
|
+
pass
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
TRANSITIONS: dict[Phase, frozenset[Phase]] = {
|
|
13
|
+
# TRIAGE_RUNNING: a sweep run's triage task — also reused by the sweep's
|
|
14
|
+
# legacy-ledger migration task (same lifecycle, its own task key);
|
|
15
|
+
# story tasks go to DEV_RUNNING
|
|
16
|
+
Phase.PENDING: frozenset({Phase.DEV_RUNNING, Phase.TRIAGE_RUNNING}),
|
|
17
|
+
Phase.DEV_RUNNING: frozenset({Phase.DEV_VERIFY}),
|
|
18
|
+
# COMMITTING: review.enabled = false skips the review loop entirely, so a
|
|
19
|
+
# verified dev pass commits straight from DEV_VERIFY
|
|
20
|
+
Phase.DEV_VERIFY: frozenset(
|
|
21
|
+
{Phase.DEV_RUNNING, Phase.REVIEW_RUNNING, Phase.COMMITTING, Phase.DEFERRED, Phase.ESCALATED}
|
|
22
|
+
),
|
|
23
|
+
Phase.REVIEW_RUNNING: frozenset({Phase.REVIEW_VERIFY}),
|
|
24
|
+
Phase.REVIEW_VERIFY: frozenset(
|
|
25
|
+
# DEV_RUNNING: fix session after a clean review whose verify commands failed
|
|
26
|
+
{
|
|
27
|
+
Phase.REVIEW_RUNNING,
|
|
28
|
+
Phase.DEV_RUNNING,
|
|
29
|
+
Phase.COMMITTING,
|
|
30
|
+
Phase.DEFERRED,
|
|
31
|
+
Phase.ESCALATED,
|
|
32
|
+
}
|
|
33
|
+
),
|
|
34
|
+
# AWAITING_OPERATOR: a story owing human-only external actions parks on the
|
|
35
|
+
# NORMAL commit path — the work commits, then the final phase is chosen by
|
|
36
|
+
# whether the task carries operator_actions. Reachable only from COMMITTING
|
|
37
|
+
# precisely so a park can never skip the gates/commit a DONE story clears.
|
|
38
|
+
Phase.COMMITTING: frozenset({Phase.DONE, Phase.ESCALATED, Phase.AWAITING_OPERATOR}),
|
|
39
|
+
Phase.TRIAGE_RUNNING: frozenset({Phase.TRIAGE_VERIFY}),
|
|
40
|
+
# TRIAGE_RUNNING: invalid triage output retries with feedback, like DEV_VERIFY
|
|
41
|
+
Phase.TRIAGE_VERIFY: frozenset({Phase.TRIAGE_RUNNING, Phase.DONE, Phase.ESCALATED}),
|
|
42
|
+
Phase.DONE: frozenset(),
|
|
43
|
+
Phase.DEFERRED: frozenset(),
|
|
44
|
+
Phase.ESCALATED: frozenset(),
|
|
45
|
+
# terminal: `froid-loop confirm` completes a parked story out of band, not by
|
|
46
|
+
# transitioning the (by then finished) run's task.
|
|
47
|
+
Phase.AWAITING_OPERATOR: frozenset(),
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def advance(task: StoryTask, to: Phase) -> None:
|
|
52
|
+
allowed = TRANSITIONS[task.phase]
|
|
53
|
+
if to not in allowed:
|
|
54
|
+
raise IllegalTransition(
|
|
55
|
+
f"{task.story_key}: {task.phase} -> {to} (allowed: {sorted(allowed)})"
|
|
56
|
+
)
|
|
57
|
+
task.phase = to
|