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,474 @@
1
+ """Committed, per-story records of stories parked at ``awaiting-operator`` (#335, #356).
2
+
3
+ A park commits everything an agent could do and records what a human still owes
4
+ in the spec's ``operator_actions:`` frontmatter. That frontmatter, plus the
5
+ board's ``awaiting-operator`` token, is the *committed truth*. This module adds
6
+ a record over it — one JSON file per parked story under ``.froid-loop/operator/``
7
+ — because the truth alone is not findable: ``spec_file`` is reported by the dev
8
+ session in its result JSON and is not derivable from a story key, so the record
9
+ is the only route from ``froid-loop confirm <key>`` back to the spec.
10
+
11
+ Committed, one file per story
12
+ -----------------------------
13
+ The record is written into the WORKSPACE inside the story's commit window, just
14
+ before ``finalize_commit``'s ``git add -A``, so it rides the park's own commit —
15
+ and under ``scm.isolation = "worktree"`` it reaches the target branch with the
16
+ ordinary merge-back (#355 merges parked units beside DONE). That placement is
17
+ what makes a committed record safe where committing a shared index was not
18
+ (#356): nothing lands on the *target* branch during the commit window (the
19
+ ``ff`` merge-back survives, ``branch_per = "run"`` included), the clean tree the
20
+ epic-boundary auto-sweep requires is undisturbed (the record is part of the
21
+ story's commit, not residue beside it), and a crash re-drives through the same
22
+ COMMITTING resume arm that re-derives the park itself. One file per story,
23
+ rather than one shared index, means two parks on different branches can never
24
+ produce a merge conflict. The payoff is the acceptance criterion of #356: a
25
+ fresh clone carries every record, so ``confirm`` works wherever the repository
26
+ does.
27
+
28
+ The record deliberately carries no commit sha — it is written into the very
29
+ commit it rides, so the sha does not exist yet. :func:`resolve` derives it from
30
+ ``git log`` over the record's own path instead, which under a squash merge names
31
+ the commit that actually carries the park on *this* branch; a record not yet in
32
+ any commit derives to ``""``.
33
+
34
+ The legacy machine-local index
35
+ ------------------------------
36
+ Before #356 the store was a single ``.froid-loop/operator-actions.json``, kept
37
+ out of git via the repository-local exclude file. :func:`load` still reads it —
38
+ a park written by an older version must stay confirmable on the machine that
39
+ wrote it — and :func:`drop` prunes it, but nothing writes it anymore. A
40
+ per-story record wins its key over a legacy entry. Stale exclude lines on old
41
+ machines are left alone: they keep leftover legacy files invisible, which is
42
+ exactly right for a file nothing writes.
43
+
44
+ Because a record can still drift from the committed truth it points at (a
45
+ hand-edited spec, a ``git revert``, a story re-driven to ``done``), ``validate``
46
+ carries ``operator.registry-stale``, ``operator.actions-malformed`` and
47
+ ``operator.confirm-interrupted``, and reports a board parked at
48
+ ``awaiting-operator`` that no record claims as ``operator.park-record-missing``
49
+ (#356) — ids split where the remedy does. All are warnings: ``confirm`` refuses
50
+ drifted entries itself, so nothing gates on the record.
51
+ """
52
+
53
+ from __future__ import annotations
54
+
55
+ import json
56
+ from dataclasses import dataclass
57
+ from pathlib import Path
58
+
59
+ from . import devcontract, sprintstatus, verify
60
+ from .froidconfig import ProjectPaths
61
+ from .frontmatter import operator_actions_of, read_frontmatter, status_of
62
+ from .platform_util import atomic_write_text_confined, safe_segment
63
+
64
+ RECORDS_REL = Path(".froid-loop") / "operator"
65
+ LEGACY_STORE_REL = Path(".froid-loop") / "operator-actions.json"
66
+ AWAITING_OPERATOR = verify.AWAITING_OPERATOR
67
+ # The status a confirmation lands on. Spelled here rather than imported from
68
+ # `sprintstatus.STATUS_ORDER` because both sides of the join use it — the board
69
+ # token and the spec's frontmatter status — and only one of those is a board.
70
+ DONE = "done"
71
+
72
+
73
+ def records_dir(project: Path) -> Path:
74
+ return project / RECORDS_REL
75
+
76
+
77
+ def record_path(project: Path, story_key: str) -> Path:
78
+ """Where a story's park record lives. `safe_segment` is identity for every
79
+ conventional story key; a hostile key gets a legal filename, and the record's
80
+ own ``story_key`` field stays authoritative over the mangled stem."""
81
+ return records_dir(project) / f"{safe_segment(story_key)}.json"
82
+
83
+
84
+ def legacy_store_path(project: Path) -> Path:
85
+ return project / LEGACY_STORE_REL
86
+
87
+
88
+ # --------------------------------------------------------------- store I/O
89
+
90
+
91
+ def load(project: Path) -> dict[str, dict]:
92
+ """Every park record, ``{story_key: {actions, spec_file, run_id,
93
+ parked_at}}``, merged over any legacy machine-local entries (a record wins
94
+ its key). Tolerant throughout: this is a convenience over committed truth,
95
+ so an unreadable side degrades rather than blocking a human from confirming
96
+ their own work. An unparseable RECORD file still contributes an empty entry
97
+ under its filename stem — visible as drift ("no spec file could be located
98
+ for it") rather than silently absent, because the file's presence is the
99
+ committed claim that something is owed."""
100
+ data: dict[str, dict] = dict(_load_legacy(project))
101
+ for path in _record_files(project):
102
+ record = _read_json(path)
103
+ if isinstance(record, dict):
104
+ key = str(record.get("story_key") or "") or path.stem
105
+ data[key] = record
106
+ else:
107
+ data.setdefault(path.stem, {})
108
+ return data
109
+
110
+
111
+ def _record_files(project: Path) -> list[Path]:
112
+ try:
113
+ return sorted(records_dir(project).glob("*.json"))
114
+ except OSError:
115
+ return []
116
+
117
+
118
+ def _read_json(path: Path) -> object | None:
119
+ try:
120
+ return json.loads(path.read_text(encoding="utf-8"))
121
+ except (json.JSONDecodeError, OSError, UnicodeDecodeError):
122
+ return None
123
+
124
+
125
+ def _load_legacy(project: Path) -> dict[str, dict]:
126
+ path = legacy_store_path(project)
127
+ if not path.is_file():
128
+ return {}
129
+ data = _read_json(path)
130
+ return data if isinstance(data, dict) else {}
131
+
132
+
133
+ # ------------------------------------------------------------ record + drop
134
+
135
+
136
+ def record_park(
137
+ project: Path,
138
+ story_key: str,
139
+ *,
140
+ actions: list[str],
141
+ spec_file: str,
142
+ run_id: str,
143
+ parked_at: str,
144
+ ) -> Path:
145
+ """Write a story's park record, returning its path. Re-parking the same key
146
+ overwrites rather than accumulates: a story owes whatever its latest park
147
+ says it owes, and a stale action list is worse than none.
148
+
149
+ The write goes through :func:`platform_util.atomic_write_text` (#379), which
150
+ carries the unlink-on-raise this used to hand-roll — hence no try/except here;
151
+ two nested guards would only obscure which one runs — and adds the two things
152
+ the hand-rolled version lacked. The temp is *uniquely* named instead of the
153
+ fixed ``.tmp`` sibling: this runs just ahead of ``finalize_commit``'s ``git
154
+ add -A``, so a stranded temp rides the story's own commit forever, and one
155
+ fixed name is what two writers of the same key would collide on. And the
156
+ contents are fsynced *before* the replace publishes them — a host losing power
157
+ just after the rename otherwise comes back with the record's name pointing at
158
+ blocks that were never written, which :func:`load` reads as an entry owing
159
+ nothing while the board still says a human owes something.
160
+
161
+ Refusing a link at the record itself preserved what the bare ``os.replace``
162
+ did (it never dereferenced this destination) and matched what the record is:
163
+ machine-minted, under a project root a driven session can write. The write is
164
+ now confined to ``project`` (#593), because that refusal stopped at the final
165
+ component: ``mkdir(parents=True, exist_ok=True)`` on the line below accepts a
166
+ symlink-to-a-directory, so a link planted at ``.froid-loop/`` survived the
167
+ setup step and redirected both the temp and the publish to wherever it
168
+ pointed. The confined writer walks the components below ``project``
169
+ ``O_NOFOLLOW`` and writes through the descriptor that walk produced. The
170
+ record still lands at ``mkstemp``'s ``0600`` rather than the hand-rolled
171
+ temp's ``0644`` — no-follow never inherited a mode either, so confining it
172
+ changes no permissions; git carries no mode but the exec bit, so nothing
173
+ downstream of the commit notices.
174
+
175
+ ``require_writable_target=True`` (#597) for consistency with the OTHER two
176
+ writers of this same file — ``Engine._restore_park_record`` and ``confirm``'s
177
+ prune — since write semantics belong to the FILE, not to whichever code path
178
+ reached it last. An operator who marks a park record read-only gets the
179
+ ``PermissionError`` a bare ``Path.write_text`` raised before #379."""
180
+ path = record_path(project, story_key)
181
+ path.parent.mkdir(parents=True, exist_ok=True)
182
+ record = {
183
+ "story_key": story_key,
184
+ "actions": list(actions),
185
+ "spec_file": spec_file,
186
+ "run_id": run_id,
187
+ "parked_at": parked_at,
188
+ }
189
+ atomic_write_text_confined(
190
+ path,
191
+ json.dumps(record, indent=2, sort_keys=True),
192
+ confine_root=project,
193
+ require_writable_target=True,
194
+ )
195
+ return path
196
+
197
+
198
+ def drop(project: Path, story_key: str) -> bool:
199
+ """Remove a story's park record — and any legacy machine-local entry —
200
+ returning whether anything was removed. No write when the key is absent
201
+ everywhere, so confirming a story no store ever knew about (pre-#356 the
202
+ fresh-clone case) leaves no file behind. Removal raises on OSError like any
203
+ repair write: a drop that silently failed would leave `validate` warning
204
+ about a story that was genuinely confirmed."""
205
+ removed = _drop_record(project, story_key)
206
+ return _drop_legacy(project, story_key) or removed
207
+
208
+
209
+ def _drop_record(project: Path, story_key: str) -> bool:
210
+ """Unlink every record file claiming ``story_key`` — matched on the record's
211
+ own field, not the filename, so a mangled or hand-renamed file cannot survive
212
+ its confirmation (nor can dropping one key ever unlink another's record)."""
213
+ removed = False
214
+ for path in _record_files(project):
215
+ if _record_key(path) == story_key:
216
+ path.unlink()
217
+ removed = True
218
+ return removed
219
+
220
+
221
+ def _record_key(path: Path) -> str:
222
+ record = _read_json(path)
223
+ if isinstance(record, dict):
224
+ return str(record.get("story_key") or "") or path.stem
225
+ return path.stem
226
+
227
+
228
+ def _drop_legacy(project: Path, story_key: str) -> bool:
229
+ """Prune a legacy entry. The emptied store is deleted outright rather than
230
+ rewritten as ``{}``: nothing writes the legacy file anymore, and an empty
231
+ husk would read as "an index with nothing parked" forever.
232
+
233
+ The rewrite goes through the same helper `record_park` uses and inherits its
234
+ unlink-on-raise — but the temp matters here for the opposite reason. `drop`
235
+ runs out of band from `confirm`, not inside a commit window, so the hazard is
236
+ not a temp RIDING a commit but one OUTLIVING the failure: `.froid-loop/` is not
237
+ ignored (`install` excludes only `runs/`, `cache/` and `policy.toml`, and the
238
+ pre-#356 exclude line was the anchored literal `operator-actions.json`, never
239
+ its `.tmp` sibling), so a stranded `.froid-loop/operator-actions.tmp` is an
240
+ untracked file to `verify.worktree_clean` — a dirty tree blocking the next
241
+ run's preflight and the epic-boundary auto-sweep, over a prune of a store
242
+ nothing writes. The helper's temp carries a random infix, so even that
243
+ surviving name is no longer one a second `drop` of a different key collides
244
+ on mid-write.
245
+
246
+ Confined to `project` and refusing a read-only target for the same reasons
247
+ `record_park` is (#593, #597) — this writes an operator-curated file under the
248
+ same session-writable `.froid-loop/`. There is no `mkdir` here and none is
249
+ needed: the write is reached only when `_load_legacy` found an entry to
250
+ prune, so the file — and therefore the parent the confinement walk must reach
251
+ — already exists."""
252
+ path = legacy_store_path(project)
253
+ data = _load_legacy(project)
254
+ if story_key not in data:
255
+ return False
256
+ del data[story_key]
257
+ if data:
258
+ atomic_write_text_confined(
259
+ path,
260
+ json.dumps(data, indent=2, sort_keys=True),
261
+ confine_root=project,
262
+ require_writable_target=True,
263
+ )
264
+ else:
265
+ path.unlink(missing_ok=True)
266
+ return True
267
+
268
+
269
+ # ------------------------------------------------- joining records to truth
270
+
271
+
272
+ @dataclass(frozen=True)
273
+ class ParkedStory:
274
+ """One park record joined back to the committed truth it points at.
275
+
276
+ The record is what `confirm` can *find*; the spec and the board are what it is
277
+ allowed to *believe*. Keeping both readings on one object is what lets the
278
+ command refuse precisely — "the record says parked, the board says done" is a
279
+ different message, and a different remedy, from "no such story".
280
+
281
+ ``spec_status`` / ``board_status`` are None when that side could not be read
282
+ at all (spec missing or unreadable, board missing or the key absent), which
283
+ is deliberately distinct from a side that reads some *other* status.
284
+
285
+ ``confirmation_recorded`` is the third reading: whether the spec already
286
+ carries the ``## Operator Confirmation`` section `confirm` writes. It is what
287
+ separates a story a human never signed off from one whose confirmation was
288
+ interrupted part-way — two states whose spec and board readings otherwise
289
+ look identical to a stale entry.
290
+
291
+ ``commit`` is display provenance only, never a predicate input. For a park
292
+ record it is DERIVED (``git log`` over the record's path — the record rides
293
+ the commit it names, so it cannot store it) and is ``""`` for a record not
294
+ yet in any commit; a legacy index entry keeps its stored sha."""
295
+
296
+ story_key: str
297
+ actions: tuple[str, ...]
298
+ spec_path: Path | None
299
+ spec_status: str | None
300
+ board_status: str | None
301
+ confirmation_recorded: bool
302
+ commit: str
303
+ run_id: str
304
+ parked_at: str
305
+
306
+ @property
307
+ def confirmable(self) -> bool:
308
+ """Whether both sides of the committed truth still describe a park with
309
+ something owed. Everything else is drift, and `confirm` refuses it rather
310
+ than flipping a board on the record's word alone."""
311
+ return (
312
+ bool(self.actions)
313
+ and self.spec_status == AWAITING_OPERATOR
314
+ and self.board_status == AWAITING_OPERATOR
315
+ )
316
+
317
+ @property
318
+ def resumable(self) -> bool:
319
+ """Whether this entry is a confirmation that was INTERRUPTED between its
320
+ spec writes and its board write, and can simply be finished.
321
+
322
+ `confirm` writes the audit section, then the spec status, then the board,
323
+ then drops the entry. Stop it between the spec half and the board half —
324
+ a raising `sprintstatus.advance`, or one that returns unchanged because
325
+ the board line is in a shape its line regex cannot rewrite — and what is
326
+ left on disk is a signed-off spec at `done` with an entry still pointing
327
+ at it. That reads to :meth:`drift` as a stale entry (arm 3, "its spec now
328
+ says status: done"), so a re-run refuses the very state a re-run exists to
329
+ clear, and `validate` nags about it forever.
330
+
331
+ All three readings are required. The section is the human's acknowledgment
332
+ — without it a spec at `done` is a story someone finished by hand or
333
+ re-drove, and confirming it would append an audit record for a sign-off
334
+ that never happened.
335
+
336
+ ⚠️ The board arm accepts `done` as well as `awaiting-operator`. The
337
+ interruption message tells the human to fix the board by hand; if they do,
338
+ a strict ``== AWAITING_OPERATOR`` test would drop the entry out of this
339
+ predicate and strand it — record retained, `validate` warning forever,
340
+ and no command that will remove either. `sprintstatus.advance` is
341
+ already idempotent at `done`, so resuming from there costs nothing and
342
+ finishes the one thing left: dropping the entry."""
343
+ return (
344
+ self.confirmation_recorded
345
+ and self.spec_status == DONE
346
+ and self.board_status in (AWAITING_OPERATOR, DONE)
347
+ )
348
+
349
+ def committed_drift(self) -> str | None:
350
+ """Why the COMMITTED state — spec and board only — disagrees with this
351
+ entry, or None when it does not.
352
+
353
+ Split from :meth:`drift` so a caller that has already reported something
354
+ about the *record* side (an unreadable action list) can still report a
355
+ co-occurring disagreement about the committed side. Folding both into one
356
+ method meant the first cause found was the only one anybody heard about,
357
+ and the two have different remedies: repair the list, versus discard the
358
+ entry. Ordered most-fundamental first — a missing spec explains a missing
359
+ status, so it is reported instead of it."""
360
+ if self.spec_path is None:
361
+ return "no spec file could be located for it"
362
+ if self.spec_status is None:
363
+ return f"its spec is missing or unreadable ({self.spec_path})"
364
+ if self.spec_status != AWAITING_OPERATOR:
365
+ # A blank frontmatter `status:` reads "" (status_of normalizes YAML-null),
366
+ # which would otherwise render as an empty tail on a human-facing line.
367
+ return f"its spec now says status: {self.spec_status or '(blank)'}"
368
+ if self.board_status is None:
369
+ return "it is not on the sprint board"
370
+ if self.board_status != AWAITING_OPERATOR:
371
+ return f"the board now says {self.board_status}"
372
+ return None
373
+
374
+ def drift(self) -> str | None:
375
+ """Why this entry is not confirmable, phrased for a human, or None when
376
+ it is — the committed-side causes, then the record's own.
377
+
378
+ The empty-actions cause comes last because it is the least fundamental:
379
+ a spec that has moved on to `done` explains its own unreadable list, and
380
+ reporting "it declares no readable actions" about a story nobody is
381
+ parked on anymore sends a human to repair a file they should discard."""
382
+ return self.committed_drift() or (
383
+ "it declares no readable actions" if not self.actions else None
384
+ )
385
+
386
+
387
+ def resolve(project: Path, paths: ProjectPaths) -> list[ParkedStory]:
388
+ """Every park entry joined to its spec and board status, sorted by key.
389
+
390
+ Reading degrades rather than raises throughout: this backs `confirm --list`
391
+ and a `validate` warning, and neither may be the thing that crashes on a spec
392
+ someone deleted. The actions come from the SPEC when it can be read and from
393
+ the record only as a fallback — the spec is the committed truth, so a spec
394
+ edited after the park shows the human what they actually owe now. The
395
+ confirmation-section reading degrades the same way — an absent or unreadable
396
+ spec cannot be SHOWN to carry an acknowledgment, so it reads as False and the
397
+ entry takes the ordinary path, which reports the real fault.
398
+
399
+ ``commit`` comes from the entry when stored (a legacy index entry) and is
400
+ otherwise derived from the record file's own git history — see the module
401
+ docstring. The derived sha can legitimately differ from the journal's
402
+ ``story-awaiting-operator`` entry: under a squash merge the unit's own park
403
+ commit never reaches the target, and the squash commit that did is the
404
+ truthful provenance here."""
405
+ entries = load(project)
406
+ out: list[ParkedStory] = []
407
+ for key in sorted(entries):
408
+ entry = entries[key] if isinstance(entries[key], dict) else {}
409
+ spec_path = _spec_path(entry, paths)
410
+ spec_fm = _spec_frontmatter(spec_path)
411
+ spec_actions = operator_actions_of(spec_fm) if spec_fm is not None else ()
412
+ out.append(
413
+ ParkedStory(
414
+ story_key=key,
415
+ actions=spec_actions or actions_of(entry),
416
+ spec_path=spec_path,
417
+ spec_status=status_of(spec_fm) if spec_fm is not None else None,
418
+ board_status=_board_status(paths, key),
419
+ confirmation_recorded=(
420
+ spec_path is not None and devcontract.has_operator_confirmation(spec_path)
421
+ ),
422
+ commit=str(entry.get("commit") or "") or _derive_commit(project, paths, key),
423
+ run_id=str(entry.get("run_id") or ""),
424
+ parked_at=str(entry.get("parked_at") or ""),
425
+ )
426
+ )
427
+ return out
428
+
429
+
430
+ def _derive_commit(project: Path, paths: ProjectPaths, story_key: str) -> str:
431
+ try:
432
+ return verify.last_commit_for(paths.repo_root, record_path(project, story_key))
433
+ except verify.GitError:
434
+ return "" # no VCS, or a repository with no history yet
435
+
436
+
437
+ def _spec_path(entry: dict, paths: ProjectPaths) -> Path | None:
438
+ spec_file = str(entry.get("spec_file") or "")
439
+ if not spec_file:
440
+ return None
441
+ try:
442
+ return verify.resolve_spec_path(spec_file, paths)
443
+ except (OSError, ValueError):
444
+ return None
445
+
446
+
447
+ def _spec_frontmatter(spec_path: Path | None) -> dict | None:
448
+ """The spec's frontmatter, or None when there is no readable spec there."""
449
+ if spec_path is None:
450
+ return None
451
+ try:
452
+ if not spec_path.is_file():
453
+ return None
454
+ return read_frontmatter(spec_path)
455
+ except (OSError, UnicodeDecodeError):
456
+ return None
457
+
458
+
459
+ def _board_status(paths: ProjectPaths, key: str) -> str | None:
460
+ try:
461
+ return sprintstatus.story_status(paths.sprint_status, key)
462
+ except (sprintstatus.SprintStatusError, OSError, UnicodeDecodeError):
463
+ return None
464
+
465
+
466
+ def actions_of(entry: dict) -> tuple[str, ...]:
467
+ """The actions a park entry declares, read through the *same* normalizer
468
+ the spec frontmatter goes through (:func:`frontmatter.operator_actions_of`).
469
+
470
+ Sharing the reading is the point: the record is written from a spec and
471
+ compared back against one, so a shape that collapses to ``()`` on the spec
472
+ side must collapse to ``()`` here too. Two readings would let a hand-edited
473
+ record disagree with the spec about what a human owes."""
474
+ return operator_actions_of({"operator_actions": entry.get("actions")})