froid-loop 0.11.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. froid_loop/__init__.py +11 -0
  2. froid_loop/__main__.py +12 -0
  3. froid_loop/adapters/__init__.py +3 -0
  4. froid_loop/adapters/base.py +254 -0
  5. froid_loop/adapters/entrypoints.py +63 -0
  6. froid_loop/adapters/env_fault.py +290 -0
  7. froid_loop/adapters/generic.py +2013 -0
  8. froid_loop/adapters/mock.py +49 -0
  9. froid_loop/adapters/multiplexer.py +914 -0
  10. froid_loop/adapters/opencode_http.py +1687 -0
  11. froid_loop/adapters/profile.py +650 -0
  12. froid_loop/adapters/psmux_backend.py +1428 -0
  13. froid_loop/adapters/registry.py +322 -0
  14. froid_loop/adapters/tmux_backend.py +35 -0
  15. froid_loop/adapters/tmux_base.py +630 -0
  16. froid_loop/checks.py +187 -0
  17. froid_loop/cli.py +5041 -0
  18. froid_loop/data/__init__.py +0 -0
  19. froid_loop/data/froid_loop_hook.py +228 -0
  20. froid_loop/data/froid_loop_probe_hook.py +88 -0
  21. froid_loop/data/plugins/example/plugin.toml +21 -0
  22. froid_loop/data/plugins/tea/plugin.toml +184 -0
  23. froid_loop/data/plugins/tea/tea_plugin.py +258 -0
  24. froid_loop/data/plugins/unity/plugin.toml +140 -0
  25. froid_loop/data/plugins/unity/unity_assets/FroidLoop.Unity.Editor.asmdef +16 -0
  26. froid_loop/data/plugins/unity/unity_assets/FroidLoop.Unity.Editor.asmdef.meta +7 -0
  27. froid_loop/data/plugins/unity/unity_assets/SceneAutoSaveGuard.cs +221 -0
  28. froid_loop/data/plugins/unity/unity_assets/SceneAutoSaveGuard.cs.meta +11 -0
  29. froid_loop/data/plugins/unity/unity_assets/_folders/Editor.meta +8 -0
  30. froid_loop/data/plugins/unity/unity_assets/_folders/FroidLoop.meta +8 -0
  31. froid_loop/data/plugins/unity/unity_cleanup.py +125 -0
  32. froid_loop/data/plugins/unity/unity_dialog_probe.py +239 -0
  33. froid_loop/data/plugins/unity/unity_facts.md +17 -0
  34. froid_loop/data/plugins/unity/unity_plugin.py +415 -0
  35. froid_loop/data/plugins/unity/unity_quiesce.py +234 -0
  36. froid_loop/data/plugins/unity/unity_ready.py +230 -0
  37. froid_loop/data/plugins/unity/unity_seed_assets.py +298 -0
  38. froid_loop/data/plugins/unity/unity_setup.py +551 -0
  39. froid_loop/data/plugins/unity/unity_teardown.py +362 -0
  40. froid_loop/data/profiles/antigravity.toml +52 -0
  41. froid_loop/data/profiles/claude.toml +85 -0
  42. froid_loop/data/profiles/codex.toml +22 -0
  43. froid_loop/data/profiles/copilot.toml +52 -0
  44. froid_loop/data/profiles/gemini.toml +26 -0
  45. froid_loop/data/profiles/opencode.toml +54 -0
  46. froid_loop/data/settings/core.toml +458 -0
  47. froid_loop/data/skills/README.md +93 -0
  48. froid_loop/data/skills/froid-loop-resolve/SKILL.md +288 -0
  49. froid_loop/data/skills/froid-loop-setup/SKILL.md +161 -0
  50. froid_loop/data/skills/froid-loop-setup/assets/module-help.csv +3 -0
  51. froid_loop/data/skills/froid-loop-setup/assets/module.yaml +19 -0
  52. froid_loop/data/skills/froid-loop-sweep/SKILL.md +100 -0
  53. froid_loop/data/skills/froid-loop-sweep/automation-mode.md +127 -0
  54. froid_loop/data/skills/froid-loop-sweep/deferred-work-format.md +302 -0
  55. froid_loop/data/skills/froid-loop-sweep/migration-mode.md +86 -0
  56. froid_loop/decisions.py +202 -0
  57. froid_loop/deferredwork.py +2282 -0
  58. froid_loop/devcontract.py +892 -0
  59. froid_loop/diagnostics.py +1104 -0
  60. froid_loop/documents.py +532 -0
  61. froid_loop/engine.py +7732 -0
  62. froid_loop/envvars.py +111 -0
  63. froid_loop/escalation.py +225 -0
  64. froid_loop/events.py +266 -0
  65. froid_loop/fences.py +103 -0
  66. froid_loop/froidconfig.py +226 -0
  67. froid_loop/frontmatter.py +526 -0
  68. froid_loop/gates.py +133 -0
  69. froid_loop/install.py +2936 -0
  70. froid_loop/journal.py +178 -0
  71. froid_loop/machine.py +148 -0
  72. froid_loop/model.py +898 -0
  73. froid_loop/operatoractions.py +474 -0
  74. froid_loop/platform_util.py +1490 -0
  75. froid_loop/plugins/__init__.py +64 -0
  76. froid_loop/plugins/bus.py +259 -0
  77. froid_loop/plugins/context.py +319 -0
  78. froid_loop/plugins/loader.py +145 -0
  79. froid_loop/plugins/manifest.py +279 -0
  80. froid_loop/plugins/model.py +296 -0
  81. froid_loop/plugins/registry.py +245 -0
  82. froid_loop/plugins/trust.py +75 -0
  83. froid_loop/policy.py +1569 -0
  84. froid_loop/probe.py +1044 -0
  85. froid_loop/process_host.py +408 -0
  86. froid_loop/recovery_flow.py +1561 -0
  87. froid_loop/resolve.py +283 -0
  88. froid_loop/runs.py +4715 -0
  89. froid_loop/runsetup.py +1293 -0
  90. froid_loop/sanitize.py +593 -0
  91. froid_loop/settings_schema.py +276 -0
  92. froid_loop/signals.py +160 -0
  93. froid_loop/sprintstatus.py +609 -0
  94. froid_loop/statemachine.py +57 -0
  95. froid_loop/stories.py +615 -0
  96. froid_loop/stories_engine.py +796 -0
  97. froid_loop/sweep.py +1892 -0
  98. froid_loop/tokens.py +196 -0
  99. froid_loop/tui/__init__.py +11 -0
  100. froid_loop/tui/app.py +1584 -0
  101. froid_loop/tui/data.py +840 -0
  102. froid_loop/tui/launch.py +1003 -0
  103. froid_loop/tui/screens/__init__.py +1 -0
  104. froid_loop/tui/screens/dashboard.py +1071 -0
  105. froid_loop/tui/screens/modals.py +943 -0
  106. froid_loop/tui/screens/settings_screen.py +477 -0
  107. froid_loop/tui/settings.py +135 -0
  108. froid_loop/tui/widgets.py +981 -0
  109. froid_loop/verify.py +4545 -0
  110. froid_loop/workspace.py +320 -0
  111. froid_loop/worktree_flow.py +2301 -0
  112. froid_loop-0.11.1.dist-info/METADATA +728 -0
  113. froid_loop-0.11.1.dist-info/RECORD +116 -0
  114. froid_loop-0.11.1.dist-info/WHEEL +4 -0
  115. froid_loop-0.11.1.dist-info/entry_points.txt +2 -0
  116. froid_loop-0.11.1.dist-info/licenses/LICENSE +30 -0
