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,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")})
|