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
froid_loop/sweep.py ADDED
@@ -0,0 +1,1892 @@
1
+ """Deferred-work sweep: triage the ledger, decide, execute bundles.
2
+
3
+ A sweep is its own run type. One LLM triage session classifies every open
4
+ deferred-work entry (verified against actual code — ledger statuses are
5
+ unreliable); the orchestrator validates the result deterministically, asks
6
+ the human about decision items (interactive runs only), then drives each
7
+ work bundle through the inherited dev -> review -> verify -> commit pipeline.
8
+ The orchestrator performs all ledger edits it can do deterministically and
9
+ gates on the ones it delegates (verify.verify_review_bundle).
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import re
16
+ from dataclasses import dataclass
17
+ from pathlib import Path
18
+ from typing import Any, Callable, Iterable
19
+
20
+ from . import deferredwork, gates, verify
21
+ from .engine import Engine, RunPaused, _ArmedClose, _LedgerAnchor
22
+ from .escalation import critical_escalations, env_fault_pause_reason, session_failure_reason
23
+ from .model import PAUSE_STORY_GATE, Phase, StoryTask
24
+ from .platform_util import (
25
+ atomic_write_text,
26
+ atomic_write_text_confined,
27
+ neutralize_surrogates,
28
+ safe_segment,
29
+ )
30
+ from .runs import _project_of_run_dir
31
+ from .statemachine import advance
32
+
33
+
34
+ def _read_json(path: Path) -> Any:
35
+ return json.loads(path.read_text(encoding="utf-8"))
36
+
37
+
38
+ TRIAGE_KEY = "sweep-triage"
39
+ TRIAGE_WORKFLOW = "deferred-sweep-triage"
40
+ MIGRATE_KEY = "sweep-migrate"
41
+ MIGRATE_WORKFLOW = "deferred-sweep-migrate"
42
+ BUNDLE_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9-]{1,39}\Z")
43
+ _BUNDLE_NAME_MAX_LENGTH = 40
44
+ _BUNDLE_NAME_INITIAL_CHARS = frozenset("abcdefghijklmnopqrstuvwxyz0123456789")
45
+ _BUNDLE_NAME_SAFE_CHARS = frozenset("abcdefghijklmnopqrstuvwxyz0123456789-")
46
+ # the inverse of SweepEngine._bundle_key: "dw-<name>" (cycle 1) / "dw<N>-<name>".
47
+ # A cycle-1 key always has "-" straight after "dw", so the cycle group matches
48
+ # empty and the split stays unambiguous even for a bundle named "2fix".
49
+ BUNDLE_KEY_RE = re.compile(r"^dw(\d*)-(.+)\Z")
50
+ DECISION_EFFECTS = ("build", "close", "keep-open")
51
+
52
+
53
+ @dataclass(frozen=True)
54
+ class _BundleNameRepair:
55
+ field: str
56
+ original: str
57
+ normalized: str
58
+
59
+
60
+ def _normalize_bundle_names(rj: dict[str, Any] | None) -> tuple[_BundleNameRepair, ...]:
61
+ """Truncate overlong bundle-name fields only when their shape is already safe."""
62
+ if rj is None:
63
+ return ()
64
+
65
+ repairs: list[_BundleNameRepair] = []
66
+
67
+ def normalize(container: dict[str, Any], key: str, field: str) -> None:
68
+ raw = container.get(key)
69
+ if not isinstance(raw, str) or len(raw) <= _BUNDLE_NAME_MAX_LENGTH:
70
+ return
71
+ if raw[0] not in _BUNDLE_NAME_INITIAL_CHARS or any(
72
+ char not in _BUNDLE_NAME_SAFE_CHARS for char in raw[1:]
73
+ ):
74
+ return
75
+ normalized = raw[:_BUNDLE_NAME_MAX_LENGTH]
76
+ container[key] = normalized
77
+ repairs.append(_BundleNameRepair(field, raw, normalized))
78
+
79
+ bundles = rj.get("bundles", [])
80
+ if isinstance(bundles, list):
81
+ for bundle_index, bundle in enumerate(bundles):
82
+ if isinstance(bundle, dict):
83
+ normalize(bundle, "name", f"bundles[{bundle_index}].name")
84
+
85
+ decisions = rj.get("decisions", [])
86
+ if isinstance(decisions, list):
87
+ for decision_index, decision in enumerate(decisions):
88
+ if not isinstance(decision, dict):
89
+ continue
90
+ options = decision.get("options", [])
91
+ if not isinstance(options, list):
92
+ continue
93
+ for option_index, option in enumerate(options):
94
+ if isinstance(option, dict):
95
+ normalize(
96
+ option,
97
+ "bundle_name",
98
+ f"decisions[{decision_index}].options[{option_index}].bundle_name",
99
+ )
100
+ return tuple(repairs)
101
+
102
+
103
+ # ------------------------------------------------------------- triage plan
104
+
105
+
106
+ @dataclass(frozen=True)
107
+ class ResolvedEntry:
108
+ id: str
109
+ evidence: str
110
+
111
+
112
+ @dataclass(frozen=True)
113
+ class Bundle:
114
+ name: str
115
+ dw_ids: tuple[str, ...]
116
+ intent: str
117
+ decision_note: str = "" # human-decision context appended to the intent file
118
+
119
+
120
+ @dataclass(frozen=True)
121
+ class DecisionOption:
122
+ key: str
123
+ label: str
124
+ effect: str # build | close | keep-open
125
+ intent: str = "" # required when effect == "build"
126
+ resolution: str = "" # optional when effect == "close"
127
+ bundle_name: str = "" # optional name override for the built bundle
128
+
129
+
130
+ @dataclass(frozen=True)
131
+ class Decision:
132
+ id: str
133
+ question: str
134
+ context: str
135
+ options: tuple[DecisionOption, ...]
136
+ recommendation: str
137
+
138
+ def option(self, key: str) -> DecisionOption | None:
139
+ for opt in self.options:
140
+ if opt.key == key:
141
+ return opt
142
+ return None
143
+
144
+
145
+ @dataclass(frozen=True)
146
+ class TriagePlan:
147
+ open_ids: frozenset[str]
148
+ already_resolved: tuple[ResolvedEntry, ...] = ()
149
+ bundles: tuple[Bundle, ...] = ()
150
+ blocked: tuple[tuple[str, str], ...] = () # (id, blocker)
151
+ skip: tuple[tuple[str, str], ...] = () # (id, reason)
152
+ decisions: tuple[Decision, ...] = ()
153
+
154
+
155
+ def validate_triage(
156
+ rj: dict[str, Any] | None, expected_open_ids: set[str] | None
157
+ ) -> tuple[TriagePlan | None, list[str]]:
158
+ """Deterministic validation of the triage session's result.json. Returns
159
+ (plan, []) or (None, errors). expected_open_ids=None skips the ledger
160
+ equality check (used when reloading a previously validated plan)."""
161
+ errors: list[str] = []
162
+ rj = rj or {}
163
+ _normalize_bundle_names(rj)
164
+ if rj.get("workflow") != TRIAGE_WORKFLOW:
165
+ return None, [f"workflow must be {TRIAGE_WORKFLOW!r}: got {rj.get('workflow')!r}"]
166
+
167
+ claimed_open = {str(i) for i in rj.get("open_ids", [])}
168
+ if expected_open_ids is not None and claimed_open != expected_open_ids:
169
+ missed = sorted(expected_open_ids - claimed_open)
170
+ invented = sorted(claimed_open - expected_open_ids)
171
+ return None, [
172
+ "open_ids do not match the ledger's open entries"
173
+ + (f"; missing: {', '.join(missed)}" if missed else "")
174
+ + (f"; not open in the ledger: {', '.join(invented)}" if invented else "")
175
+ ]
176
+ universe = expected_open_ids if expected_open_ids is not None else claimed_open
177
+
178
+ seen: dict[str, str] = {} # id -> category that claimed it
179
+
180
+ def claim(dw_id: str, category: str) -> None:
181
+ if dw_id not in universe:
182
+ errors.append(f"{category} references unknown/closed id {dw_id}")
183
+ elif dw_id in seen:
184
+ errors.append(f"{dw_id} appears in both {seen[dw_id]} and {category}")
185
+ else:
186
+ seen[dw_id] = category
187
+
188
+ resolved = []
189
+ for item in rj.get("already_resolved", []):
190
+ dw_id = str(item.get("id", ""))
191
+ evidence = str(item.get("evidence", "")).strip()
192
+ claim(dw_id, "already_resolved")
193
+ if not evidence:
194
+ errors.append(f"already_resolved {dw_id} has no evidence")
195
+ resolved.append(ResolvedEntry(dw_id, evidence))
196
+
197
+ bundles = []
198
+ names: set[str] = set()
199
+ for item in rj.get("bundles", []):
200
+ name = str(item.get("name", ""))
201
+ if not BUNDLE_NAME_RE.match(name):
202
+ errors.append(f"bundle name {name!r} invalid (want {BUNDLE_NAME_RE.pattern})")
203
+ # The one rule BUNDLE_NAME_RE cannot express. A cycle-1 bundle's name IS its
204
+ # directory (`_write_intent`), and the reserved Windows device basenames --
205
+ # CON, NUL, AUX, PRN, COM<N>, LPT<N> -- are `[a-z0-9-]`-legal names that no
206
+ # Windows filesystem will accept as one (matched case-insensitively, so
207
+ # lowercase is no reprieve). Testing `safe_segment` identity rather than a
208
+ # hand-written device list keeps this gate in lockstep with the sanitizer
209
+ # that defines the set: the identical idiom, for the identical reason, as
210
+ # `runs.is_valid_run_id`. Guarded on the match above so one bad name yields
211
+ # one error and not two.
212
+ if BUNDLE_NAME_RE.match(name) and safe_segment(name) != name:
213
+ errors.append(f"bundle name {name!r} is not a legal path segment")
214
+ if name in names:
215
+ errors.append(f"duplicate bundle name {name!r}")
216
+ names.add(name)
217
+ dw_ids = [str(i) for i in item.get("dw_ids", [])]
218
+ if not dw_ids:
219
+ errors.append(f"bundle {name!r} has no dw_ids")
220
+ for dw_id in dw_ids:
221
+ claim(dw_id, f"bundle {name!r}")
222
+ intent = str(item.get("intent", "")).strip()
223
+ if not intent:
224
+ errors.append(f"bundle {name!r} has no intent")
225
+ bundles.append(Bundle(name, tuple(dw_ids), intent))
226
+
227
+ blocked = []
228
+ for item in rj.get("blocked", []):
229
+ dw_id = str(item.get("id", ""))
230
+ blocker = str(item.get("blocker", "")).strip()
231
+ claim(dw_id, "blocked")
232
+ if not blocker:
233
+ errors.append(f"blocked {dw_id} names no blocker")
234
+ blocked.append((dw_id, blocker))
235
+
236
+ skip = []
237
+ for item in rj.get("skip", []):
238
+ dw_id = str(item.get("id", ""))
239
+ reason = str(item.get("reason", "")).strip()
240
+ claim(dw_id, "skip")
241
+ if not reason:
242
+ errors.append(f"skip {dw_id} gives no reason")
243
+ skip.append((dw_id, reason))
244
+
245
+ decisions = []
246
+ for item in rj.get("decisions", []):
247
+ dw_id = str(item.get("id", ""))
248
+ claim(dw_id, "decisions")
249
+ question = str(item.get("question", "")).strip()
250
+ if not question:
251
+ errors.append(f"decision {dw_id} has no question")
252
+ options = []
253
+ keys: set[str] = set()
254
+ decision_bundle_names: set[str] = set()
255
+ for raw in item.get("options", []):
256
+ key = str(raw.get("key", ""))
257
+ effect = str(raw.get("effect", ""))
258
+ intent = str(raw.get("intent", "")).strip()
259
+ if not key or key in keys:
260
+ errors.append(f"decision {dw_id}: missing/duplicate option key {key!r}")
261
+ keys.add(key)
262
+ if effect not in DECISION_EFFECTS:
263
+ errors.append(f"decision {dw_id} option {key}: bad effect {effect!r}")
264
+ if effect == "build" and not intent:
265
+ errors.append(f"decision {dw_id} option {key}: effect 'build' needs intent")
266
+ bundle_name = str(raw.get("bundle_name", ""))
267
+ if bundle_name and not BUNDLE_NAME_RE.match(bundle_name):
268
+ errors.append(f"decision {dw_id} option {key}: bad bundle_name {bundle_name!r}")
269
+ # The second site that mints a bundle directory, gated for the reason
270
+ # stated at the `bundles` loop above. A build-effect option's
271
+ # `bundle_name` becomes `Bundle.name` in `_materialize_bundles`, so it
272
+ # reaches `_write_intent`'s cycle-1 directory by the identical path --
273
+ # `BUNDLE_NAME_RE` is no more able to express the rule here than there.
274
+ # Guarded on the match above so one bad name yields one error, and on
275
+ # nothing else: an absent `bundle_name` fails that match already.
276
+ if BUNDLE_NAME_RE.match(bundle_name) and safe_segment(bundle_name) != bundle_name:
277
+ errors.append(
278
+ f"decision {dw_id} option {key}: bundle_name {bundle_name!r} "
279
+ "is not a legal path segment"
280
+ )
281
+ if effect == "build" and bundle_name:
282
+ if bundle_name in names:
283
+ errors.append(f"duplicate bundle name {bundle_name!r}")
284
+ decision_bundle_names.add(bundle_name)
285
+ options.append(
286
+ DecisionOption(
287
+ key=key,
288
+ label=str(raw.get("label", "")).strip() or key,
289
+ effect=effect,
290
+ intent=intent,
291
+ resolution=str(raw.get("resolution", "")).strip(),
292
+ bundle_name=bundle_name,
293
+ )
294
+ )
295
+ names.update(decision_bundle_names)
296
+ if len(options) < 2:
297
+ errors.append(f"decision {dw_id} needs at least 2 options")
298
+ recommendation = str(item.get("recommendation", ""))
299
+ if recommendation not in keys:
300
+ errors.append(f"decision {dw_id}: recommendation {recommendation!r} not an option")
301
+ decisions.append(
302
+ Decision(
303
+ dw_id,
304
+ question,
305
+ str(item.get("context", "")).strip(),
306
+ tuple(options),
307
+ recommendation,
308
+ )
309
+ )
310
+
311
+ unclaimed = sorted(universe - set(seen))
312
+ if unclaimed:
313
+ errors.append(f"open entries not triaged: {', '.join(unclaimed)}")
314
+
315
+ if errors:
316
+ return None, errors
317
+ return (
318
+ TriagePlan(
319
+ open_ids=frozenset(universe),
320
+ already_resolved=tuple(resolved),
321
+ bundles=tuple(bundles),
322
+ blocked=tuple(blocked),
323
+ skip=tuple(skip),
324
+ decisions=tuple(decisions),
325
+ ),
326
+ [],
327
+ )
328
+
329
+
330
+ # ---------------------------------------------------------- migration plan
331
+
332
+
333
+ @dataclass(frozen=True)
334
+ class PreCanonical:
335
+ """What a pre-existing canonical entry is held to across a migration.
336
+
337
+ Status alone was the whole snapshot until #519, and that is what let a
338
+ rewrite drop a ``gate:`` line and pass: the gate is the one field whose loss
339
+ is both silent and unsafe. ``deferred-work-format.md`` calls removing it
340
+ "the exact failure this field exists to prevent", and
341
+ ``Engine._refuse_gated_story`` then dispatches the story the entry was
342
+ holding back.
343
+
344
+ ``gate_tokens`` unions :attr:`~froid_loop.deferredwork.EntryGates.tokens`
345
+ with ``malformed`` because the question is "did a token the entry declared
346
+ survive", not "was it enforceable". A malformed ``gate: 3.2`` gates nothing,
347
+ but it reads to anyone scanning the entry as a gate in force and ``validate``
348
+ reports it (``deferred.hard-gate-unstructured``); dropping it retires that
349
+ report silently, which is the same failure one level down.
350
+
351
+ The counts ``EntryGates`` also carries — ``lines``, ``empty``, ``near_miss``
352
+ — are deliberately NOT snapshotted. None of them names a story, so losing one
353
+ cannot change which story is gated, and they are exactly what a legitimate
354
+ reflow of a multi-line declaration moves.
355
+ """
356
+
357
+ status: str
358
+ gate_tokens: tuple[str, ...]
359
+
360
+
361
+ def snapshot_canonical(text: str) -> dict[str, PreCanonical]:
362
+ """The pre-migration state ``validate_migration`` holds the rewrite to.
363
+
364
+ A named function rather than a comprehension inlined at its one call site so
365
+ that the tests grade the snapshot production actually builds: a hand-written
366
+ ``{"DW-1": PreCanonical("open", ("3-2",))}`` would pass whatever the parser
367
+ really produces for that entry, and the bug being fixed here lived in the
368
+ snapshot, not in the comparison.
369
+
370
+ Keying by id is safe only because ``_ensure_migration`` refuses a ledger
371
+ that carries duplicate canonical ids before any rewrite is attempted. Do
372
+ not soften that refusal into per-id collapse-hardening here: tokens and
373
+ status snapshotted independently describe an entry that never existed, and
374
+ each half patched on its own opens the next cross-product (a token
375
+ harvested from a ``done`` twin paired with an ``open`` twin's status both
376
+ refuses a faithful rewrite and newly gates a story that was not gated).
377
+ """
378
+ snapshot: dict[str, PreCanonical] = {}
379
+ for e in deferredwork.parse_ledger(text):
380
+ g = deferredwork.gates(e)
381
+ snapshot[e.id] = PreCanonical(e.status, g.tokens + g.malformed)
382
+ return snapshot
383
+
384
+
385
+ def duplicate_ids(entries: Iterable[deferredwork.DWEntry]) -> list[str]:
386
+ """The DW ids naming more than one entry, sorted.
387
+
388
+ One function for both sides of a migration on purpose: the rewrite is
389
+ refused when the ledger it STARTED from carries duplicates and when the
390
+ ledger it produced does, and two detectors that disagreed about what counts
391
+ as a duplicate would leave exactly the gap between them open.
392
+ """
393
+ seen: set[str] = set()
394
+ dupes: set[str] = set()
395
+ for e in entries:
396
+ (dupes if e.id in seen else seen).add(e.id)
397
+ return sorted(dupes)
398
+
399
+
400
+ def validate_migration(
401
+ rj: dict[str, Any] | None,
402
+ manifest: list[dict[str, Any]],
403
+ pre_canonical: dict[str, PreCanonical],
404
+ new_text: str,
405
+ ) -> list[str]:
406
+ """Deterministic validation of a legacy-ledger migration session: the
407
+ rewritten ledger must contain zero legacy items, preserve every
408
+ pre-existing canonical entry's status and every ``gate:`` token it
409
+ declared, continue DW numbering, and the result.json mapping must cover
410
+ the manifest exactly. Returns errors, empty on success."""
411
+ rj = rj or {}
412
+ if rj.get("workflow") != MIGRATE_WORKFLOW:
413
+ return [f"workflow must be {MIGRATE_WORKFLOW!r}: got {rj.get('workflow')!r}"]
414
+ errors: list[str] = []
415
+
416
+ leftovers = deferredwork.parse_legacy(new_text)
417
+ if leftovers:
418
+ listed = "; ".join(f"{e.section or 'top level'}: {e.title[:60]}" for e in leftovers[:10])
419
+ errors.append(f"{len(leftovers)} legacy item(s) still parse as legacy: {listed}")
420
+
421
+ parsed = deferredwork.parse_ledger(new_text)
422
+ entries: dict[str, deferredwork.DWEntry] = {e.id: e for e in parsed}
423
+ dupes = duplicate_ids(parsed)
424
+ if dupes:
425
+ errors.append("duplicate DW ids: " + ", ".join(dupes))
426
+
427
+ def first_word(status: str) -> str:
428
+ return status.split()[0] if status.split() else ""
429
+
430
+ pre_max = max((int(i.split("-")[1]) for i in pre_canonical), default=0)
431
+ for dw_id, pre in pre_canonical.items():
432
+ e = entries.get(dw_id)
433
+ if e is None:
434
+ errors.append(f"pre-existing {dw_id} disappeared")
435
+ continue
436
+ if first_word(e.status) != first_word(pre.status):
437
+ errors.append(f"pre-existing {dw_id} status changed: {pre.status!r} -> {e.status!r}")
438
+ # Drops and edits only; an ADDED token is deliberately accepted. The two
439
+ # directions are not the same failure: a dropped token un-gates a story
440
+ # silently, which is what #519 is about, while an added one over-blocks
441
+ # loudly and in the safe direction — the operator meets a refusal naming
442
+ # the entry. Refusing an addition would spend one of the two migration
443
+ # attempts on the only direction that cannot cause the failure this
444
+ # guard exists to stop. An EDITED token is caught here anyway: an edit
445
+ # is a drop plus an add, and the drop half is what this reads.
446
+ post = deferredwork.gates(e)
447
+ kept = set(post.tokens) | set(post.malformed)
448
+ lost = [t for t in pre.gate_tokens if t not in kept]
449
+ if lost:
450
+ errors.append(f"pre-existing {dw_id} lost gate token(s): {', '.join(lost)}")
451
+ for dw_id, e in entries.items():
452
+ if dw_id in pre_canonical:
453
+ continue
454
+ if int(dw_id.split("-")[1]) <= pre_max:
455
+ errors.append(f"new entry {dw_id} does not continue numbering past DW-{pre_max}")
456
+ if first_word(e.status) not in ("open", "done"):
457
+ errors.append(f"new entry {dw_id} has status {e.status!r}; want open or done")
458
+
459
+ manifest_by_key = {str(m["key"]): m for m in manifest}
460
+ mapping = rj.get("mapping", [])
461
+ if not isinstance(mapping, list):
462
+ return errors + ["mapping must be a list of {key, dw_id}"]
463
+ seen_keys: set[str] = set()
464
+ for item in mapping:
465
+ key = str(item.get("key", "")) if isinstance(item, dict) else ""
466
+ dw_id = str(item.get("dw_id", "")) if isinstance(item, dict) else ""
467
+ source = manifest_by_key.get(key)
468
+ if source is None:
469
+ errors.append(f"mapping invents unknown key {key!r}")
470
+ continue
471
+ if key in seen_keys:
472
+ errors.append(f"mapping repeats key {key!r}")
473
+ seen_keys.add(key)
474
+ target = entries.get(dw_id)
475
+ if target is None:
476
+ errors.append(f"mapping {key} -> {dw_id}: no such entry in the ledger")
477
+ elif (first_word(target.status) == "done") != bool(source["done"]):
478
+ want = "done" if source["done"] else "open"
479
+ errors.append(f"mapping {key} -> {dw_id}: manifest says {want}, ledger disagrees")
480
+ missing = sorted(set(manifest_by_key) - seen_keys)
481
+ if missing:
482
+ errors.append("manifest keys not mapped: " + ", ".join(missing))
483
+ return errors
484
+
485
+
486
+ # --------------------------------------------------------------- prompting
487
+
488
+
489
+ class DecisionPrompter:
490
+ """Walks the human through pending decisions on the terminal. Injection
491
+ points exist so tests can script answers.
492
+
493
+ The interactive terminal prompt is the v1 protocol: observers (the TUI
494
+ dashboard, ATTENTION watchers) learn a sweep is blocked from the
495
+ decision-pending journal event written just before ask() and attach to
496
+ the sweep's tmux window to answer. A decisions-file protocol — engine
497
+ writes the pending question to a file and polls for an answer the TUI
498
+ could write in-app — is deliberately deferred to v2; it needs timeout +
499
+ ownership semantics this run-blocking prompt avoids."""
500
+
501
+ def __init__(
502
+ self,
503
+ input_fn: Callable[[str], str] = input,
504
+ print_fn: Callable[[str], None] = print,
505
+ ):
506
+ self.input_fn = input_fn
507
+ self.print_fn = print_fn
508
+
509
+ def ask(self, decision: Decision) -> DecisionOption:
510
+ p = self.print_fn
511
+ p("")
512
+ p(f"── decision needed: {decision.id} " + "─" * 30)
513
+ p(decision.question)
514
+ if decision.context:
515
+ p("")
516
+ p(decision.context)
517
+ p("")
518
+ for opt in decision.options:
519
+ marker = " (recommended)" if opt.key == decision.recommendation else ""
520
+ p(f" [{opt.key}] {opt.label} — {opt.effect}{marker}")
521
+ if opt.intent:
522
+ p(f" {opt.intent}")
523
+ keys = [o.key for o in decision.options]
524
+ while True:
525
+ raw = self.input_fn(
526
+ f"choice [{'/'.join(keys)}] (enter = {decision.recommendation}): "
527
+ ).strip()
528
+ if not raw:
529
+ raw = decision.recommendation
530
+ chosen = decision.option(raw)
531
+ if chosen is not None:
532
+ return chosen
533
+ p(f" invalid choice {raw!r}")
534
+
535
+
536
+ # ------------------------------------------------------------ sweep engine
537
+
538
+
539
+ class SweepEngine(Engine):
540
+ """Engine variant whose loop processes the deferred-work ledger instead
541
+ of sprint-status. Bundles reuse the inherited story pipeline through the
542
+ override seams; the triage session has its own phase pair."""
543
+
544
+ def __init__(
545
+ self,
546
+ *args: Any,
547
+ triage_adapter: Any = None,
548
+ prompting: bool = False,
549
+ decisions_only: bool = False,
550
+ max_bundles: int | None = None,
551
+ repeat: bool | None = None,
552
+ max_cycles: int | None = None,
553
+ prompter: DecisionPrompter | None = None,
554
+ **kwargs: Any,
555
+ ):
556
+ super().__init__(*args, **kwargs)
557
+ self.adapters["triage"] = (
558
+ triage_adapter if triage_adapter is not None else self.adapters["dev"]
559
+ )
560
+ self.prompting = prompting
561
+ self.decisions_only = decisions_only
562
+ self.max_bundles = max_bundles if max_bundles is not None else self.policy.sweep.max_bundles
563
+ self.repeat = repeat if repeat is not None else self.policy.sweep.repeat
564
+ self.max_cycles = max_cycles if max_cycles is not None else self.policy.sweep.max_cycles
565
+ self.prompter = prompter or DecisionPrompter()
566
+ # decisions already journaled as skipped this process; without it a
567
+ # persistent decision item would notify once per repeat cycle
568
+ self._skipped_decisions: set[str] = set()
569
+ self.state.run_type = "sweep"
570
+
571
+ def _remaining_estimate(self) -> int | None:
572
+ """Sweep override of the graceful-stop hint: how many deferred-work
573
+ entries are still open in the ledger — the work a resume would pick up.
574
+ Like the base, a hint only: the whole body is guarded so an
575
+ unreadable/invalid ledger returns None rather than derailing the stop."""
576
+ try:
577
+ ledger = self.workspace.paths.deferred_work
578
+ text = ledger.read_text(encoding="utf-8") if ledger.is_file() else ""
579
+ return len(deferredwork.open_ids(text))
580
+ except Exception: # a hint must never break the stop
581
+ return None
582
+
583
+ # ------------------------------------------------------------ main loop
584
+
585
+ def _loop(self) -> None:
586
+ ledger = self.workspace.paths.deferred_work
587
+ cycle = max(1, self.state.sweep_cycle)
588
+ if self._finish_inflight_bundles():
589
+ # a recovered bundle's ledger restore can leave the tree dirty, and
590
+ # triage plus the first bundle baseline need a clean one. Guarded on
591
+ # a non-empty pass so a fresh sweep never commits the user's dirt.
592
+ self._commit_ledger("chore(sweep): commit ledger after recovering in-flight bundles")
593
+ while True:
594
+ # First statement of the loop body: covers the boundary right after
595
+ # _finish_inflight_bundles on resume and between repeat cycles. A
596
+ # request during a cycle is caught before the next _run_bundle (see
597
+ # _cycle); one landing between cycles stops here before cycle N+1
598
+ # re-triages.
599
+ self._check_stop_request()
600
+ self.state.sweep_cycle = cycle
601
+ self._save()
602
+ text = ledger.read_text(encoding="utf-8") if ledger.is_file() else ""
603
+ if deferredwork.has_legacy(text):
604
+ if cycle > 1:
605
+ # freeform text appeared mid-run; _ensure_migration assumes
606
+ # one migration per run, so hand off to a fresh sweep
607
+ self.journal.append(
608
+ "sweep-repeat-done", cycles=cycle - 1, reason="legacy-appeared"
609
+ )
610
+ gates.notify(
611
+ self.policy,
612
+ self.run_dir,
613
+ "legacy ledger entries appeared mid-sweep",
614
+ "run a fresh `froid-loop sweep` to migrate them",
615
+ )
616
+ return
617
+ self._ensure_migration(text)
618
+ text = ledger.read_text(encoding="utf-8") if ledger.is_file() else ""
619
+ open_now = deferredwork.open_ids(text)
620
+ if not open_now:
621
+ if cycle == 1:
622
+ self.journal.append("sweep-nothing-open", ledger=str(ledger))
623
+ else:
624
+ self.journal.append("sweep-repeat-done", cycles=cycle - 1, reason="no-open")
625
+ return
626
+ if cycle > 1:
627
+ self.journal.append("sweep-cycle", cycle=cycle, open=len(open_now))
628
+ progressed = self._cycle(cycle, open_now)
629
+ if self.decisions_only or not self.repeat:
630
+ return
631
+ if not progressed:
632
+ self.journal.append("sweep-repeat-done", cycles=cycle, reason="no-progress")
633
+ return
634
+ if cycle >= self.max_cycles:
635
+ self.journal.append("sweep-repeat-done", cycles=cycle, reason="max-cycles")
636
+ return
637
+ # a deferred bundle's ledger restore can leave the tree dirty; the
638
+ # next cycle's triage and bundle baselines need a clean tree
639
+ self._commit_ledger("chore(sweep): commit ledger before next sweep cycle")
640
+ cycle += 1
641
+
642
+ def _finish_inflight_bundles(self) -> int:
643
+ """Re-drive every bundle this run left in flight, keyed on the task's own
644
+ persisted story_key. Returns how many were recovered.
645
+
646
+ The base Engine._loop opens with _finish_inflight for exactly this reason;
647
+ the sweep loop used to recover a bundle only from inside _run_bundle, which
648
+ a cycle reaches only after re-deriving the bundle's key from the *current*
649
+ triage plan. A re-armed bundle therefore survived a resume solely because
650
+ the cached triage.json reloaded and re-emitted the same bundle name — lose
651
+ that cache and a fresh triage partitions the ids under new names, silently
652
+ orphaning the human's resolution (#94).
653
+
654
+ Runs before the ledger is read, so a bundle it closes leaves the open set
655
+ and no fresh triage can re-bundle those ids (validate_triage rejects a plan
656
+ whose open_ids disagree with the ledger). A recovered bundle that defers or
657
+ escalates keeps its ids open. A dev-leg discard never closes them; an
658
+ in-place post-acceptance defer reopens this run's close, while an isolated
659
+ unit's close dies with its unmerged worktree. The existing failed_ids filter
660
+ then drops the fresh plan's overlapping bundle."""
661
+ recovered = 0
662
+ for task in list(self.state.tasks.values()):
663
+ if task.terminal or not BUNDLE_KEY_RE.match(task.story_key):
664
+ continue
665
+ recovered += 1
666
+ self.journal.append(
667
+ "sweep-inflight-redrive",
668
+ story_key=task.story_key,
669
+ phase=str(task.phase),
670
+ rearmed=task.rearmed, # read before the recovery clears the latch
671
+ )
672
+ if self._recover_inflight_bundle(task):
673
+ continue
674
+ self._ensure_bundle_intent(task)
675
+ self._save()
676
+ self._emit("pre_bundle", task)
677
+ self._run_story(task)
678
+ self._emit("post_bundle", task)
679
+ return recovered
680
+
681
+ def _warn_stranded_bundles(self) -> None:
682
+ """Invariant: _finish_inflight_bundles has driven every persisted bundle to
683
+ a terminal phase before a cycle picks new work. A survivor means a bundle
684
+ would be silently dropped — say so loudly rather than sweep past it."""
685
+ stranded = [
686
+ t.story_key
687
+ for t in self.state.tasks.values()
688
+ if BUNDLE_KEY_RE.match(t.story_key) and not t.terminal
689
+ ]
690
+ if not stranded:
691
+ return
692
+ self.journal.append("sweep-inflight-stranded", story_keys=stranded)
693
+ gates.notify(
694
+ self.policy,
695
+ self.run_dir,
696
+ f"{len(stranded)} sweep bundle(s) left in flight",
697
+ "not re-driven by this cycle: " + ", ".join(stranded),
698
+ )
699
+
700
+ def _cycle(self, cycle: int, open_now: set[str]) -> bool:
701
+ """One triage -> close -> decide -> bundle pass. Returns whether the
702
+ cycle completed any addressable work — the repeat loop's progress
703
+ predicate. Caveat: on crash-resume of a cycle whose only progress was
704
+ already-resolved closes, the replayed (idempotent) closes report 0 and
705
+ the run stops with no-progress; errs toward stopping, never loops."""
706
+ self._emit("pre_sweep_cycle", phase=str(cycle))
707
+ self._warn_stranded_bundles()
708
+ plan = self._ensure_triage(open_now, cycle)
709
+ closed = self._close_resolved(plan)
710
+ answers, decisions_closed = self._decisions_phase(plan)
711
+ bundles = self._materialize_bundles(plan, answers)
712
+ if self.decisions_only:
713
+ self.journal.append("sweep-decisions-only", bundles_not_run=len(bundles))
714
+ self._prune_pre_answers()
715
+ self._emit("post_sweep_cycle", phase=str(cycle))
716
+ return False
717
+ for bundle in bundles:
718
+ # Item boundary: a request during bundle N lets N finish through
719
+ # commit; bundle N+1 never starts. A request landing during triage
720
+ # reaches the first iteration here, so triage completes but zero
721
+ # bundles run. Mid-cycle stop is resume-safe: sweep_cycle is
722
+ # persisted, triage.json is cached, closes are idempotent, and
723
+ # terminal tasks are skipped on re-drive.
724
+ self._check_stop_request()
725
+ self._run_bundle(bundle, cycle)
726
+ bundles_done = sum(
727
+ 1
728
+ for b in bundles
729
+ if self.state.tasks[self._bundle_key(b.name, cycle)].phase == Phase.DONE
730
+ )
731
+ self._prune_pre_answers()
732
+ self._emit("post_sweep_cycle", phase=str(cycle))
733
+ return closed > 0 or decisions_closed > 0 or bundles_done > 0
734
+
735
+ def _prune_pre_answers(self) -> None:
736
+ """Drop consumed pre-answers — entries built or closed this cycle have
737
+ left the open set. Keeps the store from re-applying a stale answer (and a
738
+ keep-open answer's audit line) on the next sweep."""
739
+ from . import decisions as decisions_store # lazy: decisions imports sweep
740
+
741
+ ledger = self.workspace.paths.deferred_work
742
+ text = ledger.read_text(encoding="utf-8") if ledger.is_file() else ""
743
+ # The store lives under the project that owns `run_dir`, never
744
+ # `self.workspace.root`: under the `repo_root` override the two diverge
745
+ # and a workspace-rooted prune trimmed a store that does not exist,
746
+ # leaving consumed entries behind (the comment in `_decisions_phase`
747
+ # says why the run dir is the stable anchor). The ledger read above is
748
+ # unaffected — `deferred_work` hangs off `implementation_artifacts`,
749
+ # which stays project-rooted under the override.
750
+ dropped = decisions_store.prune_pre_answers(
751
+ _project_of_run_dir(self.run_dir), deferredwork.open_ids(text)
752
+ )
753
+ if dropped:
754
+ self.journal.append("decision-preanswers-pruned", dw_ids=dropped)
755
+ self._commit_ledger("chore(sweep): drop consumed deferred-work pre-answers")
756
+
757
+ def _drive_story(self, task: StoryTask) -> None:
758
+ # no spec-approval gate for bundles: the bundle intent came from the
759
+ # validated triage plan (and, for decision bundles, from the human).
760
+ # The base _run_story wraps this in a worktree when isolation=worktree.
761
+ if self._dev_phase(task):
762
+ self._review_and_commit(task)
763
+
764
+ # cycle 1 keeps the legacy key so pre-repeat paused runs resume unchanged;
765
+ # "dw{N}-" (not "dw-c{N}-") so a cycle-1 bundle named "c2-foo" can never
766
+ # collide with a cycle-2 bundle named "foo"
767
+ def _bundle_key(self, name: str, cycle: int) -> str:
768
+ return f"dw-{name}" if cycle == 1 else f"dw{cycle}-{name}"
769
+
770
+ def _run_bundle(self, bundle: Bundle, cycle: int) -> None:
771
+ key = self._bundle_key(bundle.name, cycle)
772
+ task = self.state.tasks.get(key)
773
+ if task is not None and task.terminal:
774
+ return # finished (or adjudicated) in a previous resume cycle
775
+ if task is None:
776
+ task = StoryTask(story_key=key, epic=0, dw_ids=list(bundle.dw_ids))
777
+ self.state.tasks[key] = task
778
+ self.journal.append("bundle-start", story_key=key, dw_ids=list(bundle.dw_ids))
779
+ elif self._recover_inflight_bundle(task):
780
+ return
781
+ dirname = bundle.name if cycle == 1 else f"c{cycle}-{bundle.name}"
782
+ task.bundle_file = str(self._write_intent(bundle, dirname))
783
+ self._save()
784
+ self._emit("pre_bundle", task)
785
+ self._run_story(task)
786
+ self._emit("post_bundle", task)
787
+
788
+ def _recover_inflight_bundle(self, task: StoryTask) -> bool:
789
+ """Recover a bundle task interrupted mid-flight (or re-armed after a
790
+ human resolved its escalation via `froid-loop resolve`): the same recovery
791
+ as Engine._finish_inflight, including the re-drive latch so a
792
+ human-resolved escalation is protected through every reset (mirrors
793
+ engine.py:1163-1169).
794
+
795
+ Returns True when the persisted PROCEED receipt carried the bundle all
796
+ the way through accepted sync, review, and commit — the caller is done.
797
+ Returns False once the task has been reset to PENDING, leaving the caller
798
+ to dispatch it. A bare DEV_VERIFY + spec_file shape is insufficient: the
799
+ pre-action decision save has the same shape for rejected decisions.
800
+
801
+ Deliberately narrower than the base _finish_inflight: no
802
+ `_resumable_session` arm, so a bundle whose host died in the
803
+ post-session window still restarts rather than replaying its recorded
804
+ result. Lifting that is a resume-fidelity change of its own. The
805
+ COMMITTING window IS recovered, though — same as the base engine's
806
+ resume-commit arm (#115)."""
807
+ if task.worktree_path:
808
+ # The same re-anchor `Engine._finish_inflight` makes, for the same reason
809
+ # and in the same position — ABOVE the `isolated` gate. Sweep does not
810
+ # inherit it: `SweepEngine` replaces `_loop` wholesale and `Engine._loop`
811
+ # is the only caller of `_finish_inflight`, so nothing on this path had
812
+ # re-absolutized the persisted spelling. Both legs below need it. The
813
+ # restart arm discards the mount and clears `worktree_path` before the
814
+ # caller saves, which would strand the mount-RELATIVE value beside an
815
+ # empty `worktree_path` (`_serialized_worktree_path` only relativizes
816
+ # while that field is set); and the gate is live policy, so an
817
+ # `isolation` flip across a resume drops the `elif task.baseline_commit`
818
+ # and the two non-isolated arms onto never-re-anchored paths. Either way
819
+ # the raw value resolves against the MAIN checkout — same layout, so
820
+ # `recovery_flow._attempt_owned_spec` finds one candidate,
821
+ # `spec_within_roots` accepts it, and the snapshot restore rewrites the
822
+ # operator's own copy.
823
+ task.rebase_spec_paths_on(Path(task.worktree_path))
824
+ isolated = self._isolated and task.worktree_path
825
+ if task.phase == Phase.COMMITTING:
826
+ # the gate+advance save landed pre-death; finish the commit
827
+ # instead of rolling verified bundle work back (see
828
+ # Engine._finalize_commit_phase for the re-drive contract).
829
+ self.journal.append("resume-commit", story_key=task.story_key)
830
+ if isolated:
831
+ unit = self._reopen_unit(task)
832
+ prev = self.workspace
833
+ self.workspace = unit.workspace
834
+ try:
835
+ self._finalize_commit_phase(task)
836
+ finally:
837
+ self.workspace = prev
838
+ self._integrate_unit(task, unit)
839
+ else:
840
+ self._finalize_commit_phase(task)
841
+ return True
842
+ self.journal.append("resume-restart", story_key=task.story_key, phase=str(task.phase))
843
+ if (
844
+ task.phase == Phase.DEV_VERIFY
845
+ and task.spec_file
846
+ and self._accepted_dev_session_matches(task)
847
+ ):
848
+ self._save()
849
+ if isolated:
850
+ unit = self._reopen_unit(task)
851
+ prev = self.workspace
852
+ self.workspace = unit.workspace
853
+ try:
854
+ self._resume_after_dev_verify(task)
855
+ finally:
856
+ self.workspace = prev
857
+ self._integrate_unit(task, unit)
858
+ else:
859
+ self._resume_after_dev_verify(task)
860
+ return True
861
+ if isolated:
862
+ # drop the half-built worktree; _run_story mounts a fresh one
863
+ self._discard_unit_for_restart(task)
864
+ elif task.baseline_commit:
865
+ # latch resolved_redrive so the corrected spec + restored diff stay
866
+ # protected through every reset of this re-drive, not just this
867
+ # first one; cause="resolved" keeps a human-initiated re-arm
868
+ # pause-free regardless of scm.rollback_on_failure
869
+ task.resolved_redrive = task.resolved_redrive or task.rearmed
870
+ self._rollback_or_pause(task, cause="resolved" if task.rearmed else "stopped")
871
+ task.rearmed = False # past rollback (only reached when not paused)
872
+ task.phase = Phase.PENDING # deliberate reset, not a normal transition
873
+ return False
874
+
875
+ # ------------------------------------------------------------ migration
876
+
877
+ def _ensure_migration(self, text: str) -> None:
878
+ """Pre-DW-format ledger content (older FROID-method projects) blocks a
879
+ sweep: open_ids() cannot see it and mark_done() cannot flip it. One
880
+ LLM session rewrites the legacy items into canonical DW entries; the
881
+ orchestrator pins exactly what to convert (a manifest from
882
+ parse_legacy), validates the rewrite deterministically, and restores
883
+ the original ledger before any retry."""
884
+ ledger = self.workspace.paths.deferred_work
885
+ task = self.state.tasks.get(MIGRATE_KEY)
886
+ if task is None:
887
+ task = StoryTask(story_key=MIGRATE_KEY, epic=0)
888
+ self.state.tasks[MIGRATE_KEY] = task
889
+ elif task.phase != Phase.PENDING:
890
+ # resumed mid-migration or retrying after an escalation: restart
891
+ self.journal.append("resume-restart", story_key=MIGRATE_KEY, phase=str(task.phase))
892
+ if task.phase == Phase.ESCALATED:
893
+ task.attempt = 0 # the human resumed deliberately; fresh budget
894
+ if task.baseline_commit and not verify.worktree_clean(self.workspace.root):
895
+ self._safe_reset(task) # a session died mid-rewrite; restore our ledger
896
+ text = ledger.read_text(encoding="utf-8") if ledger.is_file() else ""
897
+ task.phase = Phase.PENDING # deliberate reset, not a normal transition
898
+ # **The invariant: a refusal that dispatches nothing leaves this task
899
+ # owning NO baseline.** It takes both halves below. Sitting above the
900
+ # stamp keeps a fresh entry from taking one; clearing handles the entry
901
+ # that arrives already holding one, which the resume-after-escalation
902
+ # branch above does. Either way the next resume re-stamps the repaired
903
+ # HEAD. Leave a baseline behind and it names the PRE-repair tree: the
904
+ # operator renumbers and resumes, and when that migration session
905
+ # env-faults, the next resume's `_safe_reset` rewinds to that stale
906
+ # baseline — destroying the repair and any commits beside it, and
907
+ # landing back on this same pause. Safe to clear because nothing of
908
+ # ours is outstanding here: no session has run, and the branch above
909
+ # has already restored the tree if a previous one died mid-rewrite.
910
+ # This still sits BELOW that branch, because the ledger it reads must
911
+ # be the restored one.
912
+ #
913
+ # Refused BEFORE a rewrite is dispatched, not after one is graded (#519).
914
+ # A ledger where one id names two entries is corrupt in a way the format
915
+ # cannot express, and there is no automatic outcome that is right: this
916
+ # mode is required to keep pre-existing entries byte-identical, so the
917
+ # only rewrite that preserves the pair trips `duplicate DW ids` in
918
+ # validate_migration, and the only rewrite that passes is a collapse
919
+ # that drops one twin's `gate:` silently. Grading the collapse instead
920
+ # cannot be made safe — a snapshot keyed by id describes a merged entry
921
+ # that never existed, and each half hardened on its own opens the next
922
+ # cross-product. So a human renumbers, which is the call
923
+ # `_apply_deferred_closes` already makes on a duplicate id (#286).
924
+ # Paused rather than ESCALATED, and the task stays PENDING on purpose:
925
+ # that is what makes the refusal re-askable the way `_refuse_gated_story`
926
+ # is. The operator renumbers the ids and resumes, and this re-reads the
927
+ # ledger and lets the migration run — an ESCALATED task would also spend
928
+ # the migration attempt budget on a rewrite that never happened.
929
+ #
930
+ # PAUSE_STORY_GATE, NOT PAUSE_ESCALATION, and the pairing is the point:
931
+ # the stage picks the recovery UI, and every escalation action
932
+ # (`runs.rearm_story`, the TUI's Resolve) requires the task to be
933
+ # Phase.ESCALATED, which this one deliberately is not — so an escalation
934
+ # stage here would offer the operator only actions that must fail. The
935
+ # gate stage routes to a viewer whose single action is "resume", which
936
+ # is the whole remedy once the ids are renumbered. `_refuse_gated_story`
937
+ # already pauses this way with a task that is not escalated: same
938
+ # contract — the deferred-work ledger blocks the run, a human edits it,
939
+ # the resume re-reads it.
940
+ dupes = duplicate_ids(deferredwork.parse_ledger(text))
941
+ if dupes:
942
+ reason = (
943
+ f"{ledger.name} carries duplicate DW ids: {', '.join(dupes)} — one id "
944
+ "names more than one entry, so no migration of it can both preserve "
945
+ "the entries and produce a valid ledger; renumber or merge them by "
946
+ "hand and COMMIT the fix, then resume"
947
+ )
948
+ self.journal.append("migrate-duplicate-ids", story_key=MIGRATE_KEY, dw_ids=list(dupes))
949
+ gates.notify(
950
+ self.policy,
951
+ self.run_dir,
952
+ f"migration refused: {ledger.name}",
953
+ f"{reason} — then `froid-loop resume {self.state.run_id}`",
954
+ )
955
+ task.baseline_commit = None
956
+ task.baseline_untracked = None
957
+ self._save()
958
+ raise RunPaused(reason, PAUSE_STORY_GATE, MIGRATE_KEY)
959
+
960
+ if not task.baseline_commit:
961
+ # `self.workspace.root`, which is `paths.repo_root` — the same anchor the
962
+ # dev writer (`Engine._dev_phase`), the re-arm writer
963
+ # (`runs.rearm_escalation`) and every proof-of-work probe in
964
+ # `verify._verify_shared_gates` use. Under the `repo_root` override it is
965
+ # NOT `paths.project`, and a baseline stamped in one tree and measured in
966
+ # the other names a commit the measuring repo has never heard of (#716).
967
+ task.baseline_commit = verify.rev_parse_head(self.workspace.root)
968
+ task.baseline_untracked = sorted(verify.untracked_files(self.workspace.root))
969
+
970
+ legacy = deferredwork.parse_legacy(text)
971
+ pre_canonical = snapshot_canonical(text)
972
+ manifest = [
973
+ {
974
+ "key": e.key,
975
+ "id": e.id,
976
+ "title": e.title,
977
+ "section": e.section,
978
+ "done": e.done,
979
+ "severity": e.severity,
980
+ }
981
+ for e in legacy
982
+ ]
983
+ manifest_path = self.run_dir / "migrate-manifest.json"
984
+ manifest_path.write_text(json.dumps(manifest, indent=2), encoding="utf-8")
985
+
986
+ feedback: Path | None = None
987
+ while True:
988
+ task.attempt += 1
989
+ advance(task, Phase.TRIAGE_RUNNING)
990
+ self._save()
991
+ result = self._run_session(
992
+ task,
993
+ role="triage",
994
+ prompt=self._migrate_prompt(manifest_path, feedback),
995
+ seq=task.attempt,
996
+ session_stage="pre_migrate_session",
997
+ )
998
+ advance(task, Phase.TRIAGE_VERIFY)
999
+ self._save()
1000
+ crits = critical_escalations(result.result_json)
1001
+ if crits:
1002
+ details = "; ".join(str(e.get("detail", e.get("type", "?"))) for e in crits)
1003
+ self._escalate(task, f"CRITICAL escalation from migration session: {details}")
1004
+ # Split so ABSENCE survives: `new_text` stays `str` for
1005
+ # `validate_migration`, while `rewrite` keeps the difference between
1006
+ # "the session emptied the ledger" and "the session deleted it". On
1007
+ # an untracked ledger the restore below has no blob to anchor on and
1008
+ # this rejected rewrite — the exact text this attempt graded — is the
1009
+ # anchor instead, so flattening `None` to `""` here would make the
1010
+ # deleted-ledger case indistinguishable from a rival's empty write.
1011
+ rewrite = ledger.read_text(encoding="utf-8") if ledger.is_file() else None
1012
+ new_text = rewrite if rewrite is not None else ""
1013
+ if result.status != "completed":
1014
+ errors = [session_failure_reason("migration", result)]
1015
+ else:
1016
+ errors = validate_migration(result.result_json, manifest, pre_canonical, new_text)
1017
+ self.journal.append(
1018
+ "migrate-decision",
1019
+ attempt=task.attempt,
1020
+ session_status=result.status,
1021
+ ok=not errors,
1022
+ errors=errors,
1023
+ env_fault=result.env_fault,
1024
+ )
1025
+ if result.status != "completed" and result.env_fault:
1026
+ # The migration session's CLI lost its API connection (#194): it did
1027
+ # no rewrite work, so pause (the ESCALATED-resume above resets
1028
+ # task.attempt to 0 — fresh budget) instead of charging a migration
1029
+ # attempt. Escalate BEFORE the _safe_reset/attempt-cap path; that
1030
+ # resume also restores the ledger if the worktree is dirty.
1031
+ self._escalate(
1032
+ task,
1033
+ env_fault_pause_reason("migration", result),
1034
+ )
1035
+ if not errors:
1036
+ advance(task, Phase.DONE)
1037
+ self._save()
1038
+ (self.run_dir / "migrate-result.json").write_text(
1039
+ json.dumps(result.result_json, indent=2), encoding="utf-8"
1040
+ )
1041
+ self._commit_ledger(
1042
+ "chore(sweep): migrate legacy deferred-work entries to DW format"
1043
+ )
1044
+ post = deferredwork.parse_ledger(new_text)
1045
+ self.journal.append(
1046
+ "sweep-migrated",
1047
+ converted=len(manifest),
1048
+ entries_now=len(post),
1049
+ open_now=sum(1 for e in post if e.open),
1050
+ )
1051
+ self._emit("post_migrate", task)
1052
+ return
1053
+ # never re-prompt over a half-broken rewrite; the baseline reset
1054
+ # covers tracked files, the explicit write covers an untracked
1055
+ # ledger that `git reset` cannot restore
1056
+ self._safe_reset(task)
1057
+ # The WRITE anchor derives from the committed blob, never from an
1058
+ # observation of the tree taken after the very reset it would attest
1059
+ # to: a rival writing a tracked ledger inside that window would BE
1060
+ # the observation, and this restore would overwrite it (#735). Probed
1061
+ # BEFORE the lock — it spawns git, and `ledger_lock` may cover file
1062
+ # I/O only, which no reset window can (#286). A ledger git does not
1063
+ # own has no blob to anchor on, and `reset --hard` cannot have
1064
+ # touched it either, so there the anchor is the rejected rewrite this
1065
+ # attempt actually graded — down to `None == None` when the session
1066
+ # deleted the ledger outright. No anchor at all withholds the write.
1067
+ anchor, committed = self._ledger_baseline_text(task)
1068
+ expected = committed if committed is not None else rewrite
1069
+ diverged = False
1070
+ with deferredwork.ledger_lock(ledger):
1071
+ # PURE TEXT ONLY under the hold — `ledger_lock` is not reentrant
1072
+ # and every mutator takes it.
1073
+ current = ledger.read_text(encoding="utf-8") if ledger.is_file() else None
1074
+ if anchor is _LedgerAnchor.NO_RESET_CONTENT and current == text:
1075
+ # ALREADY the text this restore exists to write, so it is
1076
+ # done and there is nothing to escalate. Reachable without
1077
+ # any rival: a session that atomic-SAVES the ledger replaces
1078
+ # a tracked symlink with a regular file, `reset --hard` puts
1079
+ # the link back, and the external target it cannot reach was
1080
+ # never rewritten — so the ledger is correct while `rewrite`,
1081
+ # read off the regular file, is not what is on disk. Demanding
1082
+ # the anchor here would escalate a finished restore and spend
1083
+ # the attempt budget on it.
1084
+ #
1085
+ # Scoped to NO_RESET_CONTENT deliberately. On a BASELINE
1086
+ # anchor the reset republishes the committed text, so
1087
+ # `current == text` is the ORDINARY post-reset state and
1088
+ # accepting it there would retire the divergence check and
1089
+ # the probe-fault escalation along with it. Only where the
1090
+ # reset restored no text of its own is "already correct"
1091
+ # information the anchor cannot supply.
1092
+ pass
1093
+ # Either anchor will do below, unlike the engine's two restores:
1094
+ # this site supplies its own text for the no-reset-content case
1095
+ # (`rewrite`, which it graded), so `expected` is never the bare
1096
+ # `None` that would read a rival's deletion as the reset's work.
1097
+ elif anchor is not _LedgerAnchor.NONE and current == expected:
1098
+ ledger.parent.mkdir(parents=True, exist_ok=True)
1099
+ atomic_write_text(ledger, text)
1100
+ else:
1101
+ diverged = True
1102
+ if diverged:
1103
+ # No merge and no silent skip. The comment above is the reason:
1104
+ # leaving the rejected rewrite standing IS re-prompting over a
1105
+ # half-broken ledger, and the migration input a human must fix is
1106
+ # no longer the one this attempt was graded against — the same
1107
+ # call `migrate-duplicate-ids` makes about a corrupt ledger.
1108
+ # A baseline probe that could not answer lands here too, and
1109
+ # deliberately: without an anchor there is no proof the text on
1110
+ # disk is the reset's own work rather than somebody's live write,
1111
+ # and an unprovable restore is exactly the overwrite this arm
1112
+ # exists to refuse. The escalation is the right recovery for both
1113
+ # — the resume above resets the attempt budget and re-reads the
1114
+ # ledger, which is what a rival-corrupted migration input needs.
1115
+ # Journaled outside the hold; `_escalate` raises.
1116
+ self.journal.append(
1117
+ "sweep-migration-restore-diverged",
1118
+ story_key=MIGRATE_KEY,
1119
+ ledger=str(ledger),
1120
+ )
1121
+ self._escalate(
1122
+ task,
1123
+ "the ledger changed underneath the failed migration attempt — "
1124
+ "re-run the sweep",
1125
+ )
1126
+ if task.attempt >= self.policy.sweep.max_migration_attempts:
1127
+ self._escalate(
1128
+ task, "migration failed deterministic validation: " + "; ".join(errors)
1129
+ )
1130
+ feedback = self._write_feedback(
1131
+ task,
1132
+ "The legacy-ledger migration failed deterministic validation:\n- "
1133
+ + "\n- ".join(errors),
1134
+ )
1135
+
1136
+ def _migrate_prompt(self, manifest: Path, feedback: Path | None) -> str:
1137
+ prompt = f"/froid-loop-sweep --migrate {manifest}"
1138
+ if feedback is not None:
1139
+ prompt += f" --feedback {feedback}"
1140
+ return prompt
1141
+
1142
+ # --------------------------------------------------------------- triage
1143
+
1144
+ def _ensure_triage(self, open_now: set[str], cycle: int = 1) -> TriagePlan:
1145
+ suffix = "" if cycle == 1 else f"-{cycle}"
1146
+ triage_path = self.run_dir / f"triage{suffix}.json"
1147
+ triage_key = TRIAGE_KEY + suffix
1148
+ if triage_path.is_file():
1149
+ # already validated this run; the ledger has moved since (closes,
1150
+ # decisions), so skip the open-set equality re-check. A cache we
1151
+ # cannot read or that is not a JSON object degrades to a fresh
1152
+ # triage — a truncated file must not crash the whole run.
1153
+ try:
1154
+ cached = _read_json(triage_path)
1155
+ except (json.JSONDecodeError, OSError, UnicodeDecodeError) as exc:
1156
+ self.journal.append("sweep-triage-reload-failed", errors=[f"unreadable: {exc}"])
1157
+ else:
1158
+ if isinstance(cached, dict):
1159
+ plan, errors = validate_triage(cached, None)
1160
+ else:
1161
+ plan, errors = None, [f"not a JSON object: {type(cached).__name__}"]
1162
+ if plan is not None:
1163
+ return plan
1164
+ self.journal.append("sweep-triage-reload-failed", errors=errors)
1165
+
1166
+ task = self.state.tasks.get(triage_key)
1167
+ if task is None:
1168
+ task = StoryTask(story_key=triage_key, epic=0)
1169
+ self.state.tasks[triage_key] = task
1170
+ elif task.phase != Phase.PENDING:
1171
+ # resumed mid-triage or retrying after an escalation: restart
1172
+ self.journal.append("resume-restart", story_key=triage_key, phase=str(task.phase))
1173
+ if task.phase == Phase.ESCALATED:
1174
+ task.attempt = 0 # the human resumed deliberately; fresh budget
1175
+ task.phase = Phase.PENDING # deliberate reset, not a normal transition
1176
+
1177
+ feedback: Path | None = None
1178
+ while True:
1179
+ task.attempt += 1
1180
+ advance(task, Phase.TRIAGE_RUNNING)
1181
+ self._save()
1182
+ result = self._run_session(
1183
+ task,
1184
+ role="triage",
1185
+ prompt=self._triage_prompt(feedback),
1186
+ seq=task.attempt,
1187
+ )
1188
+ advance(task, Phase.TRIAGE_VERIFY)
1189
+ self._save()
1190
+ crits = critical_escalations(result.result_json)
1191
+ if crits:
1192
+ details = "; ".join(str(e.get("detail", e.get("type", "?"))) for e in crits)
1193
+ self._escalate(task, f"CRITICAL escalation from triage session: {details}")
1194
+ if result.status != "completed":
1195
+ plan, errors = None, [session_failure_reason("triage", result)]
1196
+ else:
1197
+ repairs = _normalize_bundle_names(result.result_json)
1198
+ plan, errors = validate_triage(result.result_json, open_now)
1199
+ for repair in repairs:
1200
+ self.journal.append(
1201
+ "sweep-bundle-name-normalized",
1202
+ field=repair.field,
1203
+ original=repair.original,
1204
+ normalized=repair.normalized,
1205
+ )
1206
+ self.journal.append(
1207
+ "triage-decision",
1208
+ attempt=task.attempt,
1209
+ session_status=result.status,
1210
+ ok=plan is not None,
1211
+ errors=errors,
1212
+ env_fault=result.env_fault,
1213
+ )
1214
+ if result.status != "completed" and result.env_fault:
1215
+ # transport/API failure (#194): pause rather than charge a triage
1216
+ # attempt for a session that never reached the API. The
1217
+ # ESCALATED-resume above resets task.attempt to 0 (fresh budget).
1218
+ self._escalate(
1219
+ task,
1220
+ env_fault_pause_reason("triage", result),
1221
+ )
1222
+ if plan is not None:
1223
+ advance(task, Phase.DONE)
1224
+ self._save()
1225
+ triage_path.write_text(json.dumps(result.result_json, indent=2), encoding="utf-8")
1226
+ self.journal.append(
1227
+ "sweep-triage-result",
1228
+ bundles=len(plan.bundles),
1229
+ decisions=len(plan.decisions),
1230
+ already_resolved=len(plan.already_resolved),
1231
+ blocked=len(plan.blocked),
1232
+ skip=len(plan.skip),
1233
+ )
1234
+ self._emit("post_triage", task)
1235
+ return plan
1236
+ if task.attempt >= self.policy.sweep.max_triage_attempts:
1237
+ self._escalate(task, "triage output failed validation: " + "; ".join(errors))
1238
+ feedback = self._write_feedback(
1239
+ task,
1240
+ "The triage result.json failed deterministic validation:\n- " + "\n- ".join(errors),
1241
+ )
1242
+
1243
+ def _triage_prompt(self, feedback: Path | None) -> str:
1244
+ prompt = "/froid-loop-sweep"
1245
+ if feedback is not None:
1246
+ prompt += f" --feedback {feedback}"
1247
+ return prompt
1248
+
1249
+ # ------------------------------------------------------ ledger phases
1250
+
1251
+ def _close_resolved(self, plan: TriagePlan) -> int:
1252
+ self._emit("pre_close_resolved")
1253
+ ledger = self.workspace.paths.deferred_work
1254
+ # ONE locked read->edit->write for the whole batch (#286/#469). The
1255
+ # per-entry `mark_done` loop this replaces took the cross-process ledger
1256
+ # lock once per id, leaving a rival writer — a live run's harvest, the TUI
1257
+ # decision modal, `sweep --archive` — a window between every pair of
1258
+ # closures, so half this phase's closures could be lost while the other
1259
+ # half landed and the journal claimed all of them. `notes=` carries the
1260
+ # per-entry evidence the loop passed positionally, so the resulting ledger
1261
+ # text and the returned ids (order preserved, skips dropped) are unchanged.
1262
+ closed = deferredwork.mark_done_many(
1263
+ ledger,
1264
+ [entry.id for entry in plan.already_resolved],
1265
+ self._today(),
1266
+ "already resolved",
1267
+ notes=[f"already resolved: {entry.evidence}" for entry in plan.already_resolved],
1268
+ )
1269
+ if closed:
1270
+ self.journal.append("sweep-resolved-closed", dw_ids=closed)
1271
+ self._commit_ledger("chore(sweep): close resolved deferred-work entries")
1272
+ self._emit("post_close_resolved")
1273
+ return len(closed)
1274
+
1275
+ def _decisions_phase(self, plan: TriagePlan) -> tuple[dict[str, dict[str, str]], int]:
1276
+ from . import decisions as decisions_store # lazy: decisions imports sweep
1277
+
1278
+ decisions_path = self.run_dir / "decisions.json"
1279
+ # The project that OWNS `run_dir`, not `self.workspace.root`: under the
1280
+ # supported `repo_root` override (isolation = "none") the workspace root
1281
+ # is the separate code repo while the run dir — and the project-level
1282
+ # pre-answer store — stay under the PROJECT, so a workspace-rooted
1283
+ # confinement refused every write here and a workspace-rooted read
1284
+ # silently ignored the store. Derived from the run dir's own shape, which
1285
+ # no workspace swap moves.
1286
+ project_root = _project_of_run_dir(self.run_dir)
1287
+ answers: dict[str, dict[str, str]] = (
1288
+ _read_json(decisions_path) if decisions_path.is_file() else {}
1289
+ )
1290
+ closed = 0
1291
+ # Adopt out-of-band pre-answers (a human answered decisions an earlier
1292
+ # unattended/abandoned sweep left). The ledger edits were already applied
1293
+ # when they answered, so here we only take the answer onboard — this run
1294
+ # won't re-prompt/re-skip and build answers materialize into bundles.
1295
+ pre = decisions_store.load_pre_answers(project_root)
1296
+ seeded = False
1297
+ for decision in plan.decisions:
1298
+ if decision.id in answers or decision.id not in pre:
1299
+ continue
1300
+ answers[decision.id] = pre[decision.id]
1301
+ self.journal.append(
1302
+ "decision-preanswered",
1303
+ dw_id=decision.id,
1304
+ effect=pre[decision.id].get("effect"),
1305
+ )
1306
+ seeded = True
1307
+ if seeded:
1308
+ # Same helper as `decisions._write_store` (#363), but NOT for #363's
1309
+ # reason: `decisions_path` here is the PER-RUN file under
1310
+ # `.froid-loop/runs/<id>/`, which init gitignores, so a stranded temp
1311
+ # was never untracked and never held `worktree_clean` False. The
1312
+ # project-level `.froid-loop/decisions.json` is a different file with a
1313
+ # near-identical temp name — that is the exposed one. Taken anyway for
1314
+ # the fsync and the unique temp name, which two writers of one key
1315
+ # would otherwise collide on. Confined to the project root (#593):
1316
+ # replacing the NAME, as the bare replace did, left `.froid-loop/` and
1317
+ # `runs/` above it resolved by name, and a link planted at either
1318
+ # aimed this write out of the project. The root has to be the PROJECT
1319
+ # (`project_root` above; its comment says why not
1320
+ # `self.workspace.root`) — and not `self.run_dir` either: a file
1321
+ # confined against its own parent walks no components at all, which
1322
+ # would refuse nothing.
1323
+ atomic_write_text_confined(
1324
+ decisions_path,
1325
+ json.dumps(answers, indent=2),
1326
+ confine_root=project_root,
1327
+ )
1328
+ pending = [d for d in plan.decisions if d.id not in answers]
1329
+ answered_interactively = False
1330
+ if not self.prompting:
1331
+ pending = [d for d in pending if d.id not in self._skipped_decisions]
1332
+ for decision in pending:
1333
+ self.journal.append("decision-skipped-unattended", dw_id=decision.id)
1334
+ self._skipped_decisions.add(decision.id)
1335
+ if pending:
1336
+ gates.notify(
1337
+ self.policy,
1338
+ self.run_dir,
1339
+ f"{len(pending)} deferred-work decisions pending",
1340
+ "run `froid-loop sweep` interactively to answer them",
1341
+ )
1342
+ else:
1343
+ for decision in pending:
1344
+ # announce before blocking on input so observers (TUI, ATTENTION
1345
+ # watchers) can tell a sweep is waiting on a human
1346
+ self.journal.append(
1347
+ "decision-pending", dw_id=decision.id, question=decision.question
1348
+ )
1349
+ gates.notify(
1350
+ self.policy,
1351
+ self.run_dir,
1352
+ f"decision needed: {decision.id}",
1353
+ decision.question,
1354
+ )
1355
+ self._emit("pre_decision", story_key=decision.id)
1356
+ option = self.prompter.ask(decision)
1357
+ answers[decision.id] = {
1358
+ "key": option.key,
1359
+ "label": option.label,
1360
+ "effect": option.effect,
1361
+ "answered_at": self._today(),
1362
+ }
1363
+ atomic_write_text_confined( # same file, same reasoning as above (#363, #593)
1364
+ decisions_path,
1365
+ json.dumps(answers, indent=2),
1366
+ confine_root=project_root,
1367
+ )
1368
+ self.journal.append(
1369
+ "decision-answered",
1370
+ dw_id=decision.id,
1371
+ key=option.key,
1372
+ effect=option.effect,
1373
+ )
1374
+ self._apply_decision_effect(decision, option)
1375
+ self._emit("post_decision", story_key=decision.id, decision_action=option.effect)
1376
+ answered_interactively = True
1377
+ if option.effect == "close":
1378
+ closed += 1
1379
+ self._commit_ledger("chore(sweep): record deferred-work decisions")
1380
+ if answered_interactively:
1381
+ self._return_after_decisions()
1382
+ return answers, closed
1383
+
1384
+ def _return_after_decisions(self) -> None:
1385
+ """Once the human has answered this cycle's decisions over an attached
1386
+ terminal, hand it back so the sweep runs its bundles in the background —
1387
+ detach a plain-shell client, switch a tmux client back to its origin. A
1388
+ plain foreground sweep (nobody attached, no return target) is untouched.
1389
+
1390
+ We then go unattended for the rest of the run: a later --repeat cycle's
1391
+ input() would otherwise block forever in a window no one is viewing. New
1392
+ decisions defer via the unattended path instead, recorded for
1393
+ `froid-loop decisions` or the next attended sweep.
1394
+
1395
+ The trigger for that is "nobody can be relied on to answer here any
1396
+ more", NOT "the hand-back succeeded" — the two come apart on a failed
1397
+ return, in opposite directions. A *refused* switch is evidence the
1398
+ client is still in this window with a human in front of it (ATTENDED:
1399
+ keep prompting, which is the whole point of #227). Everything else
1400
+ reports only that no hand-back was verified — a detach that found
1401
+ nothing attached, an effect the backend cannot observe, no detach verb
1402
+ at all, or a switch the backend cannot vouch for (a timed-out verb, an
1403
+ unreadable client count, nothing attached to move) — and under that
1404
+ uncertainty going unattended is the outcome that does not strand a
1405
+ --repeat cycle on input(); the decisions it defers stay reachable via
1406
+ `froid-loop decisions`. The `sweep-return-no-client` record keeps its
1407
+ name across that widening: it has always meant "no hand-back verified",
1408
+ which is what an unvouched switch reports too. Only a real return is
1409
+ announced: UNREACHABLE prints nothing, since there may be no one to
1410
+ read it."""
1411
+ from .tui import launch # import-light: launch.py has no textual imports
1412
+
1413
+ outcome = launch.return_attached_client()
1414
+ if outcome is launch.ReturnOutcome.ATTENDED:
1415
+ return
1416
+ self.prompting = False
1417
+ if outcome is launch.ReturnOutcome.RETURNED:
1418
+ self.journal.append("sweep-returned-after-decisions")
1419
+ self.prompter.print_fn("✓ decisions recorded — sweep continues in the background")
1420
+ else:
1421
+ self.journal.append("sweep-return-no-client")
1422
+
1423
+ def _apply_decision_effect(self, decision: Decision, option: DecisionOption) -> None:
1424
+ ledger = self.workspace.paths.deferred_work
1425
+ detail = option.resolution or option.intent
1426
+ close_note = None
1427
+ if option.effect == "close":
1428
+ close_note = "closed by human decision" + (
1429
+ f": {option.resolution}" if option.resolution else ""
1430
+ )
1431
+ # ONE locked read->edit->write (#286/#469). As the `append_decision` +
1432
+ # `mark_done` pair it was two acquisitions with a window between them, and
1433
+ # a rival writer landing there saw an entry whose decision line says "close
1434
+ # it" and whose status still says open — a human answer half-recorded. The
1435
+ # bytes are identical to the pair's: `record_decision` inserts the decision
1436
+ # line before it applies the close, which is the order the pair produced.
1437
+ deferredwork.record_decision(
1438
+ ledger, decision.id, self._today(), option.label, detail, close_note=close_note
1439
+ )
1440
+
1441
+ def _commit_ledger(self, message: str) -> None:
1442
+ """Commit pending orchestrator ledger edits; bundles need a clean
1443
+ baseline. No-op when the tree is already clean."""
1444
+ if verify.worktree_clean(self.workspace.root):
1445
+ return
1446
+ sha = verify.commit_story(self.workspace.root, message)
1447
+ self.journal.append("sweep-ledger-commit", message=message, commit=sha)
1448
+
1449
+ # ---------------------------------------------------------- bundles
1450
+
1451
+ def _materialize_bundles(
1452
+ self, plan: TriagePlan, answers: dict[str, dict[str, str]]
1453
+ ) -> list[Bundle]:
1454
+ self._emit("pre_materialize_bundles")
1455
+ bundles = list(plan.bundles)
1456
+ for decision in plan.decisions:
1457
+ answer = answers.get(decision.id)
1458
+ if not answer or answer.get("effect") != "build":
1459
+ continue
1460
+ # An in-run answer maps cleanly to a current option; a pre-answer
1461
+ # (answered out of band against an earlier triage) may not — a fresh
1462
+ # triage can renumber options — so fall back to the stored option
1463
+ # semantics carried in the answer itself.
1464
+ option = decision.option(str(answer.get("key")))
1465
+ intent = (option.intent if option else "") or str(answer.get("intent", ""))
1466
+ if not intent:
1467
+ continue
1468
+ label = (option.label if option else "") or str(answer.get("label", "")) or "build"
1469
+ bundle_name = (option.bundle_name if option else "") or str(
1470
+ answer.get("bundle_name", "")
1471
+ )
1472
+ # A pre-answer's bundle_name never passed `validate_triage` — it was
1473
+ # answered out of band against an earlier triage, and a fresh one can
1474
+ # renumber or drop the option it named — so this fallback lane was the
1475
+ # one route by which a name failing the two option-site gates (#637)
1476
+ # still reached `_write_intent` as a directory. Gate it with the same
1477
+ # two rules, but by DISCARD rather than by error: the human's build
1478
+ # decision is the payload and `decision-<id>` below is the always-legal
1479
+ # name it falls back to anyway, so the discard is journaled the way
1480
+ # `_normalize_bundle_names`'s repairs are and the sweep proceeds.
1481
+ if bundle_name and (
1482
+ not BUNDLE_NAME_RE.match(bundle_name) or safe_segment(bundle_name) != bundle_name
1483
+ ):
1484
+ self.journal.append(
1485
+ "sweep-bundle-name-discarded",
1486
+ decision=decision.id,
1487
+ original=bundle_name,
1488
+ )
1489
+ bundle_name = ""
1490
+ key = (option.key if option else "") or str(answer.get("key", "")) or "?"
1491
+ name = bundle_name or "decision-" + decision.id.lower()
1492
+ bundles.append(
1493
+ Bundle(
1494
+ name=name,
1495
+ dw_ids=(decision.id,),
1496
+ intent=intent,
1497
+ decision_note=(
1498
+ f"The human chose option {key} ({label}) for the "
1499
+ f"question: {decision.question}"
1500
+ ),
1501
+ )
1502
+ )
1503
+ # ids a prior bundle already failed on: re-triaging them would rebuild
1504
+ # the same hopeless bundle every repeat cycle (and a cached build-effect
1505
+ # decision answer would re-materialize its bundle each cycle)
1506
+ failed_ids = {
1507
+ i
1508
+ for t in self.state.tasks.values()
1509
+ if t.story_key.startswith("dw") and t.phase in (Phase.DEFERRED, Phase.ESCALATED)
1510
+ for i in t.dw_ids
1511
+ }
1512
+ # ids a human explicitly chose to keep open: a later triage must not
1513
+ # override that answer (bundle dev sessions mark their dw_ids done)
1514
+ keep_open_ids = {dw_id for dw_id, a in answers.items() if a.get("effect") == "keep-open"}
1515
+ kept = []
1516
+ for b in bundles:
1517
+ overlap = sorted(set(b.dw_ids) & (failed_ids | keep_open_ids))
1518
+ if overlap:
1519
+ self.journal.append(
1520
+ "sweep-bundle-skipped",
1521
+ name=b.name,
1522
+ dw_ids=overlap,
1523
+ reason=(
1524
+ "failed-or-escalated-earlier"
1525
+ if set(b.dw_ids) & failed_ids
1526
+ else "human-chose-keep-open"
1527
+ ),
1528
+ )
1529
+ continue
1530
+ kept.append(b)
1531
+ bundles = kept
1532
+ if len(bundles) > self.max_bundles:
1533
+ dropped = [b.name for b in bundles[self.max_bundles :]]
1534
+ self.journal.append("sweep-bundles-truncated", dropped=dropped)
1535
+ bundles = bundles[: self.max_bundles]
1536
+ self._emit("post_materialize_bundles")
1537
+ return bundles
1538
+
1539
+ def _write_intent(self, bundle: Bundle, dirname: str) -> Path:
1540
+ ledger = self.workspace.paths.deferred_work
1541
+ text = ledger.read_text(encoding="utf-8") if ledger.is_file() else ""
1542
+ entries = {e.id: e for e in deferredwork.parse_ledger(text)}
1543
+ blocks = [entries[i].body.rstrip() for i in bundle.dw_ids if i in entries]
1544
+ lines = [
1545
+ f"# Deferred-work bundle: {bundle.name}",
1546
+ "",
1547
+ f"bundle_name: {bundle.name}",
1548
+ f"dw_ids: {', '.join(bundle.dw_ids)}",
1549
+ "",
1550
+ "## Intent",
1551
+ "",
1552
+ bundle.intent,
1553
+ ]
1554
+ if bundle.decision_note:
1555
+ lines += ["", "## Human decision", "", bundle.decision_note]
1556
+ lines += ["", "## Ledger entries (verbatim)", "", "\n\n".join(blocks), ""]
1557
+ path = self.run_dir / "bundles" / dirname / "intent.md"
1558
+ path.parent.mkdir(parents=True, exist_ok=True)
1559
+ # Surrogates are neutralized over the whole document, not per field: the
1560
+ # triage-authored `intent`/`decision_note` are the ones that can revive one
1561
+ # (#329), but a document-wide pass covers whatever prose is added here
1562
+ # later. Line breaks are deliberately *kept* — this file is markdown, so
1563
+ # `_one_line`'s collapse would be damage, and the ledger blocks are read
1564
+ # back from a strict-UTF-8 file and so pass through byte-unchanged.
1565
+ atomic_write_text(path, neutralize_surrogates("\n".join(lines)))
1566
+ return path
1567
+
1568
+ def _ensure_bundle_intent(self, task: StoryTask) -> None:
1569
+ """Guarantee a recovered bundle has the intent file its dev prompt points
1570
+ at. The rendered intent.md persists in the run dir and the prompt consumes
1571
+ nothing else from the plan, so the normal case is to reuse it untouched.
1572
+
1573
+ Only when it is gone do we rebuild a degraded one from the task itself.
1574
+ The triage session's authored intent prose is the single unrecoverable
1575
+ piece; the verbatim ledger entries _write_intent re-attaches carry the
1576
+ actual work, so say plainly that they are now the contract."""
1577
+ if task.bundle_file and Path(task.bundle_file).is_file():
1578
+ return
1579
+ match = BUNDLE_KEY_RE.match(task.story_key)
1580
+ if match is None: # pragma: no cover - callers filter on BUNDLE_KEY_RE
1581
+ return
1582
+ cycle = int(match.group(1)) if match.group(1) else 1
1583
+ name = match.group(2)
1584
+ bundle = Bundle(
1585
+ name=name,
1586
+ dw_ids=tuple(task.dw_ids),
1587
+ intent=(
1588
+ "Resolve the deferred-work entries reproduced below. This bundle's "
1589
+ "original triage intent did not survive the run it was written in, "
1590
+ "so the verbatim ledger entries are the authoritative statement of "
1591
+ "the work."
1592
+ ),
1593
+ )
1594
+ dirname = name if cycle == 1 else f"c{cycle}-{name}"
1595
+ task.bundle_file = str(self._write_intent(bundle, dirname))
1596
+ self.journal.append(
1597
+ "sweep-intent-regenerated",
1598
+ story_key=task.story_key,
1599
+ dw_ids=list(task.dw_ids),
1600
+ path=task.bundle_file,
1601
+ )
1602
+
1603
+ # ------------------------------------------------------ override seams
1604
+
1605
+ def _dispatched_spec_for_attempt(self, task: StoryTask) -> str | None:
1606
+ """Sweep dispatch owns intent.md, never an accepted bundle spec."""
1607
+ return None
1608
+
1609
+ def _requires_dispatched_spec_snapshot(self, task: StoryTask, prompt: str) -> bool:
1610
+ """Keep explicit bundle-spec routing separate from recovery ownership.
1611
+
1612
+ Repair and patch-restore prompts name the accepted spec so deterministic
1613
+ read-back follows it, but Sweep still owns ``intent.md`` as its dispatched
1614
+ input and must never promote that result artifact into attempt ownership.
1615
+ """
1616
+ return False
1617
+
1618
+ def _retains_dispatched_spec_snapshot_on_repair(self) -> bool:
1619
+ """Sweep repairs remain owned by intent.md, not the accepted spec."""
1620
+ return False
1621
+
1622
+ def _dev_prompt(self, task: StoryTask, feedback: Path | None) -> str:
1623
+ return self._generic_bundle_prompt(task, feedback)
1624
+
1625
+ def _generic_bundle_prompt(self, task: StoryTask, feedback: Path | None) -> str:
1626
+ """Bundle invocation for the generic dev primitive (disk-resolved, see
1627
+ ``Engine._dev_skill``): the self-contained
1628
+ intent.md (intent + verbatim ledger entries) is handed over as freeform
1629
+ intent. The orchestrator owns the deferred-work ledger — the skill is told
1630
+ not to edit it — and records resolution itself in `_post_dev_accepted_sync`.
1631
+ On a repair the bundle spec is re-opened first (B6) so step-01 resumes.
1632
+
1633
+ A patch-restore re-drive (#2564, #75) must point at the bundle spec
1634
+ explicitly: only step-01's spec-pointer intent check EARLY EXITs on the
1635
+ `in-review` status the re-arm set — before step-01's version-control
1636
+ sanity check, which would otherwise HALT `blocked` on the diff
1637
+ `_restore_patch` just laid onto the tree. The freeform intent.md pointer
1638
+ takes the path where that dirty-tree check runs first."""
1639
+ bundle_ref = task.bundle_file or task.story_key
1640
+ if feedback is None:
1641
+ if task.restore_patch and task.spec_file:
1642
+ return (
1643
+ f"/{self._dev_skill()} Resume review of the in-review spec at "
1644
+ f"`{task.spec_file}` for the deferred-work bundle `{bundle_ref}`. "
1645
+ f"The attempted change was restored onto the working tree after "
1646
+ f"an intent-gap resolution; review it against the amended spec. "
1647
+ f"Do NOT edit the deferred-work ledger; the orchestrator records "
1648
+ f"resolution."
1649
+ )
1650
+ return (
1651
+ f"/{self._dev_skill()} Implement the deferred-work bundle described in "
1652
+ f"`{bundle_ref}` — it carries the intent and the verbatim ledger "
1653
+ f"entries to resolve. Do NOT edit the deferred-work ledger; the "
1654
+ f"orchestrator records resolution."
1655
+ )
1656
+ self._reset_spec_for_repair(task)
1657
+ spec_ref = task.spec_file or bundle_ref
1658
+ return (
1659
+ f"/{self._dev_skill()} Resume the autonomous dev session on the in-progress "
1660
+ f"spec at `{spec_ref}` for the deferred-work bundle `{bundle_ref}`. The "
1661
+ f"previous session's work failed deterministic verification; repair the "
1662
+ f"working tree so verification passes without changing the frozen intent "
1663
+ f"contract or editing the deferred-work ledger. Verification evidence is "
1664
+ f"in `{feedback}`."
1665
+ )
1666
+
1667
+ def _post_dev_state_sync(self, task: StoryTask, result_json: dict | None) -> None:
1668
+ """No-op: bundles have no sprint-status row for the pre-gate sync.
1669
+
1670
+ This override and the accepted-only override below are one behavior
1671
+ change. Leaving the former close here as well would run bundle closure
1672
+ twice at two different gate positions.
1673
+ """
1674
+ return
1675
+
1676
+ def _post_dev_accepted_sync(self, task: StoryTask, result_json: dict | None) -> None:
1677
+ """Generic-path ledger single-writer for bundles. The decoupled
1678
+ froid-build-auto skill does not touch the ledger, so the orchestrator marks
1679
+ each dw id the bundle owns ``done`` once the bundle's spec reaches the
1680
+ terminal status for the current stage. No-op on the legacy path.
1681
+
1682
+ This runs only after the artifact gate, verify commands, and ``decide_dev``
1683
+ have accepted the attempt. In particular, ``outcome.ok`` is insufficient:
1684
+ a CRITICAL escalation in the session result preempts that outcome. The
1685
+ review gate later requires these entries closed; ``_verify_review`` retains
1686
+ its separate reclose because a review session can rewrite the ledger.
1687
+ """
1688
+ if not self._generic_dev():
1689
+ return
1690
+ spec_file = (result_json or {}).get("spec_file")
1691
+ if not spec_file:
1692
+ return
1693
+ success_status = "in-review" if self._dev_review_enabled() else "done"
1694
+ self._close_bundle_ledger_when_spec_status(task, str(spec_file), success_status)
1695
+
1696
+ def _bundle_close_operation_id(self, task: StoryTask) -> str:
1697
+ """Stable identity for a close and its possible defer-time undo."""
1698
+ return f"{self.state.run_id}/{task.story_key}"
1699
+
1700
+ def _bundle_close_note(self, task: StoryTask) -> str:
1701
+ """Resolution note shared by a bundle close and its possible undo."""
1702
+ return f"resolved by sweep bundle {task.story_key}"
1703
+
1704
+ def _close_declared_deferred(
1705
+ self, task: StoryTask, snapshot: list[_ArmedClose] | None = None
1706
+ ) -> None:
1707
+ """No-op: a bundle's ledger closure is owned by
1708
+ ``_close_bundle_ledger_when_spec_status``, which runs after accepted dev
1709
+ because ``verify_review_bundle`` *requires* those entries closed before the
1710
+ later commit boundary. Letting the base class's commit-boundary hook (#234)
1711
+ also fire here would re-derive closure for a task whose ids come from
1712
+ ``task.dw_ids``, not from a ``closes_deferred:`` declaration."""
1713
+
1714
+ def _close_bundle_ledger_when_spec_status(
1715
+ self,
1716
+ task: StoryTask,
1717
+ spec_file: str,
1718
+ success_status: str,
1719
+ kind: str = "sweep-bundle-closed",
1720
+ ) -> None:
1721
+ spec_path = verify.resolve_spec_path(spec_file, self.workspace.paths)
1722
+ if not spec_path.is_file():
1723
+ return
1724
+ fm = self._observed_frontmatter(spec_path, task.story_key, "bundle-ledger-close")
1725
+ if fm is None:
1726
+ return
1727
+ if verify.status_of(fm) != success_status:
1728
+ return
1729
+ ledger = self.workspace.paths.deferred_work
1730
+ note = self._bundle_close_note(task)
1731
+ # Record the intended ids, never only `marked`. This method is called once
1732
+ # after accepted dev and again by the review-leg reclose. The second call
1733
+ # normally finds every entry already done, so `marked` is empty; deriving
1734
+ # the record from it would erase exactly the state a landing bundle needs.
1735
+ task.bundle_closes_intended = list(task.dw_ids)
1736
+ marked = deferredwork.mark_done_many_reopenable(
1737
+ ledger,
1738
+ task.dw_ids,
1739
+ self._today(),
1740
+ note,
1741
+ self._bundle_close_operation_id(task),
1742
+ )
1743
+ if marked:
1744
+ self.journal.append(kind, story_key=task.story_key, dw_ids=marked)
1745
+
1746
+ def _reopen_ledger_after_defer(self, task: StoryTask) -> None:
1747
+ """Reopen only this run's bundle closes after its code was discarded.
1748
+
1749
+ ``Engine._defer`` deliberately restores the whole post-review ledger after
1750
+ rollback so harvested findings survive. That restore can also replay a
1751
+ bundle close whose code no longer exists. The operation-specific undo marker
1752
+ makes this "undo my close", not "open these ids": human, legacy, and earlier
1753
+ run closures remain untouched. Replaying this method is idempotent.
1754
+ """
1755
+ ledger = self.workspace.paths.deferred_work
1756
+ note = self._bundle_close_note(task)
1757
+ operation_id = self._bundle_close_operation_id(task)
1758
+ # ONE locked read->edit->write (#286/#469): the per-id `mark_open`
1759
+ # comprehension this replaces took the lock once per id, and a rollback
1760
+ # that leaves some closes undone and others standing is the one shape this
1761
+ # method exists to prevent. Order and skip semantics are `mark_open_many`'s
1762
+ # own, which are the comprehension's.
1763
+ reopened = deferredwork.mark_open_many(ledger, list(task.dw_ids), note, operation_id)
1764
+ if reopened:
1765
+ self.journal.append("sweep-bundle-reopened", story_key=task.story_key, dw_ids=reopened)
1766
+
1767
+ def _carry_isolated_ledger_writes(self, task: StoryTask) -> None:
1768
+ """The base hook's sweep half: re-apply this bundle's ledger CLOSES to the
1769
+ MAIN checkout after an isolated unit lands, the base having first carried
1770
+ the harvest.
1771
+
1772
+ ``super()`` runs FIRST, and that is a contract rather than a style choice.
1773
+ ``deferredwork.append_entry``'s idempotence scan is OPEN-ONLY, so a close
1774
+ applied ahead of the harvest hides an already-filed row from it and mints a
1775
+ duplicate under a fresh id. The base hook now ends with a CLOSE of its own
1776
+ (``_carry_story_deferred_closes``, #458) and still satisfies the rule, since
1777
+ both of its appends precede it; the two closes never coexist on one task,
1778
+ because ``_close_declared_deferred`` is a no-op here and a story run has no
1779
+ bundle. ``_carry_harvested_deferrals`` defends the same
1780
+ hazard a second time with its own status-agnostic pre-scan, which is exactly
1781
+ why the order is pinned by a test: with that second line of defence in place
1782
+ a reversal is silent today and would only surface if the pre-scan were ever
1783
+ narrowed back to the writer's semantics.
1784
+
1785
+ ``_close_bundle_ledger_when_spec_status`` writes
1786
+ ``self.workspace.paths.deferred_work`` — under ``scm.isolation = "worktree"``
1787
+ that is the unit worktree's copy. The shape this rescues is a GITIGNORED
1788
+ ledger named in ``scm.worktree_seed``: the flip lands in the worktree, then
1789
+ ``finalize_commit``'s ``git add -A`` skips the ignored path in silence, so it
1790
+ never rides the unit branch and the merge brings nothing over. The bundle's
1791
+ entries stay ``open``, ``deferredwork.open_ids`` re-bundles them, and every
1792
+ later sweep re-triages work that is already done — an unbounded loop rather
1793
+ than a one-time drop, which is what makes this worth a carry.
1794
+
1795
+ An UNSEEDED gitignored ledger is a different, still-open hole this cannot
1796
+ reach: a worktree checks out tracked files only, so the ledger is absent
1797
+ there entirely, ``verify_review_bundle`` (which reads the WORKTREE's copy)
1798
+ never sees the ids ``done``, and the unit DEFERS on a fixable retry instead
1799
+ of landing. That shape is loud where this one is silent, and no DONE-leg
1800
+ carry helps a unit that never reaches DONE (#426).
1801
+
1802
+ DONE leg only, deliberately: ``Engine._defer``'s isolated arm calls
1803
+ ``_carry_harvested_deferrals`` directly and never this hook, and
1804
+ ``_replay_unlatched_ledger_carries``'s DEFERRED leg matches it for the same
1805
+ reason. A defer discarded the code the close claims to have RESOLVED, and a
1806
+ close is the most expensive engine-side write to leave behind — ``open_ids``
1807
+ re-bundles only ``open`` entries, so a wrong ``done`` is invisible to every
1808
+ future sweep.
1809
+
1810
+ Keyed on ``task.bundle_closes_intended``, never on the ids the in-worktree
1811
+ close managed to flip: those are exactly empty in the broken case above.
1812
+ The close is written reopenable, with the same note and operation id as the
1813
+ in-worktree close, so a carried row is indistinguishable from one that
1814
+ arrived through the merge instead of a second row shape for one event.
1815
+
1816
+ No ``_generic_dev()`` guard, unlike the two ledger WRITERS: the record is
1817
+ the guard. ``bundle_closes_intended`` is assigned only by
1818
+ ``_close_bundle_ledger_when_spec_status``, which ``_post_dev_accepted_sync``
1819
+ reaches on the generic path alone, so on the legacy path — where the session
1820
+ owns the ledger — it is empty and this returns before touching anything. A
1821
+ second predicate saying the same thing would be a branch no test can redden.
1822
+
1823
+ The commit is best effort, where ``_carry_harvested_deferrals`` re-raises on
1824
+ a ledger git can own. The asymmetry is deliberate: that method's raise is
1825
+ backed by ``harvest_carry_commit_pending``, so a replay still owes the commit
1826
+ after dedup empties its carried list. This carry has no such latch and its
1827
+ flips are idempotent, so a replay finds nothing left to commit — raising here
1828
+ would cost the run its ``integrate_unit`` over bookkeeping whose real work is
1829
+ already done. The flips themselves are unguarded: losing them is the hazard
1830
+ this exists to prevent.
1831
+ """
1832
+ super()._carry_isolated_ledger_writes(task)
1833
+ if not task.bundle_closes_intended:
1834
+ return
1835
+ ledger = self.paths.deferred_work
1836
+ carried = deferredwork.mark_done_many_reopenable(
1837
+ ledger,
1838
+ task.bundle_closes_intended,
1839
+ self._today(),
1840
+ self._bundle_close_note(task),
1841
+ self._bundle_close_operation_id(task),
1842
+ )
1843
+ if carried:
1844
+ try:
1845
+ verify.commit_paths(
1846
+ self.paths.repo_root,
1847
+ f"chore(deferred-work): close {task.story_key}'s bundle ids",
1848
+ [ledger],
1849
+ )
1850
+ except verify.GitError as e:
1851
+ self.journal.append(
1852
+ "sweep-bundle-close-carry-uncommitted",
1853
+ story_key=task.story_key,
1854
+ dw_ids=carried,
1855
+ error=str(e),
1856
+ )
1857
+ self.journal.append("sweep-bundle-close-carried", story_key=task.story_key, dw_ids=carried)
1858
+
1859
+ def _verify_dev_artifacts(self, task: StoryTask, result_json: dict | None):
1860
+ return verify.verify_dev_bundle(
1861
+ task,
1862
+ self.workspace.paths,
1863
+ result_json,
1864
+ review_enabled=self._dev_review_enabled(),
1865
+ engine_written=self._harvest_gate_exclude(task),
1866
+ )
1867
+
1868
+ def _verify_review(self, task: StoryTask):
1869
+ # Generic bundle dev sessions are told not to edit deferred-work.md; the
1870
+ # orchestrator is the ledger writer. A follow-up review can rewrite the
1871
+ # ledger from its own snapshot and re-open entries that were already
1872
+ # closed after dev. Re-apply that idempotent closure immediately before
1873
+ # verify_review_bundle requires those entries. The distinct journal kind
1874
+ # makes "a review rewrote the ledger" greppable when diagnosing runs.
1875
+ if self._generic_dev() and task.spec_file:
1876
+ self._close_bundle_ledger_when_spec_status(
1877
+ task, task.spec_file, "done", kind="sweep-bundle-reclosed"
1878
+ )
1879
+ return verify.verify_review_bundle(task, self.workspace.paths, self.policy)
1880
+
1881
+ def _operator_park_enabled(self) -> bool:
1882
+ # A bundle carries no sprint-status entry, so the pair a park is verified
1883
+ # against does not exist, and `verify_review_bundle` gates on closed dw
1884
+ # ids instead. Whether a deferred-work bundle can owe a human action is a
1885
+ # separate question from whether a story can; not answered here.
1886
+ return False
1887
+
1888
+ def _commit_message(self, task: StoryTask) -> str:
1889
+ rendered = self._render_commit_template(task)
1890
+ if rendered is not None:
1891
+ return rendered
1892
+ return f"sweep {task.story_key}: {', '.join(task.dw_ids)} via froid-loop"