@@ -0,0 +1,2301 @@
1
+ """Per-unit worktree isolation + integration flow.
2
+
3
+ Extracted from :class:`froid_loop.engine.Engine` (issue #244, findings F-3/F-9a):
4
+ ``Engine`` was a god-class whose worktree/integration cluster is an independent
5
+ state machine. It lives here as a collaborator built from narrow dependencies
6
+ (repo paths, the policy, run state, journal, plugin registry, loaded adapters)
7
+ plus a handful of engine callbacks (emit a plugin hook, save state, run the
8
+ per-unit ready gate, carry isolated ledger writes, escalate-pause, and get/set
9
+ the engine's active workspace).
10
+ The collaborator never receives the whole ``Engine`` — it cannot reach engine
11
+ internals beyond those callables.
12
+
13
+ ``Engine`` keeps same-name private methods that delegate here, so its tests and
14
+ the ``SweepEngine``/``StoriesEngine`` subclasses see an unchanged surface.
15
+
16
+ ``provision_worktree`` (previously in ``install.py``) is rehomed here too so the
17
+ runtime control loop no longer imports the installer; ``install.py`` re-exports
18
+ it lazily for its own tests.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import copy
24
+ import json
25
+ from collections.abc import Collection, Sequence
26
+ from importlib import resources
27
+ from pathlib import Path
28
+ from typing import TYPE_CHECKING, Callable, NoReturn
29
+
30
+ from . import gates, verify
31
+ from .install import (
32
+ _REVIEW_LAYER_SKILLS,
33
+ BASE_SKILLS,
34
+ FROID_DIR,
35
+ FROID_SCRIPTS_SEED_REL,
36
+ FROID_SEED_EXCLUDES,
37
+ CENTRAL_CONFIG_REL,
38
+ DEV_PRIMITIVE_MARKERS,
39
+ DEV_PRIMITIVE_ROLES,
40
+ HOOK_SCRIPT_REL,
41
+ MERGED_REVIEW_SKILL,
42
+ MODULE_SKILLS,
43
+ RENDER_DIR_REL,
44
+ RENDERER_SCRIPT_MARKER,
45
+ RENDERER_SCRIPT_UNIT_REL,
46
+ RENDERER_SEED_SENTINELS,
47
+ _absent_renderer_sources,
48
+ _copy_traversable,
49
+ _is_dir,
50
+ _is_file,
51
+ _occupied,
52
+ _renderer_unit_required,
53
+ _walk_traversable_files,
54
+ _worktree_local_exclude,
55
+ dev_primitive_or_default,
56
+ merge_hooks,
57
+ missing_stories_support,
58
+ renderer_stub_resolved,
59
+ resolve_review_layers,
60
+ strip_relay_hooks,
61
+ )
62
+ from .model import Phase
63
+ from .platform_util import atomic_write_text
64
+ from .process_host import get_process_host
65
+ from .workspace import (
66
+ UnitWorkspace,
67
+ Workspace,
68
+ close_unit_workspace,
69
+ discard_worktree,
70
+ unit_worktrees_dir,
71
+ )
72
+
73
+ if TYPE_CHECKING:
74
+ from importlib.resources.abc import Traversable
75
+
76
+ from .adapters.base import CodingCLIAdapter
77
+ from .adapters.profile import CLIProfile
78
+ from .froidconfig import ProjectPaths
79
+ from .journal import Journal
80
+ from .model import RunState, StoryTask
81
+ from .plugins import PluginRegistry
82
+ from .policy import Policy
83
+
84
+
85
+ # CLI profile name -> the agent id the Unity-MCP CLI's `setup-mcp` expects (see
86
+ # `unity-mcp-cli setup-mcp --list`). All but claude differ only by claude's
87
+ # "-code" suffix; codex/gemini/cursor and any custom profile pass through as-is.
88
+ _SETUP_MCP_AGENT_IDS = {"claude": "claude-code"}
89
+
90
+
91
+ def _setup_mcp_agent_id(profile_name: str) -> str:
92
+ """Map a CLI profile name to its Unity-MCP `setup-mcp` agent id."""
93
+ return _SETUP_MCP_AGENT_IDS.get(profile_name, profile_name)
94
+
95
+
96
+ def _worktree_skill_copy_candidates(repo_root: Path, tree: str) -> tuple[str, ...]:
97
+ """Every upstream skill worth best-effort copying into ``tree``."""
98
+ resolved = resolve_review_layers(repo_root, tree)
99
+ return tuple(dict.fromkeys((*BASE_SKILLS, *(resolved.skills() if resolved else ()))))
100
+
101
+
102
+ def _required_worktree_skills(repo_root: Path, tree: str) -> tuple[str, ...]:
103
+ """Upstream skills whose absence deterministically stalls this tree's run.
104
+
105
+ Match :func:`install.missing_base_skills`: gate the selected dev primitive and
106
+ resolved required review skills only. If the review shape is unknown, prefer a
107
+ present merged reviewer; otherwise require the standalone fallback reviewers.
108
+ Catalog-only and advisory skills remain copy candidates but never arm the fatal
109
+ pre-dispatch completeness gate.
110
+ """
111
+ resolved = resolve_review_layers(repo_root, tree)
112
+ if resolved is not None:
113
+ review_skills = tuple(resolved.required)
114
+ elif (repo_root / tree / MERGED_REVIEW_SKILL / "SKILL.md").is_file():
115
+ review_skills = (MERGED_REVIEW_SKILL,)
116
+ else:
117
+ review_skills = tuple(sorted(_REVIEW_LAYER_SKILLS))
118
+ return tuple(dict.fromkeys((dev_primitive_or_default(repo_root, tree), *review_skills)))
119
+
120
+
121
+ def _escape_exclude_pattern(pattern: str) -> str:
122
+ """Render one shield pattern so it names the literal path it spells (#476).
123
+
124
+ Patterns are built as ``f"/{rel}"`` from real on-disk rels, but git reads them
125
+ as gitignore(5) patterns, so a rel carrying pattern syntax names something
126
+ else entirely — and it goes wrong in both directions at once. Measured on git
127
+ 2.55.0, identical at 2.20.4 (the shield's floor):
128
+
129
+ * ``/cfg[env]`` leaves ``cfg[env]/conf.json`` STAGEABLE. The path we seeded is
130
+ never shielded, which is the one job the shield has (#476).
131
+ * The same line hides ``cfge/`` and ``cfgn/``, because ``[env]`` is a class
132
+ over ``e``/``n``/``v``. A broken pattern silently hides an UNRELATED file
133
+ that the unit meant to commit (#401, consolidated into #476).
134
+
135
+ An unescaped trailing space does both in one line: git drops it, so ``/kept``
136
+ plus a space shields ``kept/`` and leaves ``kept /`` visible.
137
+
138
+ gitignore(5)'s own escape rule is the fix — a backslash before a wildmatch
139
+ special (``*``, ``?``, ``[``, and the backslash itself) or before a trailing
140
+ space makes git match that character literally. ``]`` is deliberately left
141
+ alone: it is not special without an opening ``[``, which this escapes. ``!``
142
+ and ``#`` matter only at line start, and every pattern starts with ``/``.
143
+
144
+ One trailing ``/`` is split off and re-appended unescaped: it is the MUSTBEDIR
145
+ marker (``RENDER_DIR_REL``), not part of the name.
146
+
147
+ Ordinary rels come back byte-identical, which is what makes this inert for
148
+ every path the shield has ever written.
149
+ """
150
+ body, suffix = (pattern[:-1], "/") if pattern.endswith("/") else (pattern, "")
151
+ for special in ("\\", "*", "?", "["):
152
+ body = body.replace(special, "\\" + special)
153
+ stripped = body.rstrip(" ")
154
+ return stripped + "\\ " * (len(body) - len(stripped)) + suffix
155
+
156
+
157
+ def _fits_one_exclude_line(pattern: str) -> bool:
158
+ """True when ``pattern`` survives a round trip through the exclude file.
159
+
160
+ The exclude is a line-oriented format with NO escape for its own line
161
+ boundary, so two characters in a real filename cannot be written as a pattern
162
+ at all — unlike the wildmatch specials, which :func:`_escape_exclude_pattern`
163
+ can quote. `_worktree_local_exclude` writes each pattern `\n`-terminated
164
+ (`install.py`), and git reads lines back the way #472 measured them at 2.55.0:
165
+ `\n` boundaries with exactly ONE trailing `\r` trimmed.
166
+
167
+ * An embedded ``\n`` SPLITS the pattern into two. Neither half names the file,
168
+ so it is not shielded — and the orphaned second half is a live, unanchored
169
+ pattern that can hide an UNRELATED file, which is #401's harm direction
170
+ arriving through the one character #476's escaping cannot quote.
171
+ * A TRAILING ``\r`` is eaten as the line terminator's other half, so the
172
+ pattern names the path WITHOUT it — #476's harm direction, same cause.
173
+
174
+ An embedded ``\r`` is content to git (`/hidden\rjunk` ignores nothing but a
175
+ file spelled that way), so it needs no exclusion here.
176
+ """
177
+ return "\n" not in pattern and not pattern.endswith("\r")
178
+
179
+
180
+ def _reconcile_tracked_patterns(
181
+ worktree: Path, patterns: set[str], written: Collection[str]
182
+ ) -> tuple[set[str], str | None]:
183
+ """Reshape the shield patterns that name a TRACKED path; keep the rest as written.
184
+
185
+ Git consults ignore rules only for UNTRACKED paths, so what a pattern is worth
186
+ depends on what it names — and the two tracked shapes need opposite answers.
187
+
188
+ A pattern naming a tracked REGULAR FILE shields nothing and costs something
189
+ (#392, reported from production by an external user whose repo-hygiene gate then
190
+ blocked the story's commit). Measured on git 2.55.0 with the shield's own
191
+ private-exclude + worktree-scoped `core.excludesFile` shape, a tracked
192
+ `.codex/hooks.json`, and its pattern present:
193
+
194
+ * `git add -A` STAGES a modification to it anyway — ignore rules are consulted only
195
+ for untracked paths, so the pattern never did the job it is here for;
196
+ * `git ls-files -ci --exclude-standard` inside the worktree REPORTS it, which is the
197
+ tracked-and-ignored state hygiene gates reject.
198
+
199
+ So the pattern is pure cost and it is DROPPED. This is the second half of #384
200
+ — its reporter proposed it as their option 3 ("skip any pattern whose path already
201
+ contains tracked files … costs nothing and removes the surprising case entirely");
202
+ PR #385 landed option 1 (scope + lifetime) and this half was dropped rather than
203
+ rejected, which is how a second reporter hit it.
204
+
205
+ A pattern naming a tracked DIRECTORY is that reporter's shape one step out (#484),
206
+ and its answer is SUBSTITUTION. The measurement that used to justify keeping it
207
+ still stands and is kept here: a dir pattern really does hide new children, and no
208
+ pattern SHAPE both hides them and keeps the report clean — `dir/*`, `dir/**` and
209
+ `dir` plus a trailing negation all measured identical to `dir`, because gitignore
210
+ cannot re-include anything under an excluded parent, while the one shape that did
211
+ clear the report (`dir/*` with per-child negations) leaked a new file into the
212
+ commit. What reverses is the VERDICT, on the #392 physics: over a tracked tree the
213
+ pattern's protection is already mostly inert — every modification to a tracked
214
+ child stages regardless — so it was buying new-child coverage alone at the price of
215
+ a false tracked-and-ignored report for the whole tree.
216
+
217
+ The fix is therefore a different set of PATHS rather than a different shape: drop
218
+ the dir pattern and substitute one pattern per file THIS provisioning run actually
219
+ wrote below it (``written``). Those files are untracked by construction —
220
+ copy-when-absent cannot land on top of a tracked child — which is why they are
221
+ exactly the case an ignore rule works for, and why none of them is re-probed here:
222
+ the spawn count stays one per pattern the caller built. A tracked directory that
223
+ received nothing at all drops to no pattern, which is the same answer for the same
224
+ reason: there is nothing of ours below it to shield.
225
+
226
+ ACCEPTED RESIDUAL (maintainer decision on #484, 2026-08-08, which is this
227
+ function's design authority): a file the SESSION creates under a tracked tool
228
+ directory can be staged, where the dir pattern would have hidden it. That is the
229
+ trade, deliberately taken — it matches the project's own decision to TRACK that
230
+ tree, and everything the orchestrator put there keeps a pattern of its own. It is
231
+ not a defect to fix by widening back to a dir pattern.
232
+
233
+ APPEND-ONLY RESIDUE: `_worktree_local_exclude` never removes a line, by design —
234
+ operator lines ride in its rewrite prefix and its last-match-wins reasoning depends
235
+ on nothing being deleted, with no marker separating our stale line from theirs. So
236
+ a `/dir` line an OLDER provisioning of the SAME worktree wrote survives a
237
+ re-provision that would no longer write it. Accepted rather than fixed: the residue
238
+ can only be too wide, never too narrow, and the private exclude dies with the
239
+ worktree.
240
+
241
+ Patterns ending in `/` are directory-shaped by construction (`RENDER_DIR_REL`) and
242
+ are never probed.
243
+
244
+ A substituted rel that cannot be spelled as ONE exclude line
245
+ (:func:`_fits_one_exclude_line`) sends the WHOLE directory back to its dir
246
+ pattern, journaled. That is the same trade as the degrade below and for the same
247
+ reason: no per-file pattern exists for such a name, so substituting would leave
248
+ the file stageable and — for a newline — write an orphan half-pattern that hides
249
+ unrelated files.
250
+
251
+ Degrades by KEEPING every pattern it could not resolve, in its ORIGINAL shape —
252
+ the dir shape included, never the substitution. A shield staying too wide is
253
+ cosmetic, while dropping or narrowing a pattern on a guess can leak the
254
+ orchestrator's own seeded files into a story commit. Returns the surviving patterns
255
+ and a reason for the caller to journal, or None when nothing went wrong.
256
+
257
+ NOT-A-REPO IS SILENT, and it is the ordinary case rather than an edge one: many
258
+ callers provision plain non-repo directories, where there is no index, no shield and
259
+ no `git add -A` to be wrong about. The same `rev-parse --absolute-git-dir` gate
260
+ `_worktree_local_exclude` skips on is asked FIRST, so this cannot emit a degrade per
261
+ call and train operators past the one that matters. Only a repo that answers that
262
+ probe and then fails the per-path one is reported."""
263
+ try:
264
+ if verify.git_bytes(worktree, "rev-parse", "--absolute-git-dir").returncode != 0:
265
+ return patterns, None
266
+ except (verify.GitError, OSError):
267
+ return patterns, None
268
+ kept: set[str] = set()
269
+ unprobed: list[str] = []
270
+ unwritable: list[str] = []
271
+ for pattern in patterns:
272
+ rel = pattern.lstrip("/")
273
+ if not rel or pattern.endswith("/"):
274
+ kept.add(pattern)
275
+ continue
276
+ try:
277
+ kind = verify.path_tracked_kind(worktree, rel)
278
+ except (verify.GitError, OSError) as e:
279
+ kept.add(pattern)
280
+ unprobed.append(f"{rel} ({e})")
281
+ continue
282
+ if kind == "untracked":
283
+ kept.add(pattern)
284
+ elif kind == "dir":
285
+ subs = {f"/{w}" for w in written if w.startswith(rel + "/")}
286
+ unrepresentable = sorted(s for s in subs if not _fits_one_exclude_line(s))
287
+ if unrepresentable:
288
+ # Substituting here would write a pattern the exclude cannot carry,
289
+ # leaving that file stageable AND (for a `\n`) loosing an unanchored
290
+ # orphan pattern on unrelated files. The dir pattern is the only shape
291
+ # that still covers it, so this degrades the way every other
292
+ # unanswerable case does: KEEP the original, journal the reason. Too
293
+ # wide is cosmetic — this tree keeps its tracked-and-ignored report —
294
+ # while dropping or half-substituting leaks provisioned work into the
295
+ # unit's commit.
296
+ kept.add(pattern)
297
+ unwritable.extend(unrepresentable)
298
+ else:
299
+ kept |= subs
300
+ # "file": dropped. The pattern is measurably inert over a tracked file and
301
+ # only feeds it to repo-hygiene gates as tracked-and-ignored (#392).
302
+ reasons: list[str] = []
303
+ if unprobed:
304
+ reasons.append(
305
+ "worktree git-add shield could not check whether these paths are tracked, "
306
+ "so their patterns were kept as built; a tracked path among them will read "
307
+ f"as ignored to repo-hygiene checks (#392, #484): {'; '.join(sorted(unprobed))}"
308
+ )
309
+ if unwritable:
310
+ reasons.append(
311
+ "worktree git-add shield kept a tracked directory's whole-dir pattern "
312
+ "because a file provisioning wrote below it cannot be spelled as one "
313
+ "exclude line (a newline, or a trailing carriage return); that directory "
314
+ "will read as ignored to repo-hygiene checks (#484): "
315
+ f"{'; '.join(sorted(unwritable))}"
316
+ )
317
+ return kept, (" ".join(reasons) if reasons else None)
318
+
319
+
320
+ def _pin_tracked_config_rewrite(worktree: Path, rel: str) -> str | None:
321
+ """Keep a rewritten TRACKED hook config out of the unit's story commits.
322
+
323
+ The worktree-local exclude cannot: git consults ignore rules only for
324
+ untracked paths (#392). When a project tracks its hook config, the relay
325
+ rewrite — a machine-specific absolute command — would ride every
326
+ `git add -A` (the skill's own commits and finalize_commit alike) into the
327
+ story commit and merge back to the target branch, handing every other
328
+ checkout a relay path that does not exist there. The worktree's own index
329
+ carries a skip-worktree bit that `add -A`, `status` and checkout all honor
330
+ (it is the sparse-checkout mechanism) and that dies with the worktree, so
331
+ the rewrite stays session-local.
332
+
333
+ While the pin holds, the config is orchestrator-owned: a story's own edit to
334
+ the pinned file stays session-local and is discarded with the worktree. That
335
+ is deliberate — before this pin the tracked case stalled outright (#352), so
336
+ there is no prior working behavior to preserve, and any file-level hiding
337
+ that keeps OUR rewrite out of `add -A` hides a story's edit with it.
338
+
339
+ NOT-A-REPO IS SILENT for the same reason the shield's tracked-probe is:
340
+ provisioning a plain directory is ordinary, and there is no index and no
341
+ `git add -A` to be wrong about. An untracked config needs nothing — the
342
+ exclude shield owns it. A tracked-probe that cannot answer is returned for
343
+ the caller to journal (observation degrades; the rewrite stands, since a
344
+ stalled session is the worse outcome, #352). But a failed update-index on a
345
+ KNOWN-tracked config raises: the pin is a repair write, and continuing
346
+ without it knowingly leaves `git add -A` free to commit the machine-specific
347
+ command and merge it back.
348
+ """
349
+ try:
350
+ if verify.git_bytes(worktree, "rev-parse", "--absolute-git-dir").returncode != 0:
351
+ return None
352
+ except (verify.GitError, OSError):
353
+ return None
354
+ try:
355
+ if not verify.path_tracked_file(worktree, rel):
356
+ return None
357
+ except (verify.GitError, OSError) as e:
358
+ return (
359
+ f"could not check whether the rewritten hook config {rel} is tracked "
360
+ f"({e}); if the project tracks it, the worktree's machine-specific relay "
361
+ "command may be committed and merged back (#352)"
362
+ )
363
+ pinned = verify.git_bytes(worktree, "update-index", "--skip-worktree", "--", rel)
364
+ if pinned.returncode != 0:
365
+ raise verify.GitError(
366
+ f"git update-index --skip-worktree {rel} failed in the worktree; the hook "
367
+ "config is tracked, so without the pin its machine-specific relay rewrite "
368
+ "would reach story commits and merge back (#352)"
369
+ )
370
+ return None
371
+
372
+
373
+ def _seed_froid_tree(worktree: Path, repo_root: Path) -> tuple[list[str], list[str]]:
374
+ """Merge the repo's project-local FROID surface into an isolated worktree.
375
+
376
+ Renderer-backed skills receive the worktree as their project root and do not
377
+ walk upward for ``_froid``. Copy every usable file except generated render output,
378
+ per-file and without clobbering checkout content. The shared Traversable walk is
379
+ intentional: unlike ``rglob``, it descends a symlinked child directory, allowing
380
+ the result-side completeness predicates to see every file the copier considered.
381
+
382
+ Returns ``(shield_rels, written)``, two readings of the same copy:
383
+
384
+ * ``shield_rels`` — what the caller turns into git-add shield patterns: ``[]``
385
+ when nothing landed, ``[FROID_DIR]`` when the root was absent before seeding
386
+ (one pattern covers a tree that is wholly ours), otherwise each file that
387
+ actually landed. A fresh worktree checkout materializes every tracked path, so
388
+ "root absent AND tracked" cannot arise and this collapse is already correct for
389
+ the tracked-directory rule (#484): the collapsed shape only ever names an
390
+ untracked root.
391
+ * ``written`` — always the per-file landed rels, never the collapse. This is the
392
+ substitution ledger the shield reads when a tool directory turns out to be
393
+ TRACKED (#484): the dir pattern is dropped and these files get patterns of
394
+ their own, so the answer has to stay per-file even when ``shield_rels``
395
+ collapsed.
396
+ """
397
+ src_root = repo_root / FROID_DIR
398
+ if not _is_dir(src_root):
399
+ return [], []
400
+ dst_root = worktree / FROID_DIR
401
+ had_froid = _is_dir(dst_root)
402
+ try:
403
+ tops = sorted(src_root.iterdir(), key=lambda entry: entry.name)
404
+ except OSError:
405
+ return [], []
406
+
407
+ seeded: list[str] = []
408
+ for top in tops:
409
+ if top.name in FROID_SEED_EXCLUDES:
410
+ continue
411
+ for rel, src in _walk_traversable_files(top, top.name):
412
+ if not _is_file(src):
413
+ continue
414
+ dst = dst_root.joinpath(*rel.split("/"))
415
+ if _copy_traversable(
416
+ src,
417
+ dst,
418
+ skip_existing=True,
419
+ worktree=worktree,
420
+ repo_root=repo_root,
421
+ ):
422
+ seeded.append(f"{FROID_DIR}/{rel}")
423
+ if not seeded:
424
+ return [], []
425
+ return ([FROID_DIR] if not had_froid else seeded), seeded
426
+
427
+
428
+ def _record_seeded(
429
+ seeded_from: dict[Path, Path],
430
+ landed: Sequence[Path],
431
+ src: Path,
432
+ dst: Path,
433
+ ) -> None:
434
+ """Record where each path a seed entry just wrote was copied FROM.
435
+
436
+ `_copy_traversable` reproduces the source tree's shape under ``dst``, so a landed
437
+ path's rel against ``dst`` is also its source's rel against ``src``; the root of a
438
+ file entry gives ``Path(".")``, which pathlib drops, mapping ``dst`` to ``src``.
439
+
440
+ ``src`` is the RESOLVED source, so a seed entry reached through a symlink names
441
+ the file that really holds the bytes rather than the link the operator listed.
442
+ First writer wins: nothing overwrites an earlier entry, because a path is only
443
+ ever written once — copy-when-absent makes the second entry naming it a skip, and
444
+ a skip lands nothing to record (#592).
445
+ """
446
+ for path in landed:
447
+ seeded_from.setdefault(path, src / path.relative_to(dst))
448
+
449
+
450
+ def _written_rels(worktree: Path, landed: Sequence[Path]) -> list[str]:
451
+ """Worktree-relative rels of the FILES a copy call just wrote.
452
+
453
+ Feeds the shield's tracked-directory substitution (#484): over a tracked tool
454
+ dir the whole-dir pattern is replaced by one pattern per file provisioning
455
+ actually landed there, so this is the ledger of what there is to shield.
456
+
457
+ The :func:`_is_file` filter is load-bearing rather than defensive.
458
+ ``_copy_traversable``'s ``copied_paths`` records every destination that landed —
459
+ each file written AND each directory the call created (``install.py:1427-1436``).
460
+ A directory rel reaching the substitution would render as another whole-dir
461
+ pattern, reintroducing the exact shape #484 removes, one level down.
462
+
463
+ ``as_posix`` so a substituted pattern anchors on Windows too; ``os.sep`` would
464
+ not, and the exclude is read by git, not by the platform.
465
+ """
466
+ return [p.relative_to(worktree).as_posix() for p in landed if _is_file(p)]
467
+
468
+
469
+ def _froid_scripts_seed_incomplete(worktree: Path, repo_root: Path) -> bool:
470
+ """Whether a required repo renderer unit member missed the worktree.
471
+
472
+ Match the renderer preflight's content-keyed required-file predicate. Arbitrary
473
+ sibling scripts are still merge-seeded, but their absence cannot prove the
474
+ renderer will HALT and therefore must not arm the CRITICAL escalation gate.
475
+ """
476
+ return any(
477
+ _renderer_unit_required(repo_root, rel)
478
+ and _is_file(repo_root / rel)
479
+ and not _is_file(worktree / rel)
480
+ for rel in RENDERER_SCRIPT_UNIT_REL
481
+ )
482
+
483
+
484
+ def _central_config_seed_incomplete(worktree: Path, repo_root: Path) -> bool:
485
+ """Whether the repo's required renderer config failed to reach the worktree."""
486
+ return _is_file(repo_root / CENTRAL_CONFIG_REL) and not _is_file(worktree / CENTRAL_CONFIG_REL)
487
+
488
+
489
+ def base_skills_seed_incomplete(worktree: Path, repo_root: Path, trees: Sequence[str]) -> list[str]:
490
+ """Required upstream skill rels present in the repo but absent in the worktree.
491
+
492
+ ``BASE_SKILLS`` is a copy-if-present catalog, not a requirement set. Gate only
493
+ the selected primitive and required review skills; advisory/conditional and
494
+ inactive catalog entries are still provisioned best-effort, but their absence
495
+ cannot prove the dev/review session will stall. Unknown review shapes use the
496
+ same merged-or-standalone fallback as the run-start preflight.
497
+
498
+ Within those active skills, mirror the deterministic run-start contract rather
499
+ than treating every descendant as fatal: reviewers require ``SKILL.md``; the dev
500
+ primitive additionally requires its defined markers and any renderer snapshot
501
+ sources its worktree copy resolves. Other descendants still copy best-effort,
502
+ but their absence does not prove the outer session will write no artifact.
503
+
504
+ A missing ``SKILL.md`` reports the coarse skill rel. A repo directory without
505
+ ``SKILL.md`` remains the run-start preflight's concern and cannot produce a false
506
+ CRITICAL escalation here. Stories mode adds its content-keyed dispatch probe at
507
+ the caller, where the run mode is available.
508
+ """
509
+ missing: list[str] = []
510
+ for tree in dict.fromkeys(trees):
511
+ primitive = dev_primitive_or_default(repo_root, tree)
512
+ for skill in _required_worktree_skills(repo_root, tree):
513
+ repo_skill = repo_root / tree / skill
514
+ worktree_skill = worktree / tree / skill
515
+ if not _is_file(repo_skill / "SKILL.md"):
516
+ # Distinguish an unreadable directory from a skill the repo simply
517
+ # does not carry. The walk yields only the former as a directory leaf.
518
+ if any(_is_dir(src) for _, src in _walk_traversable_files(repo_skill)):
519
+ missing.append(f"{tree}/{skill}")
520
+ continue
521
+ if not _is_file(worktree_skill / "SKILL.md"):
522
+ missing.append(f"{tree}/{skill}")
523
+ continue
524
+ if skill != primitive:
525
+ continue
526
+ missing.extend(
527
+ f"{tree}/{skill}/{rel}"
528
+ for rel in DEV_PRIMITIVE_MARKERS
529
+ if _is_file(repo_skill / rel) and not _is_file(worktree_skill / rel)
530
+ )
531
+ missing.extend(
532
+ f"{tree}/{skill}/{rel}"
533
+ for rel in _absent_renderer_sources(worktree_skill)
534
+ if _is_file(repo_skill.joinpath(*rel.split("/")))
535
+ )
536
+ return missing
537
+
538
+
539
+ def worktree_seed_undelivered(
540
+ worktree: Path,
541
+ repo_root: Path,
542
+ seed_files: Sequence[str] = (),
543
+ seed_globs: Sequence[str] = (),
544
+ config_paths: Sequence[str] = (),
545
+ ) -> list[str]:
546
+ """Seed rels the repo carries that never reached the worktree.
547
+
548
+ Source containment is deliberately *not* an eligibility requirement: a source
549
+ symlink resolving outside the repo is the canonical entry the seed loop refuses
550
+ and this result check must report. Delivery does require every usable source
551
+ entry to remain within the repo, matching the copier. Destination containment is
552
+ likewise required so a path outside the worktree cannot masquerade as delivery.
553
+
554
+ Hook configs need a different result question because provisioning writes the
555
+ hook registration itself after seeding. For those rels, existence proves nothing;
556
+ source escape, a symlinked destination, or destination escape proves the seed was
557
+ refused. This report is informational and is never an escalation gate.
558
+ """
559
+ unresolved_repo_root = repo_root
560
+ try:
561
+ worktree = worktree.resolve()
562
+ repo_root = repo_root.resolve()
563
+ except (OSError, RuntimeError):
564
+ # Observation only: root uncertainty cannot prove delivery, but it must
565
+ # not turn an informational journal probe into a run-wide failure.
566
+ rels = [str(rel) for rel in seed_files]
567
+ for pattern in seed_globs:
568
+ try:
569
+ matches = sorted(unresolved_repo_root.glob(pattern))
570
+ except (OSError, RuntimeError):
571
+ continue
572
+ rels.extend(match.relative_to(unresolved_repo_root).as_posix() for match in matches)
573
+ return list(dict.fromkeys(rels))
574
+ rels = [str(rel) for rel in seed_files]
575
+ for pattern in seed_globs:
576
+ rels.extend(
577
+ match.relative_to(repo_root).as_posix() for match in sorted(repo_root.glob(pattern))
578
+ )
579
+ hook_configs = {Path(rel) for rel in config_paths}
580
+
581
+ def contained(path: Path, root: Path) -> bool:
582
+ try:
583
+ return path.resolve().is_relative_to(root)
584
+ except (OSError, RuntimeError):
585
+ return False
586
+
587
+ def delivered(src: Path, dst: Path) -> bool:
588
+ """Whether every usable source descendant has a matching destination."""
589
+ if not contained(src, repo_root) or not contained(dst, worktree):
590
+ return False
591
+ if _is_file(src):
592
+ return _is_file(dst)
593
+ if not _is_dir(src) or not _is_dir(dst):
594
+ return False
595
+
596
+ complete = True
597
+
598
+ def visit_dir(rel: str, source) -> bool:
599
+ nonlocal complete
600
+ if not isinstance(source, Path) or not contained(source, repo_root):
601
+ complete = False
602
+ return False
603
+ target = dst.joinpath(*rel.split("/")) if rel else dst
604
+ if not contained(target, worktree) or not _is_dir(target):
605
+ complete = False
606
+ return True
607
+
608
+ for child_rel, child in _walk_traversable_files(src, _visit_dir=visit_dir):
609
+ target = dst.joinpath(*child_rel.split("/")) if child_rel else dst
610
+ if _is_file(child):
611
+ if (
612
+ not isinstance(child, Path)
613
+ or not contained(child, repo_root)
614
+ or not contained(target, worktree)
615
+ or not _is_file(target)
616
+ ):
617
+ complete = False
618
+ elif _is_dir(child):
619
+ # Directories are yielded only when enumeration was refused, so
620
+ # their unknown descendants cannot be claimed as delivered.
621
+ complete = False
622
+ return complete
623
+
624
+ undelivered: list[str] = []
625
+ for rel in dict.fromkeys(rels):
626
+ src = repo_root / rel
627
+ if not (_is_file(src) or _is_dir(src)):
628
+ continue
629
+ dst = worktree / rel
630
+ if Path(rel) in hook_configs:
631
+ try:
632
+ destination_is_link = dst.is_symlink()
633
+ except OSError:
634
+ destination_is_link = True
635
+ if not contained(src, repo_root) or not contained(dst, worktree) or destination_is_link:
636
+ undelivered.append(rel)
637
+ continue
638
+ if delivered(src, dst):
639
+ continue
640
+ undelivered.append(rel)
641
+ return undelivered
642
+
643
+
644
+ def module_skills_seed_undelivered(
645
+ worktree: Path,
646
+ trees: Sequence[str],
647
+ skills_root: Traversable | None = None,
648
+ ) -> list[str]:
649
+ """Wheel-bundled ``MODULE_SKILLS`` whose content never reached the worktree.
650
+
651
+ Re-probes DISK, never the copier's bookkeeping: a user-authored
652
+ ``scm.worktree_seed`` entry that happens to spell a skill rel can therefore
653
+ neither forge nor mask a report. The source is the wheel's own skills tree, which
654
+ may be a zip Traversable rather than a real directory, so enumeration goes through
655
+ the shared :func:`install._walk_traversable_files` walk instead of ``rglob``.
656
+
657
+ Presence is the whole contract; content is NEVER compared. Seeding is per-FILE
658
+ no-clobber, so a checkout carrying its own divergent fork of a bundled skill keeps
659
+ those bytes while its absent siblings are filled in — comparing content would
660
+ report that healthy shape as undelivered. A skill the wheel itself lacks is
661
+ skipped: a broken wheel is the module-skills sync test's concern, not a run's.
662
+
663
+ Informational, and NEVER an escalation gate. No ``MODULE_SKILLS`` entry has a
664
+ worktree-resident consumer — sweep triage dispatches ``/froid-loop-sweep`` at the
665
+ MAIN checkout, ``froid-loop-resolve`` runs at the main checkout too, and
666
+ ``froid-loop-setup`` has no session consumer at all — so an absence here cannot
667
+ prove a stall, and a CRITICAL gate would refuse healthy runs. Extension point: if
668
+ a consumer ever becomes worktree-resident (e.g. sweep triage moving into unit
669
+ worktrees), arm the gate by routing this predicate's result into
670
+ :meth:`WorktreeFlow.escalate_unit` for that consumer's required subset.
671
+
672
+ Returns coarse ``"{tree}/{skill}"`` posix rels in iteration order.
673
+ """
674
+ if skills_root is None:
675
+ skills_root = resources.files("froid_loop.data").joinpath("skills")
676
+ try:
677
+ worktree = worktree.resolve()
678
+ except (OSError, RuntimeError):
679
+ # This is a journal-only observation. Root uncertainty means every
680
+ # bundled skill the wheel actually carries is coarsely undelivered.
681
+ return [
682
+ f"{tree}/{skill}"
683
+ for tree in dict.fromkeys(trees)
684
+ for skill in MODULE_SKILLS
685
+ if _is_file(skills_root.joinpath(skill)) or _is_dir(skills_root.joinpath(skill))
686
+ ]
687
+
688
+ def contained(target: Path) -> bool:
689
+ try:
690
+ return target.resolve().is_relative_to(worktree)
691
+ except (OSError, RuntimeError):
692
+ return False
693
+
694
+ def delivered(src: Traversable, dst: Path) -> bool:
695
+ """Whether every usable wheel entry has a matching worktree destination."""
696
+ for rel, entry in _walk_traversable_files(src):
697
+ target = dst.joinpath(*rel.split("/")) if rel else dst
698
+ if _is_file(entry):
699
+ if contained(target) and _is_file(target):
700
+ continue
701
+ return False
702
+ if _is_dir(entry):
703
+ # Directories are yielded only when enumeration was refused, so
704
+ # their unknown descendants cannot be claimed as delivered.
705
+ return False
706
+ # Neither a readable file nor a directory: the copier has no path for
707
+ # such an entry either, so its absence is not a delivery failure.
708
+ return True
709
+
710
+ undelivered: list[str] = []
711
+ for tree in dict.fromkeys(trees):
712
+ for skill in MODULE_SKILLS:
713
+ src = skills_root.joinpath(skill)
714
+ if not (_is_file(src) or _is_dir(src)):
715
+ continue
716
+ if not delivered(src, worktree / tree / skill):
717
+ undelivered.append(f"{tree}/{skill}")
718
+ return undelivered
719
+
720
+
721
+ def provision_worktree(
722
+ worktree: Path,
723
+ profiles: Sequence[CLIProfile],
724
+ repo_root: Path,
725
+ seed_files: Sequence[str] = (),
726
+ seed_globs: Sequence[str] = (),
727
+ *,
728
+ on_degraded: Callable[[str], None] | None = None,
729
+ ) -> list[str]:
730
+ """Make a freshly-created git worktree a self-sufficient froid-loop project.
731
+
732
+ A worktree checks out tracked files only, but the skill trees (.claude/skills,
733
+ .agents/skills), the hook config, and the project's gitignored MCP/CLI configs
734
+ are absent from the checkout. Without them the bundled froid-loop-* skills are missing,
735
+ the Stop-signal hook never fires, and isolated sessions can't reach their MCP
736
+ server. Lay the bundled skills + signal hook into the worktree for the active
737
+ CLI profiles, and copy the `seed_files` configs in from the main repo. The
738
+ upstream skills the orchestrator drives (BASE_SKILLS: froid-build-auto + the review
739
+ hunters, plus whatever review layers this project's own config names) are not
740
+ bundled in the wheel, so they are copied from the MAIN REPO's installed tree
741
+ instead — together with `_froid/custom/`, the customization those layers resolve
742
+ through, so the isolated run resolves the same layer set the preflight
743
+ validated. Quiet (no stdout) — unlike `install_into` this runs inside the
744
+ engine loop under a TUI. No-op when there's nothing to do.
745
+
746
+ seed_globs are project-relative glob patterns (e.g. ".claude/skills/*") expanded
747
+ against the main repo; every match is copied into the worktree under the same
748
+ relative path, copy-when-absent like seed_files. A game-engine plugin uses these
749
+ to pull its MCP-generated skill tree (gitignored, so absent from the checkout)
750
+ into a per_worktree Editor's checkout.
751
+
752
+ A `seed_files` entry naming a DIRECTORY whose destination already exists is
753
+ seeded child by child: the children the checkout lacks are copied in, the ones
754
+ it carries are left untouched. A worktree checks out tracked files, so such a
755
+ dir always exists and the entry would otherwise be a total no-op (issue #230).
756
+
757
+ Kept safe against the unit's eventual `git add -A` commit:
758
+ - skills + seed files are copied only when ABSENT — at FILE granularity, so a
759
+ project that commits its own skill tree (e.g. .agents/) or config keeps it
760
+ untouched (no diff merged back);
761
+ - the hook points at the MAIN repo's already-installed relay via an absolute
762
+ path (the relay locates its events directory from $FROID_LOOP_EVENTS_DIR,
763
+ falling back to $FROID_LOOP_RUN_DIR/events — never from its own location),
764
+ so nothing is written into the worktree's .froid-loop/. Since #494 the
765
+ primary channel is out of the project tree entirely, so a worktree cannot
766
+ carry a run's control plane at all — but the fallback still resolves under
767
+ the MAIN run dir, so the guarantee holds for an older installed relay too;
768
+ - everything we wrote is excluded from git, in a file private to THIS worktree
769
+ (`.git/worktrees/<id>/info/exclude`, activated per-worktree) that dies with it
770
+ when the worktree is removed. It is never the repository-wide
771
+ `.git/info/exclude`: that file is shared with the operator's own checkout and
772
+ permanent, so shielding through it hid every new file under a tool dir from
773
+ their `git add -A` forever (#384). That write is best-effort: when git can't
774
+ be queried at all it is skipped silently, but any fault after that — including
775
+ a refusal to scope the shield, and a shield that was written but which git does
776
+ not resolve to — is reported to `on_degraded` (once, with the reason) rather
777
+ than swallowed, and the shield is skipped rather than widened back to the
778
+ shared file. An unshielded worktree lets `git add -A` stage the tool files, so
779
+ it must not fail invisibly.
780
+ Skill trees, the per-CLI hook config, and the seeded configs all live in dirs
781
+ projects gitignore — but the exclude shields them even when a project doesn't.
782
+
783
+ seed_files are copied BEFORE the hook step so a seeded settings file that is
784
+ also a hook config_path (.claude/settings.json, .gemini/settings.json) keeps its
785
+ real content rather than being created empty. Its relay entry is replaced, not
786
+ kept: the seeded copy carries the main repo's $CLAUDE_PROJECT_DIR-relative relay
787
+ command, which resolves to the worktree, so the hook step strips it and registers
788
+ its own absolute command in its place (#352). A config that is already there but
789
+ cannot be parsed refuses provisioning outright — `verify.GitError`, which the
790
+ caller escalates as CRITICAL and pauses the run — rather than being replaced by
791
+ a hooks-only file: an unparseable config is evidence of an earlier fault, and the
792
+ operator's bytes are left intact for inspection. The refusal sends the repair to
793
+ whichever source supplied those bytes — read from the per-path record of what
794
+ seeding actually wrote, never inferred from the seed entry that covers the path
795
+ (#592).
796
+
797
+ The repo's `_froid/` surface is also merge-seeded, excluding generated render
798
+ output. Renderer and upstream-skill completeness failures share the return
799
+ channel with ordinary no-op seeds so they are journaled, but the caller re-probes
800
+ the skill result and content-gates the renderer sentinels before escalating.
801
+
802
+ Returns the `seed_files` entries that copied NOTHING because everything they
803
+ name was already present, plus reserved completeness reports. A directory entry
804
+ that seeded even one child is not a no-op and is not reported.
805
+ """
806
+ if not profiles and not seed_files and not seed_globs and not _is_dir(repo_root / FROID_DIR):
807
+ return []
808
+ unresolved_worktree = worktree
809
+ unresolved_repo_root = repo_root
810
+ try:
811
+ worktree = worktree.resolve()
812
+ repo_root = repo_root.resolve()
813
+ except (OSError, RuntimeError) as e:
814
+ raise verify.GitError(
815
+ "cannot resolve worktree provisioning roots safely "
816
+ f"(worktree={unresolved_worktree}, repo_root={unresolved_repo_root}): {e}"
817
+ ) from e
818
+ relay = repo_root / HOOK_SCRIPT_REL
819
+ skills_root = resources.files("froid_loop.data").joinpath("skills")
820
+
821
+ # project gitignored MCP/CLI configs: copy from the main repo when absent.
822
+ # Resolve-and-contain guards against an `..`/absolute entry escaping either tree.
823
+ seeded: list[str] = []
824
+ # Every FILE provisioning actually wrote, worktree-relative. `seeded` answers per
825
+ # ENTRY and collapses a directory to its root; this stays per-file, because over a
826
+ # TRACKED tool directory the shield drops the dir pattern and substitutes one
827
+ # pattern per file we landed under it (#484). A path is here only if this run
828
+ # wrote it, which is what makes the substituted patterns untracked by construction
829
+ # — copy-when-absent cannot land on top of a tracked child.
830
+ written: set[str] = set()
831
+ # Which source supplied each path seeding actually WROTE, keyed by where it
832
+ # landed. `seeded` answers per ENTRY and cannot be read per FILE: a directory
833
+ # entry records only its own rel below, and copy-when-absent skips occupied
834
+ # children one at a time, so a dir rel here means "at least one child landed",
835
+ # never "this child did". The hook step reads this map to name the real source of
836
+ # an unparseable config, where guessing sends the operator to repair the wrong
837
+ # file (#592). `_copy_traversable` mirrors the source layout under `dst`, so each
838
+ # landed path's rel is its source's rel too.
839
+ seeded_from: dict[Path, Path] = {}
840
+ # Entries that named a real source but copied nothing, because every path they
841
+ # name already exists. Reported to the caller (this function is quiet by
842
+ # contract — it runs under a TUI) because the no-op is otherwise silent: an
843
+ # entry that reads as applied configuration is not. Per-CHILD skips inside a
844
+ # directory entry are deliberately not reported — the checkout is expected to
845
+ # carry its tracked children, so that is routine rather than a
846
+ # misconfiguration, exactly like the glob-expanded matches below.
847
+ skipped: list[str] = []
848
+ for rel in seed_files:
849
+ raw = worktree / rel
850
+ try:
851
+ src = (repo_root / rel).resolve()
852
+ dst = raw.resolve()
853
+ except (OSError, RuntimeError):
854
+ continue
855
+ if not src.is_relative_to(repo_root) or not dst.is_relative_to(worktree):
856
+ continue
857
+ if not (_is_file(src) or _is_dir(src)):
858
+ continue
859
+ if _occupied(dst):
860
+ # A live symlink remains the ordinary existing-destination no-op. This
861
+ # arm must precede the raw-vs-resolved refusal below so it stays named in
862
+ # `skipped` rather than turning into a silent drop.
863
+ if dst != raw:
864
+ skipped.append(str(rel))
865
+ continue
866
+ # File entries keep the classic copy-when-absent skip. A destination
867
+ # that is not a directory while the source is (the checkout carries a
868
+ # FILE where the seed names a dir) is a type mismatch: recursing would
869
+ # try to mkdir over the file, so the entry is skipped whole instead.
870
+ if not _is_dir(src) or not _is_dir(raw):
871
+ skipped.append(str(rel))
872
+ continue
873
+ # A DIRECTORY whose destination exists is the case #230 reported: the
874
+ # checkout carries some tracked child, so the whole entry used to be a
875
+ # no-op — including the gitignored children that are absent and would
876
+ # clobber nothing. Recurse instead, copying only what is missing.
877
+ landed: list[Path] = []
878
+ if not _copy_traversable(
879
+ src,
880
+ raw,
881
+ skip_existing=True,
882
+ worktree=worktree,
883
+ repo_root=repo_root,
884
+ copied_paths=landed,
885
+ ):
886
+ # every child was already present: still a total no-op, still
887
+ # reported. Only a PARTIAL seed stops being reported.
888
+ skipped.append(str(rel))
889
+ continue
890
+ # Partially seeded, so the entry must still reach `patterns` below:
891
+ # the children we just wrote have to stay out of the unit's
892
+ # `git add -A`. Excluding the whole dir is safe — an exclude does not
893
+ # untrack the tracked children that were already there.
894
+ seeded.append(rel)
895
+ _record_seeded(seeded_from, landed, src, raw)
896
+ written.update(_written_rels(worktree, landed))
897
+ continue
898
+ # `resolve()` is non-strict, so a dangling leaf or parent link answers for
899
+ # its target. Never mkdir/copy through it. Existing live links were handled
900
+ # by the skip arm above, preserving copy-when-absent reporting.
901
+ if dst != raw:
902
+ continue
903
+ landed = []
904
+ if _copy_traversable(
905
+ src,
906
+ raw,
907
+ skip_existing=True,
908
+ worktree=worktree,
909
+ repo_root=repo_root,
910
+ copied_paths=landed,
911
+ ):
912
+ seeded.append(rel)
913
+ _record_seeded(seeded_from, landed, src, raw)
914
+ written.update(_written_rels(worktree, landed))
915
+
916
+ # glob-seeded trees (e.g. an engine plugin's MCP skill dirs): expand each
917
+ # pattern against the main repo and copy matches in, same contain guard +
918
+ # copy-when-absent semantics. rel is taken from the unresolved match so the
919
+ # worktree path mirrors the repo layout; resolve only guards containment.
920
+ for pattern in seed_globs:
921
+ for match in sorted(repo_root.glob(pattern)):
922
+ rel = match.relative_to(repo_root)
923
+ raw = worktree / rel
924
+ try:
925
+ src = match.resolve()
926
+ dst = raw.resolve()
927
+ except (OSError, RuntimeError):
928
+ continue
929
+ if not src.is_relative_to(repo_root) or not dst.is_relative_to(worktree):
930
+ continue
931
+ if not (_is_file(src) or _is_dir(src)) or _occupied(dst):
932
+ continue
933
+ if dst != raw:
934
+ continue
935
+ landed = []
936
+ if not _copy_traversable(
937
+ src,
938
+ raw,
939
+ skip_existing=True,
940
+ worktree=worktree,
941
+ repo_root=repo_root,
942
+ copied_paths=landed,
943
+ ):
944
+ continue
945
+ # as_posix so the exclude pattern anchors on Windows too (os.sep would not)
946
+ seeded.append(rel.as_posix())
947
+ _record_seeded(seeded_from, landed, src, raw)
948
+ written.update(_written_rels(worktree, landed))
949
+
950
+ # Renderer-backed skills are handed the worktree as their project root. Merge
951
+ # the repo's project-local FROID surface after explicit seeds (operator intent wins
952
+ # on collisions) and reserve the two renderer sentinels for result-side checks.
953
+ seeded_froid, froid_written = _seed_froid_tree(worktree, repo_root)
954
+ written.update(froid_written)
955
+ skipped = [rel for rel in skipped if rel not in RENDERER_SEED_SENTINELS]
956
+ if _froid_scripts_seed_incomplete(worktree, repo_root):
957
+ skipped.append(FROID_SCRIPTS_SEED_REL)
958
+ if _central_config_seed_incomplete(worktree, repo_root):
959
+ skipped.append(CENTRAL_CONFIG_REL)
960
+
961
+ # bundled skills into each CLI's skill tree (deduped: codex+gemini share one);
962
+ # never clobber a skill the checkout already carries (tracked or pre-existing).
963
+ for tree in dict.fromkeys(p.skill_tree for p in profiles):
964
+ tree_dir = worktree / tree
965
+ for skill in MODULE_SKILLS:
966
+ dst = tree_dir / skill
967
+ # `copied_paths` is read only by the shield: when this tree turns out to
968
+ # be TRACKED, these files are what gets a pattern in place of the dropped
969
+ # dir pattern (#484). Completeness is still re-probed on disk below —
970
+ # copy bookkeeping never arms a gate.
971
+ landed = []
972
+ _copy_traversable(
973
+ skills_root.joinpath(skill),
974
+ dst,
975
+ skip_existing=True,
976
+ worktree=worktree,
977
+ copied_paths=landed,
978
+ )
979
+ written.update(_written_rels(worktree, landed))
980
+ # Wheel MODULE_SKILLS get the same result-side completeness re-probe as every
981
+ # other seeded surface (`module_skills_seed_undelivered`, journaled by the
982
+ # caller as `worktree-module-skills-dropped`) — but journal-only, never the
983
+ # CRITICAL gate below: no MODULE_SKILLS entry has a worktree-resident consumer
984
+ # (sweep triage and froid-loop-resolve dispatch at the MAIN checkout;
985
+ # froid-loop-setup has no session consumer), so a partial wheel copy cannot
986
+ # prove a stall the way a missing upstream dev/review skill does. A future
987
+ # worktree-resident consumer arms the gate via escalate_unit over that
988
+ # predicate's result.
989
+ # The orchestrator-driven upstream skills are not in the wheel; copy them
990
+ # from the MAIN REPO's installed tree (same tree path) so an isolated
991
+ # worktree can still resolve the dev primitive and the review layers. Skip
992
+ # silently when the main repo lacks them — the run-start preflight reports
993
+ # it.
994
+ #
995
+ # BASE_SKILLS names BOTH primitive eras, which is what carries the skill
996
+ # across the rename: the resolution below returns REVIEW skills only, so a
997
+ # primitive the catalog did not name would be silently left behind (the
998
+ # is_dir guard swallows the miss) and every isolated session would stall on
999
+ # an Unknown command.
1000
+ #
1001
+ # BASE_SKILLS is only the floor. The review layers this project actually
1002
+ # invokes are read from the installed primitive — resolved on disk, so a
1003
+ # renamed project resolves its own layers rather than degrading to the
1004
+ # static catalog — exactly as the preflight reads them, so a reviewer named
1005
+ # by a project override (a custom or renamed skill) is provisioned too.
1006
+ # Validating a skill here and then not copying it is how preflight passes in
1007
+ # the main checkout while the isolated review fails on a skill that was
1008
+ # never there.
1009
+ for skill in _worktree_skill_copy_candidates(repo_root, tree):
1010
+ dst = tree_dir / skill
1011
+ try:
1012
+ src = (repo_root / tree / skill).resolve()
1013
+ except (OSError, RuntimeError):
1014
+ continue
1015
+ if not src.is_relative_to(repo_root) or not _is_dir(src):
1016
+ continue
1017
+ landed = []
1018
+ _copy_traversable(
1019
+ src,
1020
+ dst,
1021
+ skip_existing=True,
1022
+ worktree=worktree,
1023
+ repo_root=repo_root,
1024
+ copied_paths=landed,
1025
+ )
1026
+ written.update(_written_rels(worktree, landed))
1027
+
1028
+ # Re-ask the result rather than trusting copy bookkeeping. A user-authored seed
1029
+ # can happen to spell a skill rel, so only this disk predicate may arm the gate.
1030
+ skipped.extend(
1031
+ base_skills_seed_incomplete(
1032
+ worktree, repo_root, [profile.skill_tree for profile in profiles]
1033
+ )
1034
+ )
1035
+
1036
+ # per-CLI signal-hook registration, baked to the main repo's relay (absolute).
1037
+ # Hookless profiles (HTTP/SSE transport) have no config to merge.
1038
+ stripped_paths: set[Path] = set()
1039
+ for profile in profiles:
1040
+ if profile.hookless:
1041
+ continue
1042
+ raw_config_path = worktree / profile.hooks.config_path
1043
+ # Refuse before mkdir, read, or write: hook commands are worktree-specific
1044
+ # and must never mutate a shared dotfile through a live or dangling link.
1045
+ # Inspect every component: a non-strict resolve either leaves a symlink cycle
1046
+ # unresolved — and so textually equal to the raw path — or raises RuntimeError,
1047
+ # which the except below takes; which of the two happens is interpreter-version
1048
+ # dependent. The resolved comparison additionally refuses ``..`` and absolute
1049
+ # profiles.
1050
+ refused = not raw_config_path.is_relative_to(worktree)
1051
+ cursor = raw_config_path
1052
+ try:
1053
+ while not refused and cursor != worktree:
1054
+ if cursor.is_symlink():
1055
+ refused = True
1056
+ break
1057
+ cursor = cursor.parent
1058
+ config_path = raw_config_path.resolve()
1059
+ except (OSError, RuntimeError):
1060
+ continue
1061
+ if refused or config_path != raw_config_path or not config_path.is_relative_to(worktree):
1062
+ continue
1063
+ config_path.parent.mkdir(parents=True, exist_ok=True)
1064
+ config: dict = {}
1065
+ if _is_file(config_path):
1066
+ try:
1067
+ config = json.loads(config_path.read_text(encoding="utf-8"))
1068
+ except (json.JSONDecodeError, UnicodeDecodeError) as e:
1069
+ # Refuse, exactly as `_register_hooks` does at init
1070
+ # (install.py:1244-1248): same file, same merge, one policy (#592).
1071
+ # JSON has no partial read, so a config that will not parse is
1072
+ # evidence of an earlier fault rather than a blank slate — and
1073
+ # swallowing it to `{}` is not a degrade but a destructive write:
1074
+ # `baseline_config` below deep-copies that `{}`, so the change gate
1075
+ # always fires and publishes a hooks-only file over the operator's
1076
+ # allowlist, env and MCP entries, erasing the very evidence.
1077
+ # UnicodeDecodeError rides along because `read_text` raises it on
1078
+ # invalid UTF-8 — the same "operator file unreadable as content"
1079
+ # family, and uncaught it crashes the engine instead of escalating.
1080
+ # Raising mid-loop, after earlier profiles were provisioned, is
1081
+ # safe: the escalation pauses the run, and provisioning re-runs on
1082
+ # resume without duplicating its work (pinned by
1083
+ # test_shield_reprovision_does_not_duplicate_patterns).
1084
+ #
1085
+ # Send the repair to whatever SUPPLIES these bytes, which is never
1086
+ # this file: the worktree is disposable, an escalated story re-enters
1087
+ # the run only through a re-arm, and that discards the worktree
1088
+ # (`engine._finish_inflight` -> `discard_worktree`) and provisions a
1089
+ # fresh one. `seeded_from` answers which source that is POSITIVELY —
1090
+ # keyed on THIS path, and populated only where a copy actually landed.
1091
+ # Both halves are load-bearing. Existence of a main-checkout
1092
+ # counterpart proves nothing: seeding is copy-when-absent, so a config
1093
+ # the project TRACKS is skipped as an occupied destination and arrives
1094
+ # with the branch checkout instead (the call site says so at the
1095
+ # `config_path` seed), and repairing the counterpart would not take —
1096
+ # the fresh worktree checks the committed version out again and the
1097
+ # refusal recurs, so that lane is told to commit. Nor can provenance
1098
+ # come from the seed ENTRY that covers this path: a directory entry is
1099
+ # recorded once, for the parent, while its children are skipped
1100
+ # individually, so a config landing under a seeded `.claude/` and one
1101
+ # merely sitting there beside a seeded sibling are indistinguishable
1102
+ # at that granularity — and misreading the second as seeded advises
1103
+ # committing a gitignored file that may carry credentials.
1104
+ # ESCALATED is terminal with no transition out (`statemachine.py`),
1105
+ # and `resolve` takes a required `run_id` (`cli.py:4232`), so the
1106
+ # remedy names the re-arm in a form that actually runs.
1107
+ seed_rel = profile.hooks.config_path
1108
+ seeded_source = seeded_from.get(config_path)
1109
+ if seeded_source is not None:
1110
+ src_note = (
1111
+ f"this copy was seeded from {seeded_source}, which a "
1112
+ "re-arm seeds in again, so"
1113
+ )
1114
+ remedy = f"repair or remove {seeded_source} in the main checkout"
1115
+ else:
1116
+ src_note = (
1117
+ "nothing seeded this copy — it arrived with the branch "
1118
+ "checkout, which a re-arm checks out again, so"
1119
+ )
1120
+ remedy = f"commit a repaired {seed_rel} on the target branch"
1121
+ raise verify.GitError(
1122
+ f"hook config {config_path} cannot be parsed ({e}); an "
1123
+ "unparseable config is evidence of an earlier fault, not a blank "
1124
+ "slate — provisioning refuses rather than replace the operator's "
1125
+ "allowlist, env, and MCP settings with a hooks-only file. "
1126
+ f"{src_note} {remedy}, then re-arm this escalation with "
1127
+ "`froid-loop resolve <run-id> --no-interactive`: ESCALATED is "
1128
+ "terminal, so repairing the file alone does not put the story "
1129
+ "back in the run (#592)"
1130
+ ) from e
1131
+ host = get_process_host()
1132
+ interp = host.hook_interpreter()
1133
+ registrations = {
1134
+ native: f"{interp} {host.shell_quote(str(relay))} {canonical}"
1135
+ for native, canonical in profile.hooks.events.items()
1136
+ }
1137
+ # A seeded config_path (.claude/settings.json is both a seeded file and the
1138
+ # hook config) arrives carrying the MAIN repo's relay command, which for the
1139
+ # claude dialect is $CLAUDE_PROJECT_DIR-relative and resolves to a path that
1140
+ # does not exist inside the worktree. merge_hooks will not replace an
1141
+ # already-registered relay, so strip it first and let this registration —
1142
+ # baked to the main repo's relay, absolute — be authoritative. Strip only on
1143
+ # FIRST encounter per config file: profiles can share a config_path
1144
+ # (user-overlay aliases of one CLI), and a later profile's pass must not
1145
+ # tear out the relay events an earlier one just registered — merge_hooks
1146
+ # unions its events in, the first registration winning a shared event.
1147
+ baseline_config = copy.deepcopy(config)
1148
+ if config_path not in stripped_paths:
1149
+ strip_relay_hooks(config, profile.hooks.dialect)
1150
+ stripped_paths.add(config_path)
1151
+ config, _ = merge_hooks(config, registrations, profile.hooks.dialect)
1152
+ # Write — and pin — only when the strip+merge actually changed the parsed
1153
+ # config. Non-claude dialects bake the absolute main-repo relay at init
1154
+ # (_hook_command), so a tracked codex/gemini config often arrives already
1155
+ # carrying exactly the command registered here: strip-then-merge nets to
1156
+ # zero, and a pin would claim orchestrator ownership of a file this run
1157
+ # never modified, hiding a story's own edit to it for no benefit.
1158
+ if config != baseline_config:
1159
+ # atomic_write_text, never write_text (#379). `_register_hooks` states
1160
+ # the rule at length; the stakes are higher here. This config is the
1161
+ # SEEDED copy of the operator's own — allowlist, env, MCP entries and
1162
+ # their hooks all round-trip through the parse above — and a truncating
1163
+ # `"w"` publishes a prefix of it on a short write. Nothing downstream
1164
+ # re-reads it to complain, either: the session starts against a settings
1165
+ # file whose JSON no longer parses, so the CLI falls back to its defaults
1166
+ # and the Stop hook never registers. That is #363's stall with no
1167
+ # diagnostic. follow_symlinks stays at the default, matching the
1168
+ # `write_text` it replaces — and the symlink question is already settled
1169
+ # above this line, by the component-wise refusal walk that skips the
1170
+ # profile entirely when any component of the path is a link — a walk
1171
+ # stricter than the confined writers' (it refuses a link anywhere on the
1172
+ # path, in the worktree as well as out), so a confined write would
1173
+ # relax this site rather than harden it. The #597 flag is the whole
1174
+ # change here: `os.replace` needs write permission on the parent
1175
+ # DIRECTORY, never on the entry it replaces, so a seeded config the
1176
+ # operator had marked read-only was rewritten anyway and came back
1177
+ # reading `0444`. This is the operator's own settings file, round-tripped
1178
+ # through the parse above, so a read-only one earns the `PermissionError`
1179
+ # the `write_text` this replaced raised.
1180
+ atomic_write_text(
1181
+ config_path,
1182
+ json.dumps(config, indent=2) + "\n",
1183
+ require_writable_target=True,
1184
+ )
1185
+ pin_degrade = _pin_tracked_config_rewrite(worktree, profile.hooks.config_path)
1186
+ if pin_degrade is not None and on_degraded is not None:
1187
+ on_degraded(pin_degrade)
1188
+
1189
+ # Shield exactly the paths we wrote (skill trees + hook configs + seeded
1190
+ # configs) from the unit's `git add -A`, in case a project doesn't gitignore
1191
+ # its tool dirs. Scoped to this worktree and expiring with it — these are
1192
+ # paths projects legitimately TRACK, so a repo-wide exclude here went on
1193
+ # hiding their new files from the operator's own checkout (#384).
1194
+ #
1195
+ # Built here at ENTRY granularity, which is only provisional: the reconcile step
1196
+ # below re-asks git what each one names, drops a pattern over a tracked file as
1197
+ # inert (#392), and over a tracked DIRECTORY swaps the entry for one pattern per
1198
+ # file `written` recorded under it (#484). `written` is therefore not an
1199
+ # alternative source of patterns but the substitution ledger that step reads.
1200
+ patterns = {f"/{p.skill_tree}" for p in profiles}
1201
+ # hookless profiles have no config_path, so there is nothing to shield: their
1202
+ # empty string would render as a bare "/", which git strips to a zero-length
1203
+ # pattern (the trailing slash becomes MUSTBEDIR) that matches nothing — inert,
1204
+ # unlike "/*" or "*", which do blanket. A junk line in a generated file, then,
1205
+ # not a worktree-wide exclusion.
1206
+ patterns |= {f"/{p.hooks.config_path}" for p in profiles if not p.hookless}
1207
+ patterns |= {f"/{rel}" for rel in seeded}
1208
+ patterns |= {f"/{rel}" for rel in seeded_froid}
1209
+ if f"/{FROID_DIR}" not in patterns:
1210
+ # The renderer may create or rewrite this generated directory during the
1211
+ # session, after provisioning has finished. Give it a dedicated transient
1212
+ # shield unless the blanket root shield already subsumes it: `/_froid` prunes
1213
+ # the directory before git descends, so `/_froid/render/` would provably never
1214
+ # be consulted. Avoiding that inert sibling keeps the worktree-local exclude
1215
+ # precise; the file and its lines disappear with this worktree.
1216
+ patterns.add(f"/{RENDER_DIR_REL}/")
1217
+ patterns, tracked_degrade = _reconcile_tracked_patterns(worktree, patterns, written)
1218
+ if tracked_degrade is not None and on_degraded is not None:
1219
+ on_degraded(tracked_degrade)
1220
+ # Escaping is the LAST transform: `_reconcile_tracked_patterns` strips the leading
1221
+ # "/" and probes git with the LITERAL rel, and the per-file patterns it substitutes
1222
+ # come back RAW too, so it has to keep seeing and emitting unescaped patterns —
1223
+ # escaping must stay downstream of the tracked-pattern transform.
1224
+ reason = _worktree_local_exclude(worktree, sorted(_escape_exclude_pattern(p) for p in patterns))
1225
+ if reason is not None and on_degraded is not None:
1226
+ on_degraded(reason)
1227
+ return skipped
1228
+
1229
+
1230
+ class WorktreeFlow:
1231
+ """Provision, drive, integrate and reclaim per-unit git worktrees.
1232
+
1233
+ Built once per engine from narrow deps + engine callbacks (see module
1234
+ docstring). Behavior is identical to the cluster it was carved out of; the
1235
+ only structural changes are that engine-owned effects go through injected
1236
+ callables: ``emit`` fires a plugin hook (late-bound so a monkeypatched
1237
+ ``Engine._emit`` still wins), ``save`` persists run state, ``gate_unit`` runs
1238
+ the per-unit ready gate, ``carry_isolated_ledger_writes`` applies Engine-owned
1239
+ ledger bookkeeping after a successful merge, ``workspace_get``/``workspace_set``
1240
+ read and swap the engine's active workspace, and ``escalation_pause`` raises the
1241
+ engine's ``RunPaused`` (injected so this module need not import ``engine`` — that
1242
+ would reintroduce a runtime<->engine import cycle)."""
1243
+
1244
+ def __init__(
1245
+ self,
1246
+ *,
1247
+ paths: ProjectPaths,
1248
+ policy: Policy,
1249
+ state: RunState,
1250
+ journal: Journal,
1251
+ run_dir: Path,
1252
+ registry: PluginRegistry,
1253
+ adapters_get: Callable[[], dict[str, CodingCLIAdapter]],
1254
+ open_unit_workspace: Callable[..., UnitWorkspace],
1255
+ emit: Callable[..., object],
1256
+ save: Callable[[], None],
1257
+ gate_unit: Callable[[StoryTask], bool],
1258
+ carry_isolated_ledger_writes: Callable[[StoryTask], None],
1259
+ escalation_pause: Callable[..., NoReturn],
1260
+ workspace_get: Callable[[], Workspace],
1261
+ workspace_set: Callable[[Workspace], None],
1262
+ ) -> None:
1263
+ self.paths = paths
1264
+ self.policy = policy
1265
+ self.state = state
1266
+ self.journal = journal
1267
+ self.run_dir = run_dir
1268
+ self._registry = registry
1269
+ # Read live (a getter, not a captured dict) so a test that rebinds
1270
+ # `engine.adapters` after construction is still seen here.
1271
+ self._adapters_get = adapters_get
1272
+ # Injected late-bound so a test patching the `engine.open_unit_workspace`
1273
+ # module global still wins (worktree_flow's own binding wouldn't).
1274
+ self._open_unit_workspace = open_unit_workspace
1275
+ self._emit = emit
1276
+ self._save = save
1277
+ self._gate_unit = gate_unit
1278
+ self._carry_isolated_ledger_writes = carry_isolated_ledger_writes
1279
+ self._pause = escalation_pause
1280
+ self._workspace_get = workspace_get
1281
+ self._workspace_set = workspace_set
1282
+
1283
+ @property
1284
+ def isolated(self) -> bool:
1285
+ return self.policy.scm.isolation == "worktree"
1286
+
1287
+ def ensure_target_branch(self) -> None:
1288
+ """Resolve (once, at run start) the branch every unit merges back into.
1289
+
1290
+ No-op unless isolation=worktree. Default target is the branch checked out
1291
+ now; a configured target is created if missing and checked out in the
1292
+ main repo (merges land on whatever the main repo has checked out, and a
1293
+ unit worktree must never check out the target itself). Pinned in state so
1294
+ resume keeps targeting the same branch."""
1295
+ if not self.isolated or self.state.target_branch:
1296
+ return
1297
+ if self.policy.scm.failed_diff_unlimited:
1298
+ # the safety cap is off; make sure the operator knows a failed unit
1299
+ # could write a very large forensic patch.
1300
+ self.journal.append(
1301
+ "scm-failed-diff-unlimited",
1302
+ note="failed-unit diff capture is uncapped (scm.failed_diff_unlimited); "
1303
+ "changes.patch may be very large",
1304
+ )
1305
+ repo = self.paths.repo_root
1306
+ configured = self.policy.scm.target_branch.strip()
1307
+ if configured:
1308
+ if not verify.branch_exists(repo, configured):
1309
+ try:
1310
+ verify.create_branch(repo, configured, "HEAD")
1311
+ except verify.GitError as e:
1312
+ # e.g. an unborn repo (no commit to base a branch on).
1313
+ self._pause(f"cannot create target branch {configured!r}: {e}", cause=e)
1314
+ self.journal.append("target-branch-created", branch=configured)
1315
+ if verify.current_branch(repo) != configured:
1316
+ verify.checkout_branch(repo, configured)
1317
+ self.journal.append("target-branch-checkout", branch=configured)
1318
+ self.state.target_branch = configured
1319
+ else:
1320
+ current = verify.current_branch(repo)
1321
+ if current == "HEAD":
1322
+ # detached HEAD has no branch to merge into; merges would land on
1323
+ # an unreferenced commit. Require a real branch (or a configured
1324
+ # target) before isolating work into worktrees.
1325
+ self._pause(
1326
+ "isolation=worktree on a detached HEAD: check out a branch or "
1327
+ "set scm.target_branch before running"
1328
+ )
1329
+ self.state.target_branch = current
1330
+ self.journal.append("target-branch", branch=self.state.target_branch)
1331
+ self._save()
1332
+
1333
+ def worktree_profiles(self) -> list[CLIProfile]:
1334
+ """The distinct CLI profiles of the dev + review adapters, for provisioning
1335
+ their skills/hooks into a worktree. Adapters without a `profile` (e.g. test
1336
+ fakes) contribute nothing, so provisioning is a no-op for them.
1337
+
1338
+ The role set is :data:`install.DEV_PRIMITIVE_ROLES` — the same constant
1339
+ `cli._skill_trees` gates on — rather than a local pair, so the provisioned
1340
+ set and the gated set cannot drift apart. A tree gated but not provisioned
1341
+ ships a session into the `Unknown command` stall the preflight exists to
1342
+ catch; a tree provisioned but not gated refuses runs over a skill no session
1343
+ reads."""
1344
+ seen: dict[str, CLIProfile] = {}
1345
+ adapters = self._adapters_get()
1346
+ for adapter in (adapters[role] for role in DEV_PRIMITIVE_ROLES):
1347
+ profile = getattr(adapter, "profile", None)
1348
+ if profile is not None and profile.name not in seen:
1349
+ seen[profile.name] = profile
1350
+ return list(seen.values())
1351
+
1352
+ def engine_agent_ids(self) -> list[str]:
1353
+ """The Unity-MCP `setup-mcp` agent ids for every CLI that runs in a
1354
+ worktree (dev + review). A worktree can host more than one agent — e.g.
1355
+ dev=claude, review=codex — and each reads its own MCP config file, so the
1356
+ per_worktree setup must point every one of them at the worktree's Editor,
1357
+ not just the dev agent. Deduped, order-preserving; empty for test fakes."""
1358
+ ids: list[str] = []
1359
+ for profile in self.worktree_profiles():
1360
+ agent = _setup_mcp_agent_id(profile.name)
1361
+ if agent not in ids:
1362
+ ids.append(agent)
1363
+ return ids
1364
+
1365
+ def _ledger_seed(self, worktree: Path) -> tuple[str, ...]:
1366
+ """The deferred-work ledger, when a worktree checkout cannot deliver it.
1367
+
1368
+ `git worktree add` checks out TRACKED files only, so a project that
1369
+ gitignores its ledger — the default shape — gets a unit worktree with
1370
+ none. The orchestrator is the ledger's single writer under the generic
1371
+ dev path and writes through ``self.workspace.paths``, i.e. that missing
1372
+ copy: ``mark_done`` returns False on an absent file,
1373
+ ``verify_review_bundle`` reads the same absent file and never sees the
1374
+ ids `done`, so the bundle defers on a fixable retry and `open_ids`
1375
+ re-bundles the same work for ever (#426). No DONE-leg carry can rescue
1376
+ that — the unit never reaches DONE. Seeding moves the failure onto a leg
1377
+ that has one, where ``SweepEngine._carry_isolated_ledger_writes`` applies
1378
+ the close to the main checkout.
1379
+
1380
+ The copy is not what delivers the close: every seeded rel is shielded
1381
+ from the unit's ``git add -A``, so the worktree's flip never rides the
1382
+ merge. The seeded ledger exists so the GATE can read the orchestrator's
1383
+ own write; the carry stays the delivery path, hence
1384
+ ``sweep-bundle-close-carry-uncommitted`` on a run that lands.
1385
+
1386
+ Excluded: a ledger already in the checkout (without the exclusion the copy
1387
+ would be a no-op the seed loop reports as ``worktree-seed-skipped`` on every
1388
+ tracked-ledger project); one absent from the main checkout (dropped
1389
+ silently, so the entry would be invisible rather than merely inert — the
1390
+ common case, since the first harvest is what CREATES the file); and one
1391
+ resolving outside the project tree, which for an out-of-tree artifacts DIR
1392
+ ``ProjectPaths.rebased`` leaves unmoved, so the worktree already reads it.
1393
+ Presence is asked of the WORKTREE, not of git: that is the predicate the
1394
+ seed loop itself decides on, and unlike ``verify.path_tracked`` it costs no
1395
+ subprocess and cannot raise.
1396
+
1397
+ A ledger that is itself a symlink keeps the dir in-tree, so ``rebased``
1398
+ moves it and the worktree path does not exist: the exclusion is still right
1399
+ for an out-of-repo target (``provision_worktree`` refuses that source
1400
+ whatever rel it is handed), but an in-repo target is seeded to the WRONG
1401
+ path and still hits #426 (#462). Resolving also names the target of a
1402
+ TRACKED ledger symlink whose target is untracked — the only path the seed
1403
+ loop will write, since it refuses to copy through a link. ``relative_to``
1404
+ decides PLACEMENT; containment is re-checked against both roots at the copy
1405
+ site.
1406
+
1407
+ Deduped against ``scm.worktree_seed`` by the caller.
1408
+ """
1409
+ ledger = self.paths.deferred_work
1410
+ repo = self.paths.repo_root
1411
+ try:
1412
+ rel = ledger.resolve().relative_to(repo.resolve()).as_posix()
1413
+ except (OSError, RuntimeError, ValueError):
1414
+ return ()
1415
+ if not _is_file(ledger) or _is_file(worktree / rel):
1416
+ return ()
1417
+ return (rel,)
1418
+
1419
+ def _board_seed(self, worktree: Path) -> tuple[str, ...]:
1420
+ """The sprint board, when a worktree checkout cannot deliver it (#350).
1421
+
1422
+ The structural sibling of :meth:`_ledger_seed`, for the other
1423
+ orchestrator-owned artifact in the FROID artifacts dir, and the same shape
1424
+ for the same reason: `git worktree add` checks out TRACKED files only, so a
1425
+ project that gitignores its board gets a unit worktree with none, while the
1426
+ orchestrator writes the board through ``self.workspace.paths`` — i.e. that
1427
+ missing copy.
1428
+
1429
+ What the ledger's absence costs is a livelock; the board's is a CRASH.
1430
+ ``Engine._post_dev_state_sync``'s ``advance`` returns None on a file that is
1431
+ not there (a silent no-op), and ``verify_dev`` then reads the SAME absent
1432
+ file through ``story_status``, where ``sprintstatus.load`` raises
1433
+ ``SprintStatusError`` — a class cli, operatoractions and the TUI all catch
1434
+ and neither engine.py nor verify.py does, so the story dies and takes the
1435
+ run with it. Seeding removes that structurally: the gate reads the
1436
+ orchestrator's own write instead of a hole.
1437
+
1438
+ Per the maintainer decision on #350 the worktree copy is CANONICAL for the
1439
+ duration of the story. As with the ledger, the copy is not what delivers the
1440
+ advance back: every seeded rel is shielded from the unit's ``git add -A``,
1441
+ so the worktree's flip never rides the merge, and a post-merge carry is the
1442
+ delivery path.
1443
+
1444
+ Excluded, arm for arm as the ledger's: a board already in the checkout
1445
+ (tracked, hence delivered — seeding it would copy nothing and journal
1446
+ ``worktree-seed-skipped`` on every isolated unit of every project that
1447
+ tracks its board, which is the common shape for this file); one absent from
1448
+ the main checkout, which the seed loop drops SILENTLY — neither
1449
+ ``worktree-seed-skipped`` nor ``worktree-seed-dropped`` — so naming it would
1450
+ be invisible rather than merely inert (a story run cannot reach here without
1451
+ a board, since ``_pick_next`` reads it first, but a sweep or stories run
1452
+ needs no board at all); and one resolving outside the project tree, which
1453
+ for an out-of-tree artifacts dir ``ProjectPaths.rebased`` leaves unmoved, so
1454
+ the worktree already reads this very file. Presence is asked of the
1455
+ WORKTREE, not of git, for the ledger's reason: it is the predicate the seed
1456
+ loop itself decides on, it costs no subprocess and it cannot raise.
1457
+
1458
+ A board that is itself a symlink inherits ``_ledger_seed``'s caveat verbatim
1459
+ (#462); it is derived there and not repeated here.
1460
+
1461
+ INHERITED LIMITATION — parity, not a regression, and NOT fixed here: a
1462
+ non-fixable rollback does not restore a seeded board. Rollback resets
1463
+ TRACKED paths and removes untracked NON-IGNORED ones
1464
+ (``verify.untracked_files``); an ignored board is in neither set, so nothing
1465
+ puts back the pre-attempt status. An attempt that advanced the worktree
1466
+ board to ``done`` therefore PINS it there — ``advance`` never regresses — and
1467
+ a later attempt that parks instead cannot satisfy ``verify_dev``'s
1468
+ ``awaiting-operator`` expectation. That is exactly what an in-place
1469
+ gitignored board already does under ``isolation = "none"``; only a TRACKED
1470
+ board escapes, via the rollback's ``reset --hard``. Seeding neither
1471
+ introduces the trap nor widens it.
1472
+
1473
+ Deduped against ``scm.worktree_seed`` by the caller.
1474
+ """
1475
+ board = self.paths.sprint_status
1476
+ repo = self.paths.repo_root
1477
+ try:
1478
+ rel = board.resolve().relative_to(repo.resolve()).as_posix()
1479
+ except (OSError, RuntimeError, ValueError):
1480
+ return ()
1481
+ if not _is_file(board) or _is_file(worktree / rel):
1482
+ return ()
1483
+ return (rel,)
1484
+
1485
+ def run_isolated(self, task: StoryTask, drive: Callable[[StoryTask], None]) -> None:
1486
+ """Run one unit's `drive` body in a fresh per-unit worktree, then merge
1487
+ it back into the target branch. `drive` either returns (DONE/DEFERRED →
1488
+ integrate) or raises RunPaused (spec-approval gate / escalation → leave
1489
+ the worktree mounted for resume/inspection, integration skipped)."""
1490
+ try:
1491
+ unit = self._open_unit_workspace(
1492
+ self.paths.repo_root,
1493
+ self.paths,
1494
+ self.state.run_id,
1495
+ task.story_key,
1496
+ self.state.target_branch,
1497
+ self.policy.scm.branch_per,
1498
+ self.run_dir,
1499
+ )
1500
+ except verify.GitSpawnError as e:
1501
+ # a spawn fault is machine-wide, not this unit's: deferring would
1502
+ # march the whole queue into DEFERRED one notification at a time
1503
+ # and end the run "finished" over a broken environment (#194/#343).
1504
+ self._pause(
1505
+ f"cannot spawn git while opening a worktree for {task.story_key}: {e}",
1506
+ task.story_key,
1507
+ cause=e,
1508
+ )
1509
+ except verify.GitError as e:
1510
+ # could not mount a worktree (e.g. branch_per=run with a kept-failed
1511
+ # unit still holding the shared branch). Defer this unit rather than
1512
+ # crash the whole run; the operator can free the branch and re-run.
1513
+ task.defer_reason = f"could not open worktree: {e}"
1514
+ task.phase = Phase.DEFERRED # deliberate: no legal move from PENDING
1515
+ self.journal.append("worktree-open-failed", story_key=task.story_key, error=str(e))
1516
+ gates.notify(
1517
+ self.policy, self.run_dir, f"worktree open failed: {task.story_key}", str(e)
1518
+ )
1519
+ self._save()
1520
+ return
1521
+ task.worktree_path = str(unit.path)
1522
+ self.journal.append(
1523
+ "worktree-opened", story_key=task.story_key, branch=unit.branch, path=str(unit.path)
1524
+ )
1525
+ task.branch = unit.branch
1526
+ # A worktree checks out tracked files only, but the froid-loop-* skill
1527
+ # trees + signal-hook config are typically gitignored, so they are absent
1528
+ # from the fresh checkout. Re-lay them into the worktree so the bundled
1529
+ # froid-loop-* skills are present and the Stop-signal hook fires. Also seed the loaded
1530
+ # adapters' gitignored MCP/CLI configs so isolated sessions can reach their
1531
+ # MCP server (seed_adapter_defaults) plus any extra project-listed paths.
1532
+ profiles = self.worktree_profiles()
1533
+ scm = self.policy.scm
1534
+ seeds: list[str] = []
1535
+ if scm.seed_adapter_defaults:
1536
+ for profile in profiles:
1537
+ seeds.extend(profile.seed_files)
1538
+ # The hook config is seeded from the SAME list that shields it. It was
1539
+ # only ever in the shield set (`provision_worktree`'s `patterns`), never
1540
+ # here, so whether it got seeded depended on a profile happening to name
1541
+ # it twice: claude's `seed_files` carries `.claude/settings.json`, which
1542
+ # is also its `config_path`, and codex's does not carry
1543
+ # `.codex/hooks.json` (#471).
1544
+ #
1545
+ # What seeding fixes, stated only as far as it is measured: the hook
1546
+ # step in `provision_worktree` creates and writes that config whether
1547
+ # or not it was seeded (`merge_hooks` on an absent file returns
1548
+ # changed=True), so froid-loop's OWN Stop hook registers either way.
1549
+ # What an unseeded worktree loses is the PROJECT's hook configuration —
1550
+ # the session runs against a file holding the relay registrations alone.
1551
+ # #471's reported stall is consistent with the CLI declining hooks from
1552
+ # a config it has not trusted (codex.toml's `first_run_note`), but that
1553
+ # mechanism is UNCONFIRMED and nothing here rests on it; the issue's own
1554
+ # stated mechanism — the file being absent — is false at this line.
1555
+ #
1556
+ # Deriving it from `config_path` rather than restating it per profile is
1557
+ # what keeps a future profile from regressing the same way. Hookless
1558
+ # profiles have no config to seed. The seed loop skips an occupied
1559
+ # destination, so a project that TRACKS this path keeps its checked-out
1560
+ # copy untouched.
1561
+ if not profile.hookless and profile.hooks.config_path:
1562
+ seeds.append(profile.hooks.config_path)
1563
+ seeds.extend(scm.worktree_seed)
1564
+ # the two orchestrator-owned artifacts a tracked-only checkout can leave
1565
+ # behind — each decides its own exclusions; see the methods.
1566
+ seeds.extend(self._ledger_seed(unit.path))
1567
+ seeds.extend(self._board_seed(unit.path))
1568
+ # plugins (e.g. the Unity engine) may prime an isolated checkout with
1569
+ # gitignored paths they need — e.g. an MCP-generated skill tree + client
1570
+ # config so the worktree's Editor MCP is reachable. Aggregate every loaded
1571
+ # plugin's declared seeds.
1572
+ seeds.extend(self._registry.seed_files())
1573
+ seed_files = list(dict.fromkeys(seeds)) # dedupe, preserve order
1574
+ seed_globs = self._registry.seed_globs()
1575
+ try:
1576
+ skipped_seeds = provision_worktree(
1577
+ unit.path,
1578
+ profiles,
1579
+ self.paths.repo_root,
1580
+ seed_files=seed_files,
1581
+ seed_globs=seed_globs,
1582
+ on_degraded=lambda msg: self._exclude_degraded(task.story_key, msg),
1583
+ )
1584
+ except verify.GitError as e:
1585
+ # Every provisioning refusal carries its own cause — an unresolvable
1586
+ # root, a config pin that could not be recorded, an unparseable seeded
1587
+ # hook config (#592) — so the wrapper names the unit and defers the
1588
+ # "why" to the inner message rather than asserting one of the three.
1589
+ reason = f"cannot safely provision the worktree for {task.story_key}: {e}"
1590
+ self.escalate_unit(task, reason) # always raises RunPaused
1591
+ if skipped_seeds:
1592
+ # A seed entry whose destination already exists is a no-op. Harmless for
1593
+ # a file the checkout legitimately carries, but a directory entry is
1594
+ # skipped WHOLE the moment any child is tracked — so a `worktree_seed`
1595
+ # that looks applied can be copying nothing. Journal it; provision is
1596
+ # quiet by contract (it runs under the TUI).
1597
+ self.journal.append(
1598
+ "worktree-seed-skipped", story_key=task.story_key, entries=skipped_seeds
1599
+ )
1600
+
1601
+ # A dropped arbitrary seed is observable but not fatal. Unlike the two gates
1602
+ # below, froid-loop cannot know that a user/plugin config is required by the
1603
+ # session, and its usual trigger is a healthy shared/dotfile-managed config.
1604
+ # Hook config destinations cannot prove delivery because provisioning writes
1605
+ # the Stop registration itself after the seed step.
1606
+ undelivered_seeds = worktree_seed_undelivered(
1607
+ unit.path,
1608
+ self.paths.repo_root,
1609
+ seed_files=seed_files,
1610
+ seed_globs=seed_globs,
1611
+ config_paths=[p.hooks.config_path for p in profiles if not p.hookless],
1612
+ )
1613
+ if undelivered_seeds:
1614
+ self.journal.append(
1615
+ "worktree-seed-dropped", story_key=task.story_key, entries=undelivered_seeds
1616
+ )
1617
+
1618
+ trees = [p.skill_tree for p in profiles]
1619
+ # The wheel's own bundled skills, journal-only like worktree-seed-dropped but
1620
+ # under their own kind so a user seed that spells a skill rel can neither forge
1621
+ # nor mask an entry. No MODULE_SKILLS entry has a worktree-resident consumer,
1622
+ # so an absence here cannot prove a stall and must never escalate.
1623
+ undelivered_module_skills = module_skills_seed_undelivered(unit.path, trees)
1624
+ if undelivered_module_skills:
1625
+ self.journal.append(
1626
+ "worktree-module-skills-dropped",
1627
+ story_key=task.story_key,
1628
+ entries=undelivered_module_skills,
1629
+ )
1630
+
1631
+ # Missing upstream skill content is a determinate stall for both inline and
1632
+ # renderer-era primitives. Re-probe disk rather than trusting skipped_seeds,
1633
+ # which a user-authored seed rel could otherwise forge. Check this first so a
1634
+ # wholly absent primitive is not misdiagnosed as a renderer-surface problem.
1635
+ absent_skills = base_skills_seed_incomplete(unit.path, self.paths.repo_root, trees)
1636
+ if absent_skills:
1637
+ reason = (
1638
+ "the worktree is missing required upstream skill contract files the repo has "
1639
+ f"({', '.join(absent_skills)}) — the session would stall having "
1640
+ "written nothing: on `Unknown command` when the whole skill is "
1641
+ "absent, or at a required primitive marker or renderer source. "
1642
+ "The usual cause is a required skill directory or file symlinked "
1643
+ "to a shared Froid install outside the repo, which worktree seeding "
1644
+ "cannot follow"
1645
+ )
1646
+ self.escalate_unit(task, reason) # always raises RunPaused
1647
+
1648
+ # Stories mode has a stricter, content-keyed primitive contract than sprint
1649
+ # mode. Re-run that exact preflight against the mounted worktree: a step-01
1650
+ # through-link can pass in the main checkout yet be refused during copying,
1651
+ # while a tracked stale worktree copy proves that existence alone is not
1652
+ # enough. Either shape would HALT a folder+id dispatch before writing a spec.
1653
+ stories_support = (
1654
+ missing_stories_support(unit.path, trees) if self.state.source == "stories" else []
1655
+ )
1656
+ if stories_support:
1657
+ short_dispatch = []
1658
+ for finding in stories_support:
1659
+ detail = finding.detail or {}
1660
+ rel = f"{detail['tree']}/{detail['skill']}/{detail['file']}"
1661
+ marker = detail.get("marker")
1662
+ short_dispatch.append(f"{rel} (missing {marker!r})" if marker else rel)
1663
+ reason = (
1664
+ "the worktree's dev primitive does not satisfy stories-mode dispatch "
1665
+ f"support ({', '.join(short_dispatch)}) — the folder+id session would "
1666
+ "HALT without writing a spec. The usual cause is a required router "
1667
+ "file symlinked to a shared Froid install outside the repo, which "
1668
+ "worktree seeding cannot follow"
1669
+ )
1670
+ self.escalate_unit(task, reason) # always raises RunPaused
1671
+
1672
+ # Provisioning owns these exact sentinel strings, but only a content-confirmed
1673
+ # renderer stub consumes the surface. The conjunct is load-bearing: inline
1674
+ # pre-#2601 SKILL.md projects may carry the same repo paths and must proceed.
1675
+ short_surface = [rel for rel in RENDERER_SEED_SENTINELS if rel in skipped_seeds]
1676
+ if short_surface and renderer_stub_resolved(self.paths.project, trees):
1677
+ reason = (
1678
+ f"the dev primitive renders via {RENDERER_SCRIPT_MARKER} but the "
1679
+ "worktree's renderer surface came up short of the repo's "
1680
+ f"({', '.join(short_surface)}) — the session would HALT without "
1681
+ "writing a spec. The usual cause is a symlinked _froid/ pointing "
1682
+ "outside the repo, which worktree seeding cannot follow"
1683
+ )
1684
+ self.escalate_unit(task, reason) # always raises RunPaused
1685
+
1686
+ self._save()
1687
+ prev = self._workspace_get()
1688
+ self._workspace_set(unit.workspace)
1689
+ try:
1690
+ # A plugin (e.g. the Unity engine) may launch the unit's managed Editor
1691
+ # at pre_worktree_setup + wait for its MCP at pre_ready_gate before
1692
+ # driving. A veto (defer) at either stage leaves the task DEFERRED and
1693
+ # skips drive(); both fall through to _integrate_unit, which tears the
1694
+ # (empty) worktree down via the DEFERRED path.
1695
+ if self._gate_unit(task):
1696
+ self._emit("post_worktree_setup", task)
1697
+ drive(task)
1698
+ finally:
1699
+ # always run teardown — on success, on a deferral, and on a RunPaused
1700
+ # (spec gate / escalation) propagating through — before the workspace is
1701
+ # restored, so a managed Editor never outlives its worktree. Teardown
1702
+ # stages are observe-only (a veto here cannot un-tear-down).
1703
+ self._emit("pre_worktree_teardown", task)
1704
+ self._emit("post_worktree_teardown", task)
1705
+ self._workspace_set(prev)
1706
+ # reached only on a normal return (DONE or DEFERRED); a RunPaused from the
1707
+ # spec gate or an escalation propagates past here, leaving the worktree up.
1708
+ self.integrate_unit(task, unit)
1709
+
1710
+ def _exclude_degraded(self, story_key: str, msg: str) -> None:
1711
+ """The git-add shield was owed for this unit's worktree and did not happen.
1712
+
1713
+ Journaled AND notified, the way `worktree-open-failed` is above. The notify
1714
+ is the half that was missing: the shield's whole degrade policy is to SKIP
1715
+ rather than widen — activating over patterns it could not copy would shadow
1716
+ the operator's own excludes — and skipping is only defensible if the operator
1717
+ finds out. A run that ends "finished" with a journal line nobody reads is how
1718
+ the provisioned tool files reach a story's merge unnoticed.
1719
+
1720
+ `gates.notify` is best-effort and never raises, and is inert unless
1721
+ `notify.file`/`notify.desktop` is configured, so this cannot break a run —
1722
+ which matters, because it is called from inside provisioning.
1723
+ """
1724
+ self.journal.append("worktree-exclude-degraded", story_key=story_key, error=msg)
1725
+ gates.notify(self.policy, self.run_dir, f"worktree exclude degraded: {story_key}", msg)
1726
+
1727
+ def failed_diff_max_bytes(self) -> int | None:
1728
+ """Per-untracked-file size cap for a failed unit's forensic patch, in
1729
+ bytes — or None when the operator lifted the cap (scm.failed_diff_unlimited)."""
1730
+ scm = self.policy.scm
1731
+ if scm.failed_diff_unlimited:
1732
+ return None
1733
+ return scm.failed_diff_max_mb * 1_048_576
1734
+
1735
+ def integrate_unit(self, task: StoryTask, unit: UnitWorkspace) -> None:
1736
+ self._emit("pre_integrate", task)
1737
+ scm = self.policy.scm
1738
+ # AWAITING_OPERATOR merges beside DONE: a parked story CARRIES A COMMIT
1739
+ # (that is what separates it from DEFERRED/ESCALATED), and stranding that
1740
+ # commit on a torn-down unit branch would lose finished work over an
1741
+ # obligation that lives outside the repo entirely. The human's remaining
1742
+ # actions are recorded in the registry, not in this worktree.
1743
+ if task.phase in (Phase.DONE, Phase.AWAITING_OPERATOR):
1744
+ # Merge the unit branch into the target branch locally. We open PRs
1745
+ # ourselves by hand once the branch has landed; the orchestrator only
1746
+ # commits the worktree onto the selected target.
1747
+ self.merge_local(task, unit)
1748
+ # Engine-authored ledger writes normally ride the unit commit, but a
1749
+ # gitignored ledger is omitted by finalize_commit's `git add -A` and
1750
+ # would otherwise disappear with successful teardown. Carry only after
1751
+ # merge: a tracked ledger reaches the target through the merge first,
1752
+ # where Engine's all-status provenance scan can deduplicate it.
1753
+ self._carry_isolated_ledger_writes(task)
1754
+ # Phase is already terminal and persisted before integration, so make
1755
+ # the carry completion durable here. Engine replays an unlatched carry
1756
+ # after a crash in the merge-to-carry window.
1757
+ task.isolated_ledger_carried = True
1758
+ self._save()
1759
+ else: # DEFERRED — capture the diff, keep or drop per keep_failed
1760
+ patch = close_unit_workspace(
1761
+ unit,
1762
+ success=False,
1763
+ keep_failed=scm.keep_failed,
1764
+ run_dir=self.run_dir,
1765
+ unit_key=task.story_key,
1766
+ delete_branch=scm.delete_branch,
1767
+ detach_kept=scm.branch_per == "run",
1768
+ diff_max_file_bytes=self.failed_diff_max_bytes(),
1769
+ on_teardown_degraded=lambda msg: self.journal.append(
1770
+ "worktree-teardown-degraded", story_key=task.story_key, error=msg
1771
+ ),
1772
+ )
1773
+ self.journal.append(
1774
+ "unit-closed",
1775
+ story_key=task.story_key,
1776
+ branch=unit.branch,
1777
+ kept=scm.keep_failed,
1778
+ patch=str(patch) if patch else None,
1779
+ )
1780
+
1781
+ def _carried_artifact_rels(self, repo: Path) -> tuple[str, ...]:
1782
+ """The repo-relative posix paths the RUN commits for itself after the merge —
1783
+ ``clean_incoming_collisions``' ``protected`` operand (#618).
1784
+
1785
+ The sprint board and the deferred-work ledger, because those are the two
1786
+ files the four post-merge carries name: ``_carry_harvested_deferrals``,
1787
+ ``_carry_review_budget_followups`` and ``_carry_story_deferred_closes`` pass
1788
+ ``paths.deferred_work`` and ``_carry_board_advance`` passes
1789
+ ``paths.sprint_status``, all four to ``verify.commit_paths`` against this same
1790
+ ``repo``. That call stages by PATHSPEC — `git add -- :(literal)<path>` — so
1791
+ whatever the working tree holds at that path is committed no matter who wrote
1792
+ it, and a merge that walked past an operator's edit there hands the run its
1793
+ own bytes to commit under a `chore(...)` message. The blast radius is strictly
1794
+ same-path (`git commit -- <pathspec>` is implicitly `--only`), which is why
1795
+ this is an exact path set and not a policy.
1796
+
1797
+ ``self.paths``, not ``self.workspace.paths``: the carries read the MAIN
1798
+ checkout's copies (their docstrings say so explicitly), and this is the
1799
+ checkout the merge lands in.
1800
+
1801
+ Relativized exactly as ``commit_paths`` relativizes its own operands
1802
+ (``resolve().relative_to(repo.resolve()).as_posix()``) — that is what makes
1803
+ each entry the same string the carry will later hand ``git add``, and the same
1804
+ key shape ``verify.dirty_paths`` returns, so the guard's membership test is an
1805
+ equality it cannot get subtly wrong. Resolving is load-bearing rather than
1806
+ defensive: through a symlinked artifacts dir the unresolved rel names the LINK
1807
+ while both git and ``commit_paths`` name the target.
1808
+
1809
+ A path that cannot be expressed relative to ``repo`` is dropped, not raised
1810
+ on: it cannot be dirty in this checkout, and ``commit_paths`` filters the same
1811
+ ``ValueError`` and so would never commit it either. ``_ledger_seed``'s
1812
+ three-way catch for the same reason — an unresolvable path (the WSL UNC
1813
+ provider fault, a symlink loop) omits only itself.
1814
+
1815
+ TRACKED ONLY, and that is the whole boundary of the hazard rather than a
1816
+ precaution. What makes the carry dangerous is committing a DIVERGENCE from a
1817
+ baseline somebody else authored: on a tracked board, an operator's local
1818
+ reopen of a story row rides out under `chore(sprint-status): carry ...` with
1819
+ the tree left clean and nothing to read it back from. An UNTRACKED artifact
1820
+ has no such baseline — git reports the whole file as dirt because git has
1821
+ never seen it, the orchestrator has been reading that exact file as its own
1822
+ all along, and committing it is how a non-ignored board first reaches git at
1823
+ all (#350's carry). Protecting it would refuse the merge on EVERY isolated run
1824
+ of any project that has yet to commit its board — measured: an untracked,
1825
+ non-ignored board with no operator dirt anywhere ends the run
1826
+ `done=0 paused=True escalated=1` — which is the unattended-halt class #460 and
1827
+ #618 exist to remove, in exchange for a "hazard" that loses nothing (the bytes
1828
+ are committed, not overwritten). A gitignored artifact never reaches the
1829
+ question: ``dirty_paths`` does not report ignored files and ``git add`` refuses
1830
+ an ignored pathspec, so the carry degrades instead of committing.
1831
+
1832
+ A trackedness probe that cannot answer keeps the path, the direction
1833
+ ``path_tracked``'s own callers degrade in: uncertainty must not be what
1834
+ authorizes writing an operator's bytes into the run's commit. The cost of
1835
+ being wrong that way is a refusal the operator can act on; the other way it is
1836
+ silent.
1837
+ """
1838
+ rels: list[str] = []
1839
+ for artifact in (self.paths.sprint_status, self.paths.deferred_work):
1840
+ try:
1841
+ rel = artifact.resolve().relative_to(repo.resolve()).as_posix()
1842
+ except (OSError, RuntimeError, ValueError):
1843
+ continue
1844
+ try:
1845
+ tracked = verify.path_tracked(repo, rel)
1846
+ except verify.GitError:
1847
+ tracked = True
1848
+ if tracked:
1849
+ rels.append(rel)
1850
+ return tuple(rels)
1851
+
1852
+ def merge_local(
1853
+ self,
1854
+ task: StoryTask,
1855
+ unit: UnitWorkspace,
1856
+ *,
1857
+ replay: bool = False,
1858
+ replay_strategy: str | None = None,
1859
+ ) -> None:
1860
+ """Merge a DONE unit's branch into the target branch from the main repo."""
1861
+ if not replay:
1862
+ self._emit("pre_merge", task)
1863
+ scm = self.policy.scm
1864
+ merge_strategy = scm.merge_strategy if replay_strategy is None else replay_strategy
1865
+ repo = self.paths.repo_root
1866
+ target = self.state.target_branch
1867
+ source = task.commit_sha or verify.rev_parse_head(unit.path)
1868
+ merge_ref = unit.branch
1869
+ if replay:
1870
+ current_source = verify.rev_parse_head(unit.path)
1871
+ if current_source != source:
1872
+ reason = (
1873
+ f"merge replay of {unit.branch} into {target} blocked: the unit "
1874
+ f"branch advanced from recorded source {source} to {current_source}; "
1875
+ "the later commits were not produced or verified by the completed "
1876
+ "session and the branch was preserved for manual recovery"
1877
+ )
1878
+ self.keep_branch_and_escalate(task, unit, reason) # always raises RunPaused
1879
+ return
1880
+ # Pin both the collision allowlist and the merge operand to the
1881
+ # write-ahead SHA. The branch can move after the check; replay must
1882
+ # integrate only the commit the completed session actually proved.
1883
+ merge_ref = source
1884
+ # A per_worktree Unity Editor can leak asset writes into the *main*
1885
+ # checkout (see the unity plugin's worktree setup), dirtying the target with the very
1886
+ # files this branch already committed. Reconcile that first: clean only
1887
+ # the leaked copies of incoming files; nothing outside this branch's path set is
1888
+ # ever touched. Outside it two questions decide whether the merge proceeds. What
1889
+ # the MERGE can commit is what git has staged (#618), so an unstaged stray is
1890
+ # inert and is tolerated and journaled while a staged one escalates. What the RUN
1891
+ # can commit is the second question, and `protected` is what asks it: the
1892
+ # post-merge carry stages the board and the ledger BY PATHSPEC, so any dirt on
1893
+ # them — staged or not, whoever wrote it — would ride the run's own bookkeeping
1894
+ # commit. Inert-under-merge and safe-to-proceed are not the same predicate.
1895
+ tolerated: list[str] = []
1896
+
1897
+ def note_tolerated(paths: list[str]) -> None:
1898
+ """Journal the guard's decision AND keep the paths for the arms below.
1899
+
1900
+ The event stays here, before the merge, because it records what the
1901
+ GUARD decided; emitting it only on success would lose the trace in
1902
+ exactly the run worth debugging. But "tolerated" is a claim about the
1903
+ path SET, and a stray outside that set by path can still clash with it
1904
+ structurally — a file where the merge needs a directory, or the reverse
1905
+ — so git may refuse the merge over a path this event just called
1906
+ harmless. Holding the list lets the pre-flight arm correct the record
1907
+ instead of leaving it asserting the run merged past a path that in fact
1908
+ stopped it (#623).
1909
+ """
1910
+ tolerated.extend(paths)
1911
+ self.journal.append(
1912
+ "merge-target-tolerated",
1913
+ story_key=task.story_key,
1914
+ branch=unit.branch,
1915
+ paths=paths,
1916
+ )
1917
+
1918
+ try:
1919
+ cleaned = verify.clean_incoming_collisions(
1920
+ repo,
1921
+ target,
1922
+ merge_ref,
1923
+ protected=self._carried_artifact_rels(repo),
1924
+ on_tolerated=note_tolerated,
1925
+ )
1926
+ except (verify.GitError, OSError, RuntimeError) as e:
1927
+ # OSError/RuntimeError join GitError because clean_incoming_collisions
1928
+ # mutates the checkout directly (resolve/unlink/iterdir/rmdir) — non-spawn
1929
+ # FS faults the #343 chokepoint cannot translate. Crashing here would
1930
+ # strand a DONE unit mid-merge; keep-branch escalation is this boundary.
1931
+ if isinstance(e, (verify.GitSpawnError, OSError, RuntimeError)):
1932
+ # environment fault (spawn failure or direct-FS error) — there may
1933
+ # be no stray files at all, so no "clean them" guidance: the inner
1934
+ # error is the diagnosis.
1935
+ reason = (
1936
+ f"merge of {unit.branch} into {target} blocked: could not "
1937
+ f"reconcile the target checkout ({e}) — fix the underlying "
1938
+ f"fault, then `froid-loop resume {self.state.run_id}`"
1939
+ )
1940
+ else:
1941
+ # The outer sentence names the HAZARD, never the mechanism: since #618
1942
+ # there are two, and only the inner clause knows which applies to which
1943
+ # path. Saying "a merge or squash would fold them" out here attributed
1944
+ # every refusal to the merge, including one raised because the run's own
1945
+ # post-merge carry would sweep a path it commits for itself.
1946
+ reason = (
1947
+ f"merge of {unit.branch} into {target} blocked: the target checkout has "
1948
+ f"uncommitted changes to tracked files outside this branch that this run "
1949
+ f"could commit under the story's name — the clause below names the paths, "
1950
+ f"the mechanism, and what each one needs. Commit, stash or revert them, "
1951
+ f"then `froid-loop resume {self.state.run_id}`. One cause is a "
1952
+ f"per_worktree engine Editor writing into the main checkout; another is "
1953
+ f"ordinary local work. {e}"
1954
+ )
1955
+ self.keep_branch_and_escalate(task, unit, reason) # always raises RunPaused
1956
+ return
1957
+ if cleaned:
1958
+ self.journal.append(
1959
+ "merge-target-cleaned",
1960
+ story_key=task.story_key,
1961
+ branch=unit.branch,
1962
+ paths=cleaned,
1963
+ )
1964
+ if not replay:
1965
+ # The task is already terminal and durable here. Record integration
1966
+ # intent immediately before git so a host loss after merge success but
1967
+ # before `unit-merged` can safely re-run the merge instead of losing a
1968
+ # gitignored ledger when the stale worktree is reclaimed.
1969
+ self.journal.append(
1970
+ "unit-merge-started",
1971
+ story_key=task.story_key,
1972
+ branch=unit.branch,
1973
+ target=target,
1974
+ strategy=merge_strategy,
1975
+ source=source,
1976
+ )
1977
+ try:
1978
+ verify.merge_branch(
1979
+ repo,
1980
+ merge_ref,
1981
+ strategy=merge_strategy,
1982
+ message=self.merge_message(task),
1983
+ allow_empty_squash=replay,
1984
+ )
1985
+ except verify.MergePreflightError as e:
1986
+ # Subclass arm, so it must precede the GitError one below. git declined
1987
+ # before the merge began: nothing was merged and the target checkout is
1988
+ # exactly as it was. Describe that STATE rather than prescribing one
1989
+ # remedy — the same refusal covers an untracked file the merge would
1990
+ # overwrite, a staged change on an incoming path, a file/directory shape
1991
+ # clash, and a target that cannot fast-forward — and let the appended raw
1992
+ # git error name the cause and the paths (#619).
1993
+ reason = (
1994
+ f"merge of {unit.branch} into {target} was refused by git before it "
1995
+ f"started: nothing was merged, the target checkout is unchanged, and "
1996
+ f"there is no conflict to resolve. The target's state clashes with the "
1997
+ f"incoming commit; git's own message below names the cause and the "
1998
+ f"paths. Clear that clash, then `froid-loop resume {self.state.run_id}`. "
1999
+ f"{e}"
2000
+ )
2001
+ if tolerated:
2002
+ # Corrective, not duplicative: only this arm knows the merge died at
2003
+ # git's pre-flight, and only the callback above knows which paths the
2004
+ # guard waved through. One of them may be the cause — git's text names
2005
+ # it — so pair the two rather than making either infer the other. Rides
2006
+ # phase 1's typed error: one discriminator, two consumers (#623).
2007
+ self.journal.append(
2008
+ "merge-preflight-refused",
2009
+ story_key=task.story_key,
2010
+ branch=unit.branch,
2011
+ tolerated=tolerated,
2012
+ error=str(e),
2013
+ )
2014
+ self.keep_branch_and_escalate(task, unit, reason) # always raises RunPaused
2015
+ return # defensive: never fall through to the success teardown below
2016
+ except verify.MergeHalfAppliedError as e:
2017
+ # Sibling of the pre-flight arm above, not a subclass of it, so it is a
2018
+ # separate arm rather than a branch inside one — and like its neighbours it
2019
+ # must precede the bare `GitError` arm. git died PART-WAY through the
2020
+ # checkout: the pre-flight sentence above is false in its load-bearing
2021
+ # clause ("the target checkout is unchanged"), because the incoming files
2022
+ # git had already written are still there, untracked. Naming them is the
2023
+ # whole point — they are what makes the next attempt fail, as an
2024
+ # untracked-overwrite refusal over paths no earlier message mentioned.
2025
+ #
2026
+ # No `merge-preflight-refused` companion here even when `tolerated` is set:
2027
+ # that event is the #623 corrective for a guard that called a path harmless
2028
+ # and then watched git refuse over that same path. This failure is not about
2029
+ # the tolerated paths at all — it is incoming content failing to check out —
2030
+ # so pairing it with the guard's decision would assert a link that is not there.
2031
+ # Two residue axes, two different asks, and the operator needs whichever
2032
+ # ones actually apply — so the middle of this message is composed rather
2033
+ # than picked from a fixed set. An incoming path the target did not track
2034
+ # lands untracked and no restore reaches it, so it is theirs to clear; one
2035
+ # it DID track was modified in place and `merge_branch` has already
2036
+ # restored it path-scoped, unless that restore failed too.
2037
+ # The restore clause LEADS when both apply: it says "before anything
2038
+ # else" and means it — a resume dies on the tracked residue first —
2039
+ # so the untracked clause defers to it ("then") rather than both
2040
+ # claiming first place in one message.
2041
+ # The prescription is path-scoped for the reason the restore itself is:
2042
+ # only the named paths are proven git's, and a repo-wide
2043
+ # `git reset --hard HEAD` would flatten the operator's own uncommitted
2044
+ # work alongside them — the destruction the per-path attribution exists
2045
+ # to prevent.
2046
+ steps: list[str] = []
2047
+ if not e.restored:
2048
+ rewritten = (
2049
+ " ".join(e.rewritten) if e.rewritten else "<the paths git's message names>"
2050
+ )
2051
+ steps.append(
2052
+ f"The tracked files git had already rewritten could NOT be rolled "
2053
+ f"back — that failure is in the message below too — so {target} is "
2054
+ f"still holding incoming content on those paths. Restore exactly "
2055
+ f"those paths (`git checkout HEAD -- {rewritten}` in {target}, "
2056
+ f"never a repo-wide `git reset --hard`, which would flatten your "
2057
+ f"own uncommitted work too) before anything else."
2058
+ )
2059
+ if e.paths:
2060
+ steps.append(
2061
+ f"Some incoming files are left UNTRACKED in the checkout and no "
2062
+ f"restore removes them — not `git merge --abort`, not "
2063
+ f"`git reset --hard`: {', '.join(e.paths)}. "
2064
+ + ("Clear those first, " if e.restored else "Then clear those, ")
2065
+ + "checking the contents before you delete: the run can prove git "
2066
+ "wrote each path, not that the bytes now there are git's."
2067
+ )
2068
+ if e.restored and not e.paths:
2069
+ steps.append(
2070
+ "The tracked files git had already rewritten have been rolled "
2071
+ "back, so the checkout itself needs nothing from you."
2072
+ )
2073
+ reason = (
2074
+ f"merge of {unit.branch} into {target} failed PART-WAY THROUGH: git got "
2075
+ f"far enough to start writing the incoming files into {target}'s "
2076
+ f"checkout before it stopped, so nothing was committed and this is not a "
2077
+ f"clash between the target and the incoming commit. "
2078
+ + " ".join(steps)
2079
+ + f" Whatever residue is left refuses the NEXT attempt over those same "
2080
+ f"paths rather than with the error below, which is why a resume before "
2081
+ f"clearing it fails on the tree instead of on the cause. Then fix what "
2082
+ f"stopped the checkout — git's own message below names it, and a "
2083
+ f"required clean/smudge filter that cannot run is the measured cause — "
2084
+ f"and `froid-loop resume {self.state.run_id}`. {e}"
2085
+ )
2086
+ self.keep_branch_and_escalate(task, unit, reason) # always raises RunPaused
2087
+ return # defensive: never fall through to the success teardown below
2088
+ except verify.MergeResidueUnreadError as e:
2089
+ # The terminal arm's caller-side half, and another sibling that must
2090
+ # precede the bare `GitError` arm. Neither neighbour's sentence can be
2091
+ # borrowed: the pre-flight arm's "the target checkout is unchanged" is
2092
+ # exactly the claim the dead probe can no longer back, and the
2093
+ # half-applied arm names residue this run never read. Say what IS known
2094
+ # — the merge failed, git's text below names why — and send the operator
2095
+ # to the one reading the run could not take, which their own `git
2096
+ # status` still can.
2097
+ reason = (
2098
+ f"merge of {unit.branch} into {target} failed, AND the after-the-fact "
2099
+ f"probe that verifies the checkout failed too, so whether {target}'s "
2100
+ f"checkout still holds incoming residue is UNVERIFIED. Run `git "
2101
+ f"status` in {target}: clear any residue it names that is not yours "
2102
+ f"(files from {unit.branch} left untracked or rewritten), fix what "
2103
+ f"stopped the merge — git's message below names it — then "
2104
+ f"`froid-loop resume {self.state.run_id}`. {e}"
2105
+ )
2106
+ self.keep_branch_and_escalate(task, unit, reason) # always raises RunPaused
2107
+ return # defensive: never fall through to the success teardown below
2108
+ except verify.MergeCommitRefusedError as e:
2109
+ # Sibling of the arm above and equally a GitError subclass, so it too must
2110
+ # precede the last arm. Neither neighbour's remedy applies here: the merge
2111
+ # RAN and resolved, so there is no target-state clash to clear, and it
2112
+ # resolved cleanly, so there is no conflict to resolve either. `merge_branch`
2113
+ # rolled it back — `merge --abort` on the `--no-ff` leg, `reset --hard` on
2114
+ # the squash leg's own commit — so the checkout is back where it started and
2115
+ # the operator is sent to the policy that declined the commit (#619). The
2116
+ # unrestored wording branches on WHERE the checkout stands, because the two
2117
+ # legs strand differently and the first step differs with them.
2118
+ if e.restored:
2119
+ reason = (
2120
+ f"merge of {unit.branch} into {target} resolved cleanly, but git "
2121
+ f"refused to COMMIT it, so the merge was rolled back and the target "
2122
+ f"checkout is back as it was. There is no conflict to resolve and "
2123
+ f"nothing to clear from the tree: a `pre-merge-commit` or "
2124
+ f"`commit-msg` hook, or commit signing, declined it — git's own "
2125
+ f"message below names which. Fix that, then "
2126
+ f"`froid-loop resume {self.state.run_id}`. {e}"
2127
+ )
2128
+ elif e.staged:
2129
+ # The squash leg's strand: no MERGE_HEAD exists, so "recover the
2130
+ # merge" would be fiction — the squash result is sitting STAGED,
2131
+ # either because the rollback failed or because the checkout also
2132
+ # carries the operator's own uncommitted work, which the rollback
2133
+ # refuses to flatten. The exception's text says which.
2134
+ reason = (
2135
+ f"merge of {unit.branch} into {target} resolved cleanly, git "
2136
+ f"refused to COMMIT it, and the squash result is left STAGED in "
2137
+ f"{target}'s checkout — the message below says why it was not "
2138
+ f"rolled back for you. Stash or commit anything in {target} that "
2139
+ f"is yours, then clear the staged result "
2140
+ f"(`git reset --hard HEAD` in {target}). Only then fix whatever "
2141
+ f"declined the commit: a `pre-merge-commit` or `commit-msg` hook, "
2142
+ f"or commit signing. Then `froid-loop resume {self.state.run_id}`. "
2143
+ f"{e}"
2144
+ )
2145
+ else:
2146
+ # The abort failed too, so the restored sentence would be a lie about
2147
+ # the one thing the operator has to act on FIRST: a resume attempted
2148
+ # over a mid-merge checkout dies on the merge state, not on the
2149
+ # policy, and keeps doing so however well they fix the hook.
2150
+ reason = (
2151
+ f"merge of {unit.branch} into {target} resolved cleanly, git refused "
2152
+ f"to COMMIT it, and the abort meant to undo that failed as well — so "
2153
+ f"the target checkout is left MID-MERGE and has to be recovered "
2154
+ f"first (`git merge --abort`, or reset it to the pre-merge commit). "
2155
+ f"Only then fix whatever declined the commit: a `pre-merge-commit` "
2156
+ f"or `commit-msg` hook, or commit signing. git's own message below "
2157
+ f"names both failures. Then `froid-loop resume {self.state.run_id}`. "
2158
+ f"{e}"
2159
+ )
2160
+ self.keep_branch_and_escalate(task, unit, reason) # always raises RunPaused
2161
+ return # defensive: never fall through to the success teardown below
2162
+ except verify.MergeConflictError as e:
2163
+ # genuine content conflict against the target, measured (unmerged index
2164
+ # stages): keep the branch for manual merge. The unit committed cleanly
2165
+ # (phase is already DONE, which has no legal transition), so escalate
2166
+ # directly.
2167
+ reason = (
2168
+ f"merge of {unit.branch} into {target} failed "
2169
+ f"(content conflict against the target): resolve it by hand, then "
2170
+ f"`froid-loop resume {self.state.run_id}`. {e}"
2171
+ )
2172
+ self.keep_branch_and_escalate(task, unit, reason) # always raises RunPaused
2173
+ return # defensive: never fall through to the success teardown below
2174
+ except verify.GitError as e:
2175
+ # Every state the classification MEASURED has a typed arm above, so a
2176
+ # bare GitError is a state nothing measured. This arm used to claim the
2177
+ # most specific diagnosis — "content conflict, resolve it by hand" — for
2178
+ # exactly the failures it knew least about, which is how six mislabeled
2179
+ # git states in a row reached the operator wearing a fictional remedy
2180
+ # (#619). It now claims only what it knows: the merge failed, the run
2181
+ # cannot say what state the checkout is in, and git's text names the
2182
+ # cause. An unforeseen shape lands here as a vague-but-true message
2183
+ # rather than a precise fiction.
2184
+ reason = (
2185
+ f"merge of {unit.branch} into {target} failed, and the failure was "
2186
+ f"not classified: the run cannot say what state {target}'s checkout "
2187
+ f"is in. Run `git status` in {target} and put the checkout right — "
2188
+ f"git's own message below names the cause — then "
2189
+ f"`froid-loop resume {self.state.run_id}`. {e}"
2190
+ )
2191
+ self.keep_branch_and_escalate(task, unit, reason) # always raises RunPaused
2192
+ return # defensive: never fall through to the success teardown below
2193
+ self.journal.append(
2194
+ "unit-merged",
2195
+ story_key=task.story_key,
2196
+ branch=unit.branch,
2197
+ target=self.state.target_branch,
2198
+ strategy=merge_strategy,
2199
+ source=source,
2200
+ )
2201
+ self._emit("post_merge", task)
2202
+ close_unit_workspace(
2203
+ unit,
2204
+ success=True,
2205
+ keep_failed=scm.keep_failed,
2206
+ run_dir=self.run_dir,
2207
+ unit_key=task.story_key,
2208
+ delete_branch=scm.delete_branch,
2209
+ on_teardown_degraded=lambda msg: self.journal.append(
2210
+ "worktree-teardown-degraded", story_key=task.story_key, error=msg
2211
+ ),
2212
+ )
2213
+
2214
+ def keep_branch_and_escalate(self, task: StoryTask, unit: UnitWorkspace, reason: str) -> None:
2215
+ """Preserve a DONE unit's branch (no delete, kept for manual merge) and
2216
+ escalate. Shared by every merge-back failure path: a target dirtied with
2217
+ stray work, a merge git refused at pre-flight, a merge that died part-way
2218
+ through its checkout, a merge whose COMMIT git refused, a genuine content
2219
+ conflict, and a failure nothing classified."""
2220
+ close_unit_workspace(
2221
+ unit,
2222
+ success=False,
2223
+ keep_failed=True,
2224
+ run_dir=self.run_dir,
2225
+ unit_key=task.story_key,
2226
+ delete_branch=False,
2227
+ diff_max_file_bytes=self.failed_diff_max_bytes(),
2228
+ )
2229
+ self.escalate_unit(task, reason) # always raises RunPaused
2230
+
2231
+ def escalate_unit(self, task: StoryTask, reason: str) -> None:
2232
+ """Mark a unit ESCALATED, notify, and pause the run.
2233
+
2234
+ Callers escalate outside a legal transition, before dispatch or after a
2235
+ completed merge attempt, so the phase is set directly rather than advanced.
2236
+ """
2237
+ task.phase = Phase.ESCALATED
2238
+ self.journal.append("story-escalated", story_key=task.story_key, reason=reason)
2239
+ gates.notify(
2240
+ self.policy,
2241
+ self.run_dir,
2242
+ f"CRITICAL escalation: {task.story_key}",
2243
+ f"{reason} — resolve, then `froid-loop resume {self.state.run_id}`",
2244
+ )
2245
+ self._save()
2246
+ self._pause(reason, task.story_key)
2247
+
2248
+ def merge_message(self, task: StoryTask) -> str:
2249
+ return f"Merge {task.branch} into {self.state.target_branch} (froid-loop)"
2250
+
2251
+ def gc_run_worktrees(self) -> None:
2252
+ """Reclaim this run's worktree scaffolding once it finishes cleanly.
2253
+
2254
+ DONE units drop their worktree at merge time; this is a safety net for a
2255
+ worktree leaked by a crash between merge and teardown, plus it prunes
2256
+ stale git admin entries and removes the now-empty run worktree dir.
2257
+ Worktrees deliberately kept for inspection (a kept-failed/escalated unit)
2258
+ are left in place and journaled so the operator can find them."""
2259
+ if not self.isolated:
2260
+ return
2261
+ repo = self.paths.repo_root
2262
+ for task in self.state.tasks.values():
2263
+ if task.phase == Phase.DONE and task.worktree_path:
2264
+ wt = Path(task.worktree_path)
2265
+ if wt.is_dir():
2266
+ discard_worktree(repo, task.worktree_path, task.branch, run_dir=self.run_dir)
2267
+ elif task.terminal and task.worktree_path and Path(task.worktree_path).is_dir():
2268
+ # kept on purpose (keep_failed): leave it, but surface where.
2269
+ self.journal.append(
2270
+ "worktree-kept", story_key=task.story_key, path=task.worktree_path
2271
+ )
2272
+ verify.worktree_prune(repo)
2273
+ worktrees_parent = unit_worktrees_dir(self.run_dir)
2274
+ if worktrees_parent.is_dir() and not any(worktrees_parent.iterdir()):
2275
+ worktrees_parent.rmdir()
2276
+
2277
+ def reopen_unit(self, task: StoryTask) -> UnitWorkspace:
2278
+ """Reconstruct the UnitWorkspace for an in-flight unit on resume, from
2279
+ the worktree path + branch persisted on the task. The worktree must still
2280
+ be mounted — if it was pruned out from under us we cannot safely reuse it,
2281
+ so escalate rather than run a session in a missing directory."""
2282
+ wt = Path(task.worktree_path)
2283
+ if not wt.is_dir():
2284
+ self.escalate_unit(
2285
+ task,
2286
+ f"worktree for {task.story_key} is gone ({wt}); cannot resume in place",
2287
+ )
2288
+ # Spec paths are persisted relative to the worktree (model.to_dict) so
2289
+ # state stays portable; re-absolutize both accepted/result ownership and
2290
+ # the current/last attempt's dispatch ownership against the reopened tree.
2291
+ # Absolute outside-worktree paths pass through unchanged. The rule itself
2292
+ # lives on the class that creates the relative spelling, so this and
2293
+ # `Engine._finish_inflight`'s pre-discard re-anchor cannot drift apart.
2294
+ task.rebase_spec_paths_on(wt)
2295
+ return UnitWorkspace(
2296
+ workspace=Workspace(root=wt, paths=self.paths.rebased(wt)),
2297
+ repo_root=self.paths.repo_root,
2298
+ branch=task.branch,
2299
+ path=wt,
2300
+ baseline=task.baseline_commit or "",
2301
+ )