froid-loop 0.11.1__py3-none-any.whl

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