froid-loop 0.11.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. froid_loop/__init__.py +11 -0
  2. froid_loop/__main__.py +12 -0
  3. froid_loop/adapters/__init__.py +3 -0
  4. froid_loop/adapters/base.py +254 -0
  5. froid_loop/adapters/entrypoints.py +63 -0
  6. froid_loop/adapters/env_fault.py +290 -0
  7. froid_loop/adapters/generic.py +2013 -0
  8. froid_loop/adapters/mock.py +49 -0
  9. froid_loop/adapters/multiplexer.py +914 -0
  10. froid_loop/adapters/opencode_http.py +1687 -0
  11. froid_loop/adapters/profile.py +650 -0
  12. froid_loop/adapters/psmux_backend.py +1428 -0
  13. froid_loop/adapters/registry.py +322 -0
  14. froid_loop/adapters/tmux_backend.py +35 -0
  15. froid_loop/adapters/tmux_base.py +630 -0
  16. froid_loop/checks.py +187 -0
  17. froid_loop/cli.py +5041 -0
  18. froid_loop/data/__init__.py +0 -0
  19. froid_loop/data/froid_loop_hook.py +228 -0
  20. froid_loop/data/froid_loop_probe_hook.py +88 -0
  21. froid_loop/data/plugins/example/plugin.toml +21 -0
  22. froid_loop/data/plugins/tea/plugin.toml +184 -0
  23. froid_loop/data/plugins/tea/tea_plugin.py +258 -0
  24. froid_loop/data/plugins/unity/plugin.toml +140 -0
  25. froid_loop/data/plugins/unity/unity_assets/FroidLoop.Unity.Editor.asmdef +16 -0
  26. froid_loop/data/plugins/unity/unity_assets/FroidLoop.Unity.Editor.asmdef.meta +7 -0
  27. froid_loop/data/plugins/unity/unity_assets/SceneAutoSaveGuard.cs +221 -0
  28. froid_loop/data/plugins/unity/unity_assets/SceneAutoSaveGuard.cs.meta +11 -0
  29. froid_loop/data/plugins/unity/unity_assets/_folders/Editor.meta +8 -0
  30. froid_loop/data/plugins/unity/unity_assets/_folders/FroidLoop.meta +8 -0
  31. froid_loop/data/plugins/unity/unity_cleanup.py +125 -0
  32. froid_loop/data/plugins/unity/unity_dialog_probe.py +239 -0
  33. froid_loop/data/plugins/unity/unity_facts.md +17 -0
  34. froid_loop/data/plugins/unity/unity_plugin.py +415 -0
  35. froid_loop/data/plugins/unity/unity_quiesce.py +234 -0
  36. froid_loop/data/plugins/unity/unity_ready.py +230 -0
  37. froid_loop/data/plugins/unity/unity_seed_assets.py +298 -0
  38. froid_loop/data/plugins/unity/unity_setup.py +551 -0
  39. froid_loop/data/plugins/unity/unity_teardown.py +362 -0
  40. froid_loop/data/profiles/antigravity.toml +52 -0
  41. froid_loop/data/profiles/claude.toml +85 -0
  42. froid_loop/data/profiles/codex.toml +22 -0
  43. froid_loop/data/profiles/copilot.toml +52 -0
  44. froid_loop/data/profiles/gemini.toml +26 -0
  45. froid_loop/data/profiles/opencode.toml +54 -0
  46. froid_loop/data/settings/core.toml +458 -0
  47. froid_loop/data/skills/README.md +93 -0
  48. froid_loop/data/skills/froid-loop-resolve/SKILL.md +288 -0
  49. froid_loop/data/skills/froid-loop-setup/SKILL.md +161 -0
  50. froid_loop/data/skills/froid-loop-setup/assets/module-help.csv +3 -0
  51. froid_loop/data/skills/froid-loop-setup/assets/module.yaml +19 -0
  52. froid_loop/data/skills/froid-loop-sweep/SKILL.md +100 -0
  53. froid_loop/data/skills/froid-loop-sweep/automation-mode.md +127 -0
  54. froid_loop/data/skills/froid-loop-sweep/deferred-work-format.md +302 -0
  55. froid_loop/data/skills/froid-loop-sweep/migration-mode.md +86 -0
  56. froid_loop/decisions.py +202 -0
  57. froid_loop/deferredwork.py +2282 -0
  58. froid_loop/devcontract.py +892 -0
  59. froid_loop/diagnostics.py +1104 -0
  60. froid_loop/documents.py +532 -0
  61. froid_loop/engine.py +7732 -0
  62. froid_loop/envvars.py +111 -0
  63. froid_loop/escalation.py +225 -0
  64. froid_loop/events.py +266 -0
  65. froid_loop/fences.py +103 -0
  66. froid_loop/froidconfig.py +226 -0
  67. froid_loop/frontmatter.py +526 -0
  68. froid_loop/gates.py +133 -0
  69. froid_loop/install.py +2936 -0
  70. froid_loop/journal.py +178 -0
  71. froid_loop/machine.py +148 -0
  72. froid_loop/model.py +898 -0
  73. froid_loop/operatoractions.py +474 -0
  74. froid_loop/platform_util.py +1490 -0
  75. froid_loop/plugins/__init__.py +64 -0
  76. froid_loop/plugins/bus.py +259 -0
  77. froid_loop/plugins/context.py +319 -0
  78. froid_loop/plugins/loader.py +145 -0
  79. froid_loop/plugins/manifest.py +279 -0
  80. froid_loop/plugins/model.py +296 -0
  81. froid_loop/plugins/registry.py +245 -0
  82. froid_loop/plugins/trust.py +75 -0
  83. froid_loop/policy.py +1569 -0
  84. froid_loop/probe.py +1044 -0
  85. froid_loop/process_host.py +408 -0
  86. froid_loop/recovery_flow.py +1561 -0
  87. froid_loop/resolve.py +283 -0
  88. froid_loop/runs.py +4715 -0
  89. froid_loop/runsetup.py +1293 -0
  90. froid_loop/sanitize.py +593 -0
  91. froid_loop/settings_schema.py +276 -0
  92. froid_loop/signals.py +160 -0
  93. froid_loop/sprintstatus.py +609 -0
  94. froid_loop/statemachine.py +57 -0
  95. froid_loop/stories.py +615 -0
  96. froid_loop/stories_engine.py +796 -0
  97. froid_loop/sweep.py +1892 -0
  98. froid_loop/tokens.py +196 -0
  99. froid_loop/tui/__init__.py +11 -0
  100. froid_loop/tui/app.py +1584 -0
  101. froid_loop/tui/data.py +840 -0
  102. froid_loop/tui/launch.py +1003 -0
  103. froid_loop/tui/screens/__init__.py +1 -0
  104. froid_loop/tui/screens/dashboard.py +1071 -0
  105. froid_loop/tui/screens/modals.py +943 -0
  106. froid_loop/tui/screens/settings_screen.py +477 -0
  107. froid_loop/tui/settings.py +135 -0
  108. froid_loop/tui/widgets.py +981 -0
  109. froid_loop/verify.py +4545 -0
  110. froid_loop/workspace.py +320 -0
  111. froid_loop/worktree_flow.py +2301 -0
  112. froid_loop-0.11.1.dist-info/METADATA +728 -0
  113. froid_loop-0.11.1.dist-info/RECORD +116 -0
  114. froid_loop-0.11.1.dist-info/WHEEL +4 -0
  115. froid_loop-0.11.1.dist-info/entry_points.txt +2 -0
  116. froid_loop-0.11.1.dist-info/licenses/LICENSE +30 -0
froid_loop/install.py ADDED
@@ -0,0 +1,2936 @@
1
+ """`froid-loop init`: make a target project orchestratable.
2
+
3
+ - copies the hook relay script to <project>/.froid-loop/froid_loop_hook.py
4
+ - idempotently merges hook registrations into each selected CLI's hook config
5
+ (dialect + native->canonical event map come from the CLI profile)
6
+ - installs the bundled froid-loop-* skills into each selected CLI's skill tree
7
+ (.claude/skills for claude, .agents/skills for codex/gemini/copilot)
8
+ - writes .froid-loop/policy.toml from the template (if missing)
9
+ - gitignores generated dirs: .froid-loop/runs/ (per-run state) and
10
+ .froid-loop/cache/ (engine plugins' rebuildable caches, e.g. the Unity Library)
11
+
12
+ Every dialect registers the same relay script under the CLI's native event
13
+ names while passing the canonical event name as the script argument, so the
14
+ orchestrator's signal watcher is CLI-agnostic.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import errno
20
+ import json
21
+ import os
22
+ import re
23
+ import shutil
24
+ import tomllib
25
+ from collections.abc import Iterable, Iterator, Sequence
26
+ from contextlib import ExitStack
27
+ from importlib import resources
28
+ from importlib.resources.abc import Traversable
29
+ from pathlib import Path
30
+ from typing import Any, NamedTuple
31
+
32
+ from .adapters.profile import ALIASES, CLIProfile, ProfileError, load_profiles
33
+ from .checks import Finding
34
+ from .platform_util import atomic_write_bytes, atomic_write_text, file_lock
35
+ from .policy import POLICY_TEMPLATE
36
+ from .process_host import get_process_host
37
+ from .verify import GitError, git_below_floor, git_bytes, git_floor_text, git_version_at_least
38
+
39
+ HOOK_SCRIPT_REL = ".froid-loop/froid_loop_hook.py"
40
+ # Markers for froid-loop-managed hook commands. RELAY_MARKER is shared by
41
+ # merge_hooks' dedup and validate/probe detection (via relay_registered) so init
42
+ # and the preflight can never disagree about whether the relay is installed. It
43
+ # matches the relay script name specifically: a hook command whose path merely
44
+ # contains "froid_loop" can't read as a registration — or suppress one.
45
+ RELAY_MARKER = "froid_loop_hook"
46
+ # The probe-adapter capture hook participates in merge_hooks' dedup only (a
47
+ # probe re-merge must stay idempotent) and never counts as a relay
48
+ # registration. Disjoint from RELAY_MARKER: "froid_loop_probe_hook" does not
49
+ # contain the substring "froid_loop_hook".
50
+ PROBE_MARKER = "froid_loop_probe_hook"
51
+ GEMINI_HOOK_TIMEOUT_MS = 60_000
52
+ COPILOT_HOOK_TIMEOUT_SEC = 60
53
+ ANTIGRAVITY_HOOK_TIMEOUT_SEC = 60 # agy hook timeouts are seconds (default 30)
54
+ # agy's .agents/hooks.json keys by hook NAME at the top level (not a "hooks"
55
+ # wrapper); froid-loop registers all its handlers under this single group.
56
+ ANTIGRAVITY_HOOK_GROUP = "froid-loop"
57
+
58
+ # The froid-loop-* skills bundled in the wheel (froid_loop/data/skills/) that
59
+ # `froid-loop init` lays down. The inner dev primitive (`froid-build-auto`, formerly
60
+ # `froid-dev-auto`) is upstream (not bundled here): the orchestrator drives it as an
61
+ # already-installed skill.
62
+ MODULE_SKILLS = (
63
+ "froid-loop-resolve",
64
+ "froid-loop-sweep",
65
+ "froid-loop-setup",
66
+ )
67
+
68
+ # The inner dev primitive, in both upstream eras. Froid Plane PR #2651 (shipped in
69
+ # froid-plane 6.10.1-next.33) renamed `froid-dev-auto` -> `froid-build-auto` and left a
70
+ # forwarding SHIM behind under the old name: a lone SKILL.md whose customization
71
+ # migration gate is INTERACTIVE, so an unattended session that dispatches to it can
72
+ # HALT having written nothing to disk — no spec, no result artifact, nothing the
73
+ # post-session verification can read. The orchestrator therefore never accepts the
74
+ # shim: it resolves the primitive on disk (:func:`resolve_dev_primitive`) and fails
75
+ # the preflight when only the shim is installed.
76
+ #
77
+ # The shim carries no step files and no customize.toml, so DEV_PRIMITIVE_MARKERS —
78
+ # which already pinned "a real, complete install" — doubles as the shim detector.
79
+ # Markers pin BOTH a step file (catches a truncated copy) AND customize.toml, the
80
+ # layer/handoff config step-04 resolves review_layers from (Froid Plane
81
+ # #2535/#2550): a pre-July bmm install predating it would let every dev run's
82
+ # step-04 fail.
83
+ DEV_PRIMITIVE_NEW = "froid-build-auto"
84
+ DEV_PRIMITIVE_LEGACY = "froid-dev-auto"
85
+ DEV_PRIMITIVE_MARKERS = ("step-04-review.md", "customize.toml")
86
+
87
+ # The adapter roles whose skill tree is asked for the dev primitive and for the
88
+ # review layers that primitive invokes inline — i.e. every question this module's
89
+ # skill checks ask. Triage is deliberately absent: its only prompt is
90
+ # `/froid-loop-sweep`, which is in MODULE_SKILLS and is laid into that tree by
91
+ # `froid-loop init`, so a triage-only CLI never needs one byte of the bmm module.
92
+ # Gating it anyway makes `[adapter.triage] name = "gemini"` under a claude dev/review
93
+ # pair demand the whole module in `.agents/skills` — a hard preflight FAIL over a
94
+ # tree no session ever dispatches one of these skills into.
95
+ #
96
+ # This must stay the same set `WorktreeFlow.worktree_profiles` provisions, which
97
+ # reads this constant for exactly that reason. A tree gated here that no worktree
98
+ # carries refuses runs over a skill no session will ever read; a tree provisioned but
99
+ # not gated ships a session into the `Unknown command` stall the preflight exists to
100
+ # catch. Neither has a defensible reading, so the two move together or not at all.
101
+ DEV_PRIMITIVE_ROLES: tuple[str, ...] = ("dev", "review")
102
+
103
+ # Where project-level customization of an upstream skill lives. This main-native
104
+ # Path remains the single source; renderer strings derive their `_froid` spelling
105
+ # from it so the install and validation surfaces cannot drift.
106
+ CUSTOMIZE_DIR = Path("_froid") / "custom"
107
+
108
+ # FROID's project-local configuration and tooling root. Renderer paths derive from
109
+ # this one spelling so the validate-side checks in this phase and the worktree seed
110
+ # added later cannot drift.
111
+ FROID_DIR = CUSTOMIZE_DIR.parent.as_posix()
112
+
113
+ # Since Froid Plane PR #2601 a skill's SKILL.md can be a renderer stub that shells
114
+ # out to this project-local script. Its absence makes the session HALT before the
115
+ # dev workflow can write a spec or result, so a content-confirmed stub makes this a
116
+ # preflight problem rather than a best-effort advisory.
117
+ RENDERER_SCRIPT_REL = f"{FROID_DIR}/scripts/render_skill.py"
118
+ RENDERER_SCRIPT_MARKER = "render_skill.py"
119
+
120
+ # The renderer currently imports this sibling helper at module scope. Require it
121
+ # only when the installed script still names that import surface; a false positive
122
+ # would refuse every run and has no --force escape hatch.
123
+ RENDERER_CONFIG_UTILS_REL = f"{FROID_DIR}/scripts/config_utils.py"
124
+ RENDERER_CONFIG_UTILS_MARKER = "config_utils"
125
+ RENDERER_SCRIPT_UNIT_REL: tuple[str, ...] = (
126
+ RENDERER_SCRIPT_REL,
127
+ RENDERER_CONFIG_UTILS_REL,
128
+ )
129
+
130
+ # The renderer's only required project-global config layer. This is deliberately
131
+ # not `_froid/bmm/config.yaml`, which configures froid-loop's artifact paths.
132
+ CENTRAL_CONFIG_REL = f"{FROID_DIR}/config.toml"
133
+
134
+ # The renderer surface a worktree must carry, in the exact strings provisioning
135
+ # reports when it comes up short. Worktree seeding reports these two from dedicated
136
+ # completeness checks, and escalates only when a renderer stub is what resolved.
137
+ FROID_SCRIPTS_SEED_REL = f"{FROID_DIR}/scripts"
138
+ RENDERER_SEED_SENTINELS: tuple[str, ...] = (FROID_SCRIPTS_SEED_REL, CENTRAL_CONFIG_REL)
139
+
140
+ # The skill-relative render entry plus snapshot-reference grammar. The regex is a
141
+ # byte-for-byte mirror of Froid Plane main's render_skill.py `_SNAPSHOT_TOKEN` at
142
+ # 57e70562e3776b47bce1a54710f1edb1f0bd3618. Loosening it invents failures the
143
+ # renderer ignores; tightening it misses a real HALT.
144
+ RENDERER_ENTRY_REL = "workflow.md"
145
+ SNAPSHOT_TOKEN_RE = re.compile(r"\[\[froid-snapshot:([A-Za-z0-9_./-]+\.md)\]\]")
146
+
147
+ # Renderer output is regenerated with checkout-absolute paths and must never be
148
+ # copied into a worktree or committed. FROID_SEED_EXCLUDES skips it while seeding
149
+ # `_froid/`; RENDER_DIR_REL is the one spelling init gitignores and validate checks.
150
+ RENDER_DIR_NAME = "render"
151
+ FROID_SEED_EXCLUDES = (RENDER_DIR_NAME,)
152
+ RENDER_DIR_REL = f"{FROID_DIR}/{RENDER_DIR_NAME}"
153
+
154
+ # Upstream skills the orchestrator invokes but does NOT bundle in the wheel — the
155
+ # Froid Plane (bmm) module installs them. Each must exist in every ``trees`` entry —
156
+ # callers pass the DEV_PRIMITIVE_ROLES trees — and carry its marker files (a
157
+ # half-installed or pre-automation skill is caught by the `froid-loop validate`
158
+ # preflight). `{skill: (marker-rel-path, ...)}`.
159
+ # - the dev primitive — always required, and never substitutable. Its entry is
160
+ # keyed on the LEGACY name because this map doubles as the "lay down a pre-rename
161
+ # install" catalog; missing_base_skills does NOT walk it for the primitive, it
162
+ # resolves the installed name per tree first (resolve_dev_primitive).
163
+ # - the two review hunters v6.10.0 ships. These are only the FALLBACK review
164
+ # requirement, used when the installed skill's shape can't be read: normally the
165
+ # reviewers are derived per tree from the dev primitive itself
166
+ # (resolve_review_layers), because which skills the review step invokes is a
167
+ # property of that skill version, not of a catalog pinned in here (#260).
168
+ # froid-review-verification-gap is not in the fallback set: no tagged Froid Plane
169
+ # release ships it standalone (on current sources it is a thin forwarder to the
170
+ # merged froid-review), so demanding it of every project made `validate`
171
+ # unsatisfiable on real installs. A project whose review layers DO name it still has
172
+ # it required — by derivation from its own config, not by this list.
173
+ DEV_BASE_SKILLS = {
174
+ DEV_PRIMITIVE_LEGACY: DEV_PRIMITIVE_MARKERS,
175
+ "froid-review-adversarial-general": (),
176
+ "froid-review-edge-case-hunter": (),
177
+ }
178
+ # The merged lens-based reviewer (Froid Plane core-streamline). It satisfies the
179
+ # layer topology of the releases that hand off to it: step-04 driven off
180
+ # customize.toml's [[workflow.review_layers]], with the hunter layers each invoking
181
+ # froid-review with one lens. On 6.11 sources the same four layers (blind-hunter,
182
+ # edge-case-hunter, verification-gap, intent-alignment) invoke no skill at all —
183
+ # two of them read the primitive's own review-prompts/*.md, two carry their prompt
184
+ # inline — so that tree derives an empty `required` map, which is satisfied rather
185
+ # than unsatisfiable: _review_findings
186
+ # falls back to the static catalog only when the resolution is None (an unreadable
187
+ # shape), never because it resolved to requiring nothing. Both topologies stay
188
+ # supported; this constant is named here for the fallback path only, and the derived
189
+ # path sees it named in the layers themselves.
190
+ MERGED_REVIEW_SKILL = "froid-review"
191
+ # The DEV_BASE_SKILLS entries MERGED_REVIEW_SKILL subsumes. froid-dev-auto (the dev
192
+ # primitive) is NOT here — the merged reviewer never substitutes for it.
193
+ _REVIEW_LAYER_SKILLS = frozenset(
194
+ {"froid-review-adversarial-general", "froid-review-edge-case-hunter"}
195
+ )
196
+ # Every non-bundled skill that might need copying into an isolated worktree: the
197
+ # preflight set above plus the merged reviewer and the pre-consolidation standalone
198
+ # verification-gap forwarder (carried so a hand-installed forwarder still resolves,
199
+ # never validated). provision_worktree skips skills the main repo lacks, so this
200
+ # copy-if-present superset is safe in both directions.
201
+ # BOTH primitive eras are listed: a worktree must carry whichever one the main
202
+ # checkout has, and copy-if-present makes naming both free. Adding the new name here
203
+ # is what keeps isolation working across the rename — provisioning unions this
204
+ # catalog with the resolved review layers, and the layers never name the primitive.
205
+ BASE_SKILLS = {
206
+ DEV_PRIMITIVE_NEW: DEV_PRIMITIVE_MARKERS,
207
+ **DEV_BASE_SKILLS,
208
+ "froid-review-verification-gap": (),
209
+ MERGED_REVIEW_SKILL: (),
210
+ }
211
+
212
+ # How the dev primitive names a skill it hands a review off to, in both shapes:
213
+ # "Invoke the `froid-review` skill with only the `adversarial` lens" (a
214
+ # customize.toml review layer) and "Invoke the `froid-review-edge-case-hunter`
215
+ # skill on this diff" (pre-consolidation step-04). Deliberately narrow —
216
+ # backticked, `froid-` prefixed — because a false match here is a false FAIL,
217
+ # the exact failure mode #260 was.
218
+ _INVOKE_SKILL_RE = re.compile(r"[Ii]nvoke the `(froid-[a-z0-9-]+)` skill")
219
+ # Any backticked froid-* token in a layer's instruction. What this matches and
220
+ # _INVOKE_SKILL_RE does not is a skill reference we cannot confirm is an
221
+ # invocation — upstream itself writes "use the `froid-code-review` skill"
222
+ # elsewhere, so the narrow phrasing is a convention, not a contract. Such a
223
+ # reference is reported as a WARNING and never hard-required: an override may
224
+ # legitimately mention a skill it does not invoke, and blocking on that would
225
+ # rebuild #260's false FAIL. Lens names (`adversarial`) lack the prefix, so the
226
+ # shipped layers add nothing here.
227
+ _SKILL_REF_RE = re.compile(r"`(froid-[a-z0-9-]+)`")
228
+
229
+
230
+ def _customize_overrides(skill: str) -> tuple[Path, ...]:
231
+ """Project overrides of ``skill``'s shipped customize.toml, in precedence order
232
+ (later wins), per customize.toml's own header. The `.user.toml` layer is personal
233
+ and gitignored by the upstream installer.
234
+
235
+ Takes the RESOLVED primitive name and reads that pair ONLY — never both eras.
236
+ Upstream's own resolver keys on the skill directory, so on a renamed project the
237
+ legacy pair is simply not read at run time; merging it in here would make the
238
+ preflight resolve layers the session never applies, which is the exact
239
+ disagreement :func:`resolve_review_layers` exists to prevent. The orphaned file
240
+ is surfaced instead, as the ``skills.customize-legacy`` warning in
241
+ :func:`dev_primitive_warnings`.
242
+ """
243
+ return (
244
+ CUSTOMIZE_DIR / f"{skill}.toml",
245
+ CUSTOMIZE_DIR / f"{skill}.user.toml",
246
+ )
247
+
248
+
249
+ def resolve_dev_primitive(project: Path, tree: str) -> str | None:
250
+ """The dev-primitive skill name to drive in ``tree``, or None when none is usable.
251
+
252
+ Prefers :data:`DEV_PRIMITIVE_NEW`; falls back to :data:`DEV_PRIMITIVE_LEGACY`
253
+ only when that install is marker-complete, which is exactly what the post-rename
254
+ forwarding shim is not (see the constants block). None means "fail the preflight"
255
+ — never "drive the old name and hope".
256
+
257
+ The new name needs only its SKILL.md to *resolve*: completeness is reported by
258
+ :func:`missing_base_skills` against the resolved dir. Requiring markers here
259
+ instead would make a truncated froid-build-auto silently resolve to a legacy
260
+ install (or to the shim's failure message), hiding the real problem.
261
+ """
262
+ if _is_file(project / tree / DEV_PRIMITIVE_NEW / "SKILL.md"):
263
+ return DEV_PRIMITIVE_NEW
264
+ legacy = project / tree / DEV_PRIMITIVE_LEGACY
265
+ if _is_file(legacy / "SKILL.md") and all(
266
+ _is_file(legacy / marker) for marker in DEV_PRIMITIVE_MARKERS
267
+ ):
268
+ return DEV_PRIMITIVE_LEGACY
269
+ return None
270
+
271
+
272
+ def _is_dev_primitive_shim(project: Path, tree: str) -> bool:
273
+ """True when ``tree`` holds a legacy-named skill that is only a forwarding shim
274
+ (SKILL.md present, at least one marker absent). Selects the failure *message* in
275
+ :func:`missing_base_skills`; it is never a resolution input."""
276
+ legacy = project / tree / DEV_PRIMITIVE_LEGACY
277
+ if not (legacy / "SKILL.md").is_file():
278
+ return False
279
+ return any(not (legacy / marker).is_file() for marker in DEV_PRIMITIVE_MARKERS)
280
+
281
+
282
+ def _is_renderer_stub(skill_dir: Path) -> bool:
283
+ """Whether ``skill_dir`` delegates to the project renderer.
284
+
285
+ Content, not the skill's era/name, is the discriminator. An unreadable or
286
+ non-UTF-8 SKILL.md cannot prove the renderer is involved, so this fails open;
287
+ the ordinary marker checks still report the damaged skill tree.
288
+ """
289
+ try:
290
+ skill_md = (skill_dir / "SKILL.md").read_text(encoding="utf-8")
291
+ except (OSError, UnicodeDecodeError):
292
+ return False
293
+ return RENDERER_SCRIPT_MARKER in skill_md
294
+
295
+
296
+ def _renderer_unit_required(project: Path, rel: str) -> bool:
297
+ """Whether the installed renderer requires project-relative unit member ``rel``.
298
+
299
+ The entry point is unconditional. The sibling helper is content-keyed because
300
+ it is required only while ``render_skill.py`` imports it; this preflight has no
301
+ force override, so the installed script is the honest compatibility boundary.
302
+ An absent/unreadable script already earns the entry-point finding and does not
303
+ also fabricate a helper diagnosis.
304
+ """
305
+ if rel != RENDERER_CONFIG_UTILS_REL:
306
+ return True
307
+ try:
308
+ script = (project / RENDERER_SCRIPT_REL).read_text(encoding="utf-8")
309
+ except (OSError, UnicodeDecodeError):
310
+ return False
311
+ return RENDERER_CONFIG_UTILS_MARKER in script
312
+
313
+
314
+ def _absent_renderer_sources(skill_dir: Path) -> list[str]:
315
+ """Required renderer sources absent from ``skill_dir``, as POSIX rels.
316
+
317
+ This validates the two statically decidable source HALTs: the hard-coded
318
+ ``workflow.md`` entry and any ``[[froid-snapshot:...]]`` target declared by a
319
+ Markdown source. Missing config-token values are deliberately outside this
320
+ presence-only check (#407).
321
+
322
+ The era guard lives here: an inline pre-renderer SKILL.md legitimately has no
323
+ workflow entry, so asking it renderer questions would refuse a healthy install.
324
+
325
+ The ``rglob`` walk deliberately mirrors upstream
326
+ ``render_skill.py::_load_sources`` and must not be unified with
327
+ :func:`_copy_traversable`'s ``iterdir`` recursion. ``rglob`` does not descend a
328
+ symlinked sub-directory; the copier does. Using the copier's enumeration here
329
+ would invent sources the renderer never loads and turn a guaranteed HALT green.
330
+
331
+ Upstream keys nested sources by POSIX relative path and excludes every basename
332
+ ``SKILL.md``. :func:`_is_file` enforces filesystem totality, including the FIFO
333
+ guard, before any source is read. Remaining read/decode faults fail open because
334
+ they have different remediation from an absent declared source.
335
+ """
336
+ if not _is_renderer_stub(skill_dir):
337
+ return []
338
+ sources = {
339
+ path.relative_to(skill_dir).as_posix(): path
340
+ for path in skill_dir.rglob("*.md")
341
+ if path.name != "SKILL.md" and _is_file(path)
342
+ }
343
+ absent = [] if RENDERER_ENTRY_REL in sources else [RENDERER_ENTRY_REL]
344
+ for path in sources.values():
345
+ try:
346
+ body = path.read_text(encoding="utf-8")
347
+ except (OSError, UnicodeDecodeError):
348
+ continue
349
+ absent.extend(target for target in SNAPSHOT_TOKEN_RE.findall(body) if target not in sources)
350
+ return sorted(set(absent))
351
+
352
+
353
+ def renderer_stub_resolved(project: Path, trees: Sequence[str]) -> bool:
354
+ """Whether any active tree resolves to a renderer-backed dev primitive.
355
+
356
+ Resolution is per tree and deduplicated exactly like :func:`missing_base_skills`.
357
+ Public because the engine's seed-escalation decision reuses this exact preflight
358
+ discriminator rather than re-deriving one (`worktree_flow`).
359
+ """
360
+ for tree in dict.fromkeys(trees):
361
+ resolved = resolve_dev_primitive(project, tree)
362
+ if resolved is not None and _is_renderer_stub(project / tree / resolved):
363
+ return True
364
+ return False
365
+
366
+
367
+ def dev_primitive_or_default(project: Path, tree: str | None) -> str:
368
+ """Total form of :func:`resolve_dev_primitive` for callers that need a name.
369
+
370
+ A prompt string — and a path this module has to probe *somewhere* to report on —
371
+ always has to name something, and the preflight has already refused the
372
+ unresolvable cases before any session is spawned. So an unresolvable tree (and a
373
+ None tree, which is what an adapter with no profile reports) falls back to the
374
+ legacy name rather than raising into prompt construction."""
375
+ if tree is None:
376
+ return DEV_PRIMITIVE_LEGACY
377
+ return resolve_dev_primitive(project, tree) or DEV_PRIMITIVE_LEGACY
378
+
379
+
380
+ class ReviewResolution(NamedTuple):
381
+ """Which skills the installed dev primitive's review step actually invokes.
382
+
383
+ ``source`` is the file it was read from (for the finding's detail).
384
+ ``required`` maps each invoked skill to the review-layer ids invoking it —
385
+ empty ids for the pre-consolidation shape, which has no layers. ``advisory``
386
+ is the same shape for references we cannot confirm will run (see
387
+ :data:`_SKILL_REF_RE` and ``when``-gated layers): missing ones warn, never
388
+ fail. ``active_layers`` names every layer with a non-empty ``instruction`` —
389
+ all of them disabled is a run that HALTs. ``unreadable`` names override files
390
+ that failed to parse.
391
+ """
392
+
393
+ source: str
394
+ required: dict[str, tuple[str, ...]]
395
+ advisory: dict[str, tuple[str, ...]]
396
+ layer_driven: bool
397
+ active_layers: tuple[str, ...]
398
+ unreadable: tuple[str, ...]
399
+
400
+ def skills(self) -> tuple[str, ...]:
401
+ """Every skill this review step may invoke, required or advisory."""
402
+ return tuple(dict.fromkeys((*self.required, *self.advisory)))
403
+
404
+
405
+ def _read_toml(path: Path) -> dict[str, Any] | None:
406
+ """Parse a TOML file, or None if it is absent/unreadable/malformed."""
407
+ try:
408
+ return tomllib.loads(path.read_text(encoding="utf-8"))
409
+ except (OSError, UnicodeDecodeError, tomllib.TOMLDecodeError):
410
+ return None
411
+
412
+
413
+ def _layers_of(data: Any) -> list[Any]:
414
+ """The ``[[workflow.review_layers]]`` array, or [] for any other shape.
415
+
416
+ Every hop is type-guarded independently: `workflow` is only a table by
417
+ convention, and a syntactically valid `workflow = "..."` would otherwise
418
+ raise AttributeError straight out of the preflight — crashing validate, run,
419
+ resume and sweep on a file whose only sin is being misconfigured.
420
+ """
421
+ workflow = data.get("workflow") if isinstance(data, dict) else None
422
+ layers = workflow.get("review_layers") if isinstance(workflow, dict) else None
423
+ return list(layers) if isinstance(layers, list) else []
424
+
425
+
426
+ # FROID's own merge keys for arrays of tables, in detection precedence order.
427
+ _KEYED_MERGE_FIELDS = ("code", "id")
428
+
429
+
430
+ def _keyed_merge_field(items: list[Any]) -> str | None:
431
+ """The key an array of tables merges on, or None to append.
432
+
433
+ Port of `_detect_keyed_merge_field` in Froid Plane
434
+ `src/scripts/resolve_customization.py`. EVERY item — base and override
435
+ combined — must be a table carrying the same key, and `code` is checked
436
+ before `id`; a mixed or key-less array falls through to append. Guessing
437
+ differently here means the preflight requires a different skill set than the
438
+ resolver the run actually uses.
439
+ """
440
+ if not items or not all(isinstance(item, dict) for item in items):
441
+ return None
442
+ for candidate in _KEYED_MERGE_FIELDS:
443
+ if all(item.get(candidate) is not None for item in items):
444
+ return candidate
445
+ return None
446
+
447
+
448
+ def _merge_layer_arrays(base: list[Any], override: list[Any]) -> list[Any]:
449
+ """Merge two arrays of tables the way FROID's resolver does.
450
+
451
+ Port of `_merge_arrays`/`_merge_by_key` in Froid Plane
452
+ `src/scripts/resolve_customization.py`: keyed merge when the combined array
453
+ opts into one (matching keys replace in place, new keys append), plain
454
+ concatenation otherwise.
455
+ """
456
+ key = _keyed_merge_field(base + override)
457
+ if key is None:
458
+ return base + override
459
+ result: list[Any] = []
460
+ index_by_key: dict[Any, int] = {}
461
+ for item in base:
462
+ if not isinstance(item, dict):
463
+ continue
464
+ if item.get(key) is not None:
465
+ index_by_key[item[key]] = len(result)
466
+ result.append(dict(item))
467
+ for item in override:
468
+ if not isinstance(item, dict):
469
+ result.append(item)
470
+ continue
471
+ item_key = item.get(key)
472
+ if item_key is not None and item_key in index_by_key:
473
+ result[index_by_key[item_key]] = dict(item)
474
+ else:
475
+ if item_key is not None:
476
+ index_by_key[item_key] = len(result)
477
+ result.append(dict(item))
478
+ return result
479
+
480
+
481
+ def _merged_review_layers(
482
+ project: Path, tree: str, skill: str
483
+ ) -> tuple[list[Any], tuple[str, ...]] | None:
484
+ """The skill's shipped review layers with project overrides applied.
485
+
486
+ ``skill`` is the resolved dev-primitive name — the shipped config and its
487
+ overrides are read from the SAME era, because that is what the run does.
488
+
489
+ Returns ``(layers, unreadable_override_paths)``, or None when the skill's OWN
490
+ customize.toml is absent or unparseable — the one case that genuinely means
491
+ "shape unknown", so the caller falls back to the static catalog.
492
+
493
+ An unparseable OVERRIDE is not that case: FROID's resolver warns and treats it
494
+ as empty, still resolving every other layer. Matching that keeps the preflight
495
+ agreeing with the run; the broken file is reported separately as a warning.
496
+ """
497
+ data = _read_toml(project / tree / skill / "customize.toml")
498
+ if data is None:
499
+ return None
500
+ layers = _layers_of(data)
501
+ unreadable: list[str] = []
502
+ for rel in _customize_overrides(skill):
503
+ override = project / rel
504
+ if not override.is_file():
505
+ continue
506
+ extra = _read_toml(override)
507
+ if extra is None:
508
+ unreadable.append(rel.as_posix())
509
+ continue
510
+ layers = _merge_layer_arrays(layers, _layers_of(extra))
511
+ return layers, tuple(unreadable)
512
+
513
+
514
+ def _layer_id(layer: dict[str, Any], index: int) -> str:
515
+ """A layer's id for reporting — positional when it declares none."""
516
+ lid = layer.get("id")
517
+ return lid if isinstance(lid, str) and lid else f"#{index + 1}"
518
+
519
+
520
+ def resolve_review_layers(project: Path, tree: str) -> ReviewResolution | None:
521
+ """Read the review skills this project will really invoke, or None if unknown.
522
+
523
+ The primitive's installed NAME is resolved from disk here rather than taken as
524
+ an argument (:func:`dev_primitive_or_default`): both call sites — the preflight
525
+ and worktree provisioning — hold exactly ``(project, tree)`` and would otherwise
526
+ each compute the same value to hand back. On a renamed project the legacy paths
527
+ are simply absent, so without this the resolution silently returns None and the
528
+ caller degrades to the static catalog — quietly seeding a worktree with the
529
+ wrong reviewers instead of the ones this project configured.
530
+
531
+ Post-consolidation the primitive is layer-driven: each
532
+ ``[[workflow.review_layers]]`` entry carries its whole execution recipe, and a
533
+ layer that runs a skill names it inline (an empty ``instruction`` disables the
534
+ layer, and a layer may legitimately name no skill at all — `intent-alignment`
535
+ is a self-contained prompt). Pre-consolidation, ``step-04-review.md`` names its
536
+ reviewers directly instead.
537
+
538
+ Reading whichever is installed keeps the preflight honest as upstream moves:
539
+ a project whose configured layers invoke a skill it does not have is otherwise
540
+ green here and broken on every dev run (#260). None means the shape could not
541
+ be determined, so the caller falls back to the static catalog.
542
+
543
+ A ``when``-gated layer contributes ADVISORY requirements only: step-04 skips
544
+ every layer whose condition does not hold, and that condition is evaluated by
545
+ the model in run context — undecidable here, so hard-requiring its skill would
546
+ be a false FAIL.
547
+ """
548
+ # `primitive`, not `skill`: the layer loop below binds `skill` to each INVOKED
549
+ # review skill, and the step-04 fallback after it reads this name as a directory.
550
+ # Sharing one name is safe only while the layer branch returns first — a
551
+ # shadowing this function should not be one edit away from.
552
+ primitive = dev_primitive_or_default(project, tree)
553
+ merged = _merged_review_layers(project, tree, primitive)
554
+ if merged is None:
555
+ return None
556
+ layers, unreadable = merged
557
+ if layers:
558
+ required: dict[str, list[str]] = {}
559
+ advisory: dict[str, list[str]] = {}
560
+ active: list[str] = []
561
+ for index, layer in enumerate(layers):
562
+ if not isinstance(layer, dict):
563
+ continue
564
+ instruction = layer.get("instruction")
565
+ if not isinstance(instruction, str) or not instruction.strip():
566
+ continue
567
+ layer_id = _layer_id(layer, index)
568
+ active.append(layer_id)
569
+ when = layer.get("when")
570
+ gated = isinstance(when, str) and bool(when.strip())
571
+ invoked = _INVOKE_SKILL_RE.findall(instruction)
572
+ # A gated layer's invocations are advisory; so is any froid-* token we
573
+ # can't tie to an invocation phrasing, in either kind of layer.
574
+ hard = [] if gated else invoked
575
+ soft = [s for s in _SKILL_REF_RE.findall(instruction) if gated or s not in invoked]
576
+ for bucket, skills in ((required, hard), (advisory, soft)):
577
+ for skill in skills:
578
+ ids = bucket.setdefault(skill, [])
579
+ if layer_id not in ids:
580
+ ids.append(layer_id)
581
+ return ReviewResolution(
582
+ "customize.toml",
583
+ {s: tuple(i) for s, i in required.items()},
584
+ # never warn about a skill another layer already hard-requires
585
+ {s: tuple(i) for s, i in advisory.items() if s not in required},
586
+ True,
587
+ tuple(active),
588
+ unreadable,
589
+ )
590
+ try:
591
+ step04 = (project / tree / primitive / "step-04-review.md").read_text(encoding="utf-8")
592
+ except (OSError, UnicodeDecodeError):
593
+ return None
594
+ # step-04 is a whole prose document rather than a self-contained execution
595
+ # recipe, so only the invocation phrasing counts here — a backticked mention
596
+ # anywhere in it is not evidence of a handoff.
597
+ named = dict.fromkeys(_INVOKE_SKILL_RE.findall(step04), ())
598
+ if not named:
599
+ return None
600
+ return ReviewResolution("step-04-review.md", dict(named), {}, False, (), unreadable)
601
+
602
+
603
+ # Stories mode (folder+id dispatch, Froid Plane #2549) needs a *newer* dev primitive
604
+ # than sprint mode: one whose step-01 routes a spec-folder + story-id invocation.
605
+ # File existence (missing_base_skills) can't tell the two skill versions apart, so
606
+ # a content probe confirms the merged dispatch protocol is present. This literal is
607
+ # stable prose in the merged step-01 ("this is a **folder+id dispatch**").
608
+ # STORIES_PROBE_SKILL names the FALLBACK era only — the probe runs against the skill
609
+ # resolve_dev_primitive picked for that tree, so a froid-build-auto install is probed
610
+ # under its own name.
611
+ STORIES_PROBE_SKILL = DEV_PRIMITIVE_LEGACY
612
+ STORIES_PROBE_FILE = "step-01-clarify-and-route.md"
613
+ STORIES_PROBE_TEXT = "folder+id dispatch"
614
+
615
+
616
+ def missing_stories_support(project: Path, trees: Sequence[str]) -> list[Finding]:
617
+ """Problems for stories mode's stricter dev-primitive requirement.
618
+
619
+ Sprint mode drives any dev primitive; stories mode needs the folder+id
620
+ dispatch flow, which older skill versions lack. For every ``trees`` entry —
621
+ callers pass the :data:`DEV_PRIMITIVE_ROLES` trees, since only a dev or review
622
+ session ever dispatches one of these — confirm
623
+ ``<resolved-primitive>/step-01-clarify-and-route.md`` exists and carries the
624
+ dispatch-protocol marker. Returns one problem :class:`Finding` per tree lacking
625
+ it (empty = OK). Callers gate this on stories mode only — sprint-mode runs must
626
+ not require the newer skill.
627
+
628
+ The two failures are separate check ids because they are separate conditions
629
+ with separate remediations: ``-missing`` is a half install (reinstall the
630
+ module), ``-stale`` is an install that is simply too old (update it)."""
631
+ problems: list[Finding] = []
632
+ for tree in dict.fromkeys(trees):
633
+ skill = dev_primitive_or_default(project, tree)
634
+ probe = project / tree / skill / STORIES_PROBE_FILE
635
+ detail = {"tree": tree, "skill": skill, "file": STORIES_PROBE_FILE}
636
+ try:
637
+ text = probe.read_text(encoding="utf-8")
638
+ except (OSError, UnicodeDecodeError):
639
+ # OSError = the probe file is absent/unreadable; UnicodeDecodeError = it
640
+ # exists but is binary/non-UTF-8 (a corrupted skill tree). Either way the
641
+ # dispatch-protocol marker can't be confirmed, so report a problem rather
642
+ # than letting the decode error escape and crash the whole preflight.
643
+ problems.append(
644
+ Finding(
645
+ "skills.stories-dispatch-missing",
646
+ "problem",
647
+ f"{tree}/{skill}/{STORIES_PROBE_FILE} not found — stories "
648
+ f"mode needs folder+id dispatch; update the Froid Plane (bmm) module",
649
+ detail,
650
+ )
651
+ )
652
+ continue
653
+ if STORIES_PROBE_TEXT not in text:
654
+ problems.append(
655
+ Finding(
656
+ "skills.stories-dispatch-stale",
657
+ "problem",
658
+ f"{tree}/{skill} lacks folder+id dispatch (no "
659
+ f"{STORIES_PROBE_TEXT!r} in {STORIES_PROBE_FILE}) — stories mode needs a "
660
+ f"newer {skill}; update the bmm module",
661
+ {**detail, "marker": STORIES_PROBE_TEXT},
662
+ )
663
+ )
664
+ return problems
665
+
666
+
667
+ def missing_base_skills(project: Path, trees: Sequence[str]) -> list[Finding]:
668
+ """Problems for the upstream skills the orchestrator drives but doesn't bundle.
669
+
670
+ The dev primitive (froid-build-auto, or a complete pre-rename froid-dev-auto) and
671
+ the review layers its step-04 invokes inline are installed by the Froid Plane
672
+ module, not by `froid-loop init`. Each must exist in every ``trees`` entry —
673
+ callers pass the :data:`DEV_PRIMITIVE_ROLES` trees, since only a dev or review
674
+ session ever dispatches one of these — and carry its marker files. Returns one
675
+ :class:`Finding` per missing/incomplete skill; empty list means OK. Run as a
676
+ preflight so a missing skill fails loudly with remediation instead of stalling
677
+ as an `Unknown command` until the run times out.
678
+
679
+ Not every finding is fatal: review layers that are conditional, ambiguously
680
+ phrased, or configured by an unparseable override come back as ``warning``
681
+ (see :func:`_review_findings`). **Callers must branch on severity** — treating
682
+ a non-empty return as failure turns every advisory into a blocked run.
683
+
684
+ The primitive is resolved per tree (:func:`resolve_dev_primitive`) before any
685
+ marker check, so the markers are asserted against the skill this run would
686
+ actually drive. That splits the failures three ways:
687
+
688
+ - ``skills.base-incomplete`` — one resolved, but it is truncated.
689
+ - ``skills.base-shim`` — nothing resolved, yet a legacy-named SKILL.md is there.
690
+ - ``skills.base-missing`` — nothing at all under either name.
691
+
692
+ A truncated *legacy* install is byte-for-byte the same shape as the shim (old
693
+ SKILL.md, absent markers), so it lands on ``base-shim`` rather than
694
+ ``base-incomplete``; nothing on disk can tell those two apart, so the message
695
+ names both causes and the single remediation they share. What the ids DO
696
+ separate is what a consumer can act on differently: resolved-but-truncated
697
+ (reinstall that skill) vs nothing-usable-resolved (update the module).
698
+
699
+ The review skills are read from the installed primitive itself
700
+ (:func:`resolve_review_layers`) so the preflight requires what this project
701
+ will really invoke: a tree whose configured layers call the merged
702
+ ``froid-review`` needs that skill and not the standalone hunters, and a tree on
703
+ the pre-consolidation step-04 needs the two hunters it names. When the shape
704
+ can't be read we fall back to the static catalog, with a present
705
+ ``froid-review`` satisfying the hunters. Everything is per tree, since a project
706
+ can have a post-consolidation `.claude` tree and a pre-merge `.agents` one side
707
+ by side — and, across the rename, a different primitive era in each.
708
+
709
+ ``skills.base-incomplete`` carries ``missing_markers`` as a list — the message
710
+ joins it with ", " for the human line, which a consumer would otherwise have to
711
+ split back apart on a separator the message is free to change.
712
+
713
+ A resolved renderer stub is also checked at the project-global and
714
+ skill-relative surfaces whose absence deterministically HALTs before the
715
+ workflow can write its contract:
716
+
717
+ - ``skills.dev-renderer``: a required project-relative renderer script-unit
718
+ member is absent (``missing_scripts`` detail, per tree);
719
+ - ``skills.dev-renderer-config``: the project-global required config is absent
720
+ (emitted once even when multiple trees resolve a stub);
721
+ - ``skills.dev-renderer-sources``: the skill-relative workflow entry or a
722
+ declared snapshot source is absent (``missing_sources`` detail, per tree).
723
+
724
+ These are independent of the ordinary marker check: a damaged primitive may
725
+ need both remediations. All are content-gated on the installed SKILL.md, so an
726
+ inline pre-renderer install sees byte-stable behavior.
727
+ """
728
+ problems: list[Finding] = []
729
+ resolved_stub = False
730
+ for tree in dict.fromkeys(trees):
731
+ resolved = resolve_dev_primitive(project, tree)
732
+ if resolved is None and _is_dev_primitive_shim(project, tree):
733
+ legacy_dir = project / tree / DEV_PRIMITIVE_LEGACY
734
+ absent = [m for m in DEV_PRIMITIVE_MARKERS if not (legacy_dir / m).is_file()]
735
+ problems.append(
736
+ Finding(
737
+ "skills.base-shim",
738
+ "problem",
739
+ f"{tree}/{DEV_PRIMITIVE_LEGACY} is unusable (missing "
740
+ f"{', '.join(absent)}) and {DEV_PRIMITIVE_NEW} is not installed — "
741
+ f"most likely the forwarding shim the Froid Plane's rename left "
742
+ f"behind, otherwise a truncated install; update the bmm module. The "
743
+ f"shim's migration prompt is interactive and would HALT an unattended "
744
+ f"session without writing anything to disk",
745
+ {
746
+ "tree": tree,
747
+ "skill": DEV_PRIMITIVE_LEGACY,
748
+ "expected": DEV_PRIMITIVE_NEW,
749
+ "missing_markers": absent,
750
+ },
751
+ )
752
+ )
753
+ elif resolved is None:
754
+ problems.append(
755
+ Finding(
756
+ "skills.base-missing",
757
+ "problem",
758
+ f"{tree}/{DEV_PRIMITIVE_NEW} not found — the orchestrator drives this "
759
+ f"upstream dev primitive directly; it ships with the bmm module "
760
+ f"(Froid Plane >= 6.10.0); install or update bmm in this project "
761
+ f"(older installs name it {DEV_PRIMITIVE_LEGACY})",
762
+ {"tree": tree, "skill": DEV_PRIMITIVE_NEW},
763
+ )
764
+ )
765
+ else:
766
+ skill_dir = project / tree / resolved
767
+ absent = [m for m in DEV_PRIMITIVE_MARKERS if not (skill_dir / m).is_file()]
768
+ if absent:
769
+ problems.append(
770
+ Finding(
771
+ "skills.base-incomplete",
772
+ "problem",
773
+ f"{tree}/{resolved} is incomplete (missing "
774
+ f"{', '.join(absent)}) — reinstall it from the bmm module",
775
+ {"tree": tree, "skill": resolved, "missing_markers": absent},
776
+ )
777
+ )
778
+ if _is_renderer_stub(skill_dir):
779
+ resolved_stub = True
780
+ absent_scripts = [
781
+ rel
782
+ for rel in RENDERER_SCRIPT_UNIT_REL
783
+ if _renderer_unit_required(project, rel) and not (project / rel).is_file()
784
+ ]
785
+ if absent_scripts:
786
+ problems.append(
787
+ Finding(
788
+ "skills.dev-renderer",
789
+ "problem",
790
+ f"{tree}/{resolved}/SKILL.md renders via "
791
+ f"{RENDERER_SCRIPT_MARKER} but the renderer script unit is "
792
+ f"incomplete (missing {', '.join(absent_scripts)}) — the "
793
+ "session would HALT without writing a spec; reinstall the "
794
+ "Froid Plane (bmm) module",
795
+ {
796
+ "tree": tree,
797
+ "skill": resolved,
798
+ "missing_scripts": absent_scripts,
799
+ },
800
+ )
801
+ )
802
+ absent_sources = _absent_renderer_sources(skill_dir)
803
+ if absent_sources:
804
+ problems.append(
805
+ Finding(
806
+ "skills.dev-renderer-sources",
807
+ "problem",
808
+ f"{tree}/{resolved} renders via {RENDERER_SCRIPT_MARKER} but its "
809
+ f"render sources are incomplete (unresolved: "
810
+ f"{', '.join(absent_sources)}) — the session would HALT without "
811
+ "writing a spec; reinstall the Froid Plane (bmm) module",
812
+ {
813
+ "tree": tree,
814
+ "skill": resolved,
815
+ "missing_sources": absent_sources,
816
+ },
817
+ )
818
+ )
819
+ problems.extend(_review_findings(project, tree))
820
+ if resolved_stub and not (project / CENTRAL_CONFIG_REL).is_file():
821
+ problems.append(
822
+ Finding(
823
+ "skills.dev-renderer-config",
824
+ "problem",
825
+ f"the dev primitive renders via {RENDERER_SCRIPT_MARKER} but "
826
+ f"{CENTRAL_CONFIG_REL} is missing — the renderer requires that layer "
827
+ "and would HALT without writing a spec; reinstall the Froid Plane "
828
+ "(bmm) module",
829
+ {"config": CENTRAL_CONFIG_REL},
830
+ )
831
+ )
832
+ return problems
833
+
834
+
835
+ def dev_primitive_warnings(project: Path, trees: Sequence[str]) -> list[Finding]:
836
+ """Advisory findings about a resolved dev primitive — validate-only, never a gate.
837
+
838
+ One condition, and it is genuinely survivable — which is what keeps it out of
839
+ :func:`missing_base_skills`:
840
+
841
+ - ``skills.customize-legacy``: at least one tree resolved to the NEW name while a
842
+ customization override still sits under the OLD one with no counterpart, i.e.
843
+ the rename silently orphaned it. Emitted once per project (the override files
844
+ are project-global, not per tree). The session still runs; it just runs
845
+ unstyled, so naming it is an operator heads-up rather than a gate.
846
+
847
+ ⚠️ The remediation is COPY, not rename, whenever another active tree still
848
+ resolves to the legacy primitive. A project can sit mid-upgrade with a different
849
+ era in each tree (`.claude/skills` on the new name, `.agents/skills` still on a
850
+ marker-complete old one), and each tree resolves its overrides under its OWN
851
+ era — so the legacy file is orphaned for the new tree and LIVE for the legacy
852
+ one. Telling that operator to rename it moves the customization from one tree to
853
+ the other instead of fixing anything. Suppressing the finding instead would be
854
+ the opposite error: the new tree really is running unstyled, which is precisely
855
+ the silent degradation this warning exists to surface.
856
+
857
+ Deliberately a warning and not a problem. :func:`missing_base_skills` feeds a
858
+ gate with no severity filter and no ``--force``: a false FAIL there pauses every
859
+ run behind a remediation nobody can apply, so on these checks a false green is
860
+ the safe direction. The orphaned file's layers really are inert for a new-era
861
+ tree — upstream's resolver keys on the skill dir — so the honest response is to
862
+ say so and name the rename, not to block.
863
+
864
+ Returns [] when nothing resolves — :func:`missing_base_skills` owns that story.
865
+ """
866
+ findings: list[Finding] = []
867
+ resolved = {tree: resolve_dev_primitive(project, tree) for tree in dict.fromkeys(trees)}
868
+ new_trees = [tree for tree, name in resolved.items() if name == DEV_PRIMITIVE_NEW]
869
+ legacy_trees = [tree for tree, name in resolved.items() if name == DEV_PRIMITIVE_LEGACY]
870
+ if new_trees:
871
+ orphaned = [
872
+ (CUSTOMIZE_DIR / f"{DEV_PRIMITIVE_LEGACY}{suffix}").as_posix()
873
+ for suffix in (".toml", ".user.toml")
874
+ if (project / CUSTOMIZE_DIR / f"{DEV_PRIMITIVE_LEGACY}{suffix}").is_file()
875
+ and not (project / CUSTOMIZE_DIR / f"{DEV_PRIMITIVE_NEW}{suffix}").is_file()
876
+ ]
877
+ if orphaned and legacy_trees:
878
+ # Mixed-era project: the file is live for the legacy tree, so name the
879
+ # tree it still styles and say copy. `legacy_trees` rides in `detail`
880
+ # only on this branch, keeping the all-new detail dict unchanged.
881
+ findings.append(
882
+ Finding(
883
+ "skills.customize-legacy",
884
+ "warning",
885
+ f"{', '.join(orphaned)} does not apply in "
886
+ f"{', '.join(new_trees)} (resolved {DEV_PRIMITIVE_NEW}) but still "
887
+ f"applies in {', '.join(legacy_trees)} — COPY the override file(s) "
888
+ f"to the {DEV_PRIMITIVE_NEW} name; renaming would drop the "
889
+ f"customization from {', '.join(legacy_trees)}",
890
+ {
891
+ "files": orphaned,
892
+ "skill": DEV_PRIMITIVE_NEW,
893
+ "legacy_trees": legacy_trees,
894
+ },
895
+ )
896
+ )
897
+ elif orphaned:
898
+ findings.append(
899
+ Finding(
900
+ "skills.customize-legacy",
901
+ "warning",
902
+ f"{', '.join(orphaned)} no longer applies — the dev primitive is now "
903
+ f"{DEV_PRIMITIVE_NEW}; rename the override file(s) to match",
904
+ {"files": orphaned, "skill": DEV_PRIMITIVE_NEW},
905
+ )
906
+ )
907
+ return findings
908
+
909
+
910
+ def _where_clause(layer_ids: Sequence[str]) -> str:
911
+ """How a finding's message names the layers that reach for a skill."""
912
+ if len(layer_ids) > 1:
913
+ return f"review layers {', '.join(layer_ids)} invoke"
914
+ if layer_ids:
915
+ return f"review layer {layer_ids[0]} invokes"
916
+ return "review step invokes"
917
+
918
+
919
+ def _review_findings(project: Path, tree: str) -> list[Finding]:
920
+ """Findings for the review skills this tree's dev primitive invokes.
921
+
922
+ Problems block; warnings never do. A skill is only a problem when the
923
+ installed config says this run WILL invoke it — anything conditional or
924
+ ambiguous warns instead, because a false FAIL here is #260 all over again.
925
+ Callers must therefore honour severity rather than treating any finding as
926
+ fatal.
927
+
928
+ The primitive's name is resolved here for the MESSAGES — which tell an operator
929
+ which skill dir and which override file to go and fix, so a hardcoded era would
930
+ send them to a path that does not exist on a renamed project. The static-catalog
931
+ fallback below has no :class:`ReviewResolution` to carry the name, so this
932
+ resolves independently rather than widening that tuple.
933
+ """
934
+ primitive = dev_primitive_or_default(project, tree)
935
+ resolved = resolve_review_layers(project, tree)
936
+ if resolved is None:
937
+ # Unknown shape: keep the long-standing static requirement, which both real
938
+ # topologies satisfy — the two hunters, or the merged reviewer instead.
939
+ if (project / tree / MERGED_REVIEW_SKILL / "SKILL.md").is_file():
940
+ return []
941
+ return [
942
+ Finding(
943
+ "skills.base-missing",
944
+ "problem",
945
+ f"{tree}/{skill} not found — this review layer ships with the bmm module "
946
+ f"(Froid Plane >= 6.10.0), as does the consolidated {MERGED_REVIEW_SKILL} "
947
+ f"skill that supersedes it in newer releases; install or update bmm in "
948
+ f"this project",
949
+ {"tree": tree, "skill": skill},
950
+ )
951
+ for skill in sorted(_REVIEW_LAYER_SKILLS)
952
+ if not (project / tree / skill / "SKILL.md").is_file()
953
+ ]
954
+ findings: list[Finding] = []
955
+ for rel in resolved.unreadable:
956
+ findings.append(
957
+ Finding(
958
+ "skills.customize-unreadable",
959
+ "warning",
960
+ f"{rel} could not be parsed as TOML — the run's resolver skips a broken "
961
+ f"override layer, so this project's review layers resolve without it; "
962
+ f"fix the file or remove it",
963
+ {"tree": tree, "file": rel},
964
+ )
965
+ )
966
+ if resolved.layer_driven and not resolved.active_layers:
967
+ findings.append(
968
+ Finding(
969
+ "skills.review-layers-empty",
970
+ "problem",
971
+ f"{tree}/{primitive} has no enabled review layer (every "
972
+ f"`instruction` is empty) — every dev run would HALT blocked with "
973
+ f"'no active review layers'; re-enable a layer in "
974
+ f"{_customize_overrides(primitive)[0].as_posix()}",
975
+ {"tree": tree, "source": resolved.source},
976
+ )
977
+ )
978
+ for skill, layer_ids in sorted(resolved.required.items()):
979
+ if (project / tree / skill / "SKILL.md").is_file():
980
+ continue
981
+ findings.append(
982
+ Finding(
983
+ "skills.review-layer-missing",
984
+ "problem",
985
+ f"{tree}/{skill} not found — {tree}/{primitive}'s "
986
+ f"{_where_clause(layer_ids)} it ({resolved.source}), so every dev run's "
987
+ f"review would fail; install or update the bmm module so this project's "
988
+ f"review layers resolve",
989
+ {
990
+ "tree": tree,
991
+ "skill": skill,
992
+ "layers": list(layer_ids),
993
+ "source": resolved.source,
994
+ },
995
+ )
996
+ )
997
+ for skill, layer_ids in sorted(resolved.advisory.items()):
998
+ if (project / tree / skill / "SKILL.md").is_file():
999
+ continue
1000
+ findings.append(
1001
+ Finding(
1002
+ "skills.review-layer-unresolved",
1003
+ "warning",
1004
+ f"{tree}/{skill} not found — {tree}/{primitive}'s "
1005
+ f"{_where_clause(layer_ids)} it conditionally, or names it in prose this "
1006
+ f"check cannot confirm is a handoff ({resolved.source}); install it if "
1007
+ f"that layer is meant to run",
1008
+ {
1009
+ "tree": tree,
1010
+ "skill": skill,
1011
+ "layers": list(layer_ids),
1012
+ "source": resolved.source,
1013
+ },
1014
+ )
1015
+ )
1016
+ return findings
1017
+
1018
+
1019
+ def hook_script_current(project: Path) -> bool | None:
1020
+ """Does the project's installed relay match the one this wheel would write?
1021
+
1022
+ ``True`` yes, ``False`` stale (or otherwise divergent), ``None`` unknowable —
1023
+ the installed copy or the packaged source could not be read as text. The
1024
+ unknown arm is a third state and not a coerced ``False`` on purpose: the sole
1025
+ caller (``cmd_validate``'s ``hooks.relay-stale``) reports what it knows, and
1026
+ "I could not look" is not "your relay is out of date".
1027
+
1028
+ Lives here, beside :func:`install_into`'s write of the same two paths, so the
1029
+ reader and the writer of the relay stay in one module and one reviewer's view
1030
+ — a comparison that resolved the source differently from the writer would
1031
+ answer a different question.
1032
+
1033
+ Compared as TEXT read with universal newlines, not as raw bytes. That is
1034
+ precisely the round trip ``install_into`` performs (``read_text`` then
1035
+ ``write_text``), and ``write_text`` translates ``\\n`` to ``os.linesep`` — so
1036
+ on Windows every freshly-installed relay differs from the packaged source
1037
+ byte-for-byte while being exactly what ``init`` writes. A byte compare would
1038
+ call those installs permanently stale.
1039
+ """
1040
+ try:
1041
+ installed = (project / HOOK_SCRIPT_REL).read_text(encoding="utf-8")
1042
+ packaged = (
1043
+ resources.files("froid_loop.data")
1044
+ .joinpath("froid_loop_hook.py")
1045
+ .read_text(encoding="utf-8")
1046
+ )
1047
+ except (OSError, ValueError):
1048
+ # ValueError covers UnicodeDecodeError (a relay overwritten with non-UTF-8
1049
+ # bytes); OSError covers missing/unreadable on either side. Observation
1050
+ # degrades — the missing and unreadable cases already have their own
1051
+ # finding (`hooks.relay-present`), and a packaged source this process
1052
+ # cannot read is a broken wheel, not a stale project.
1053
+ return None
1054
+ return installed == packaged
1055
+
1056
+
1057
+ def _hook_command(project: Path, profile: CLIProfile, canonical_event: str) -> str:
1058
+ host = get_process_host()
1059
+ interp = host.hook_interpreter()
1060
+ if profile.hooks.dialect == "claude-settings-json":
1061
+ return f'{interp} "$CLAUDE_PROJECT_DIR"/{HOOK_SCRIPT_REL} {canonical_event}'
1062
+ # Codex/Gemini expose no $CLAUDE_PROJECT_DIR equivalent to hook commands;
1063
+ # bake the absolute path at init time.
1064
+ return f"{interp} {host.shell_quote(str(project / HOOK_SCRIPT_REL))} {canonical_event}"
1065
+
1066
+
1067
+ def _hook_entry(dialect: str, command: str) -> dict:
1068
+ handler: dict = {"type": "command", "command": command}
1069
+ if dialect == "gemini-settings-json":
1070
+ handler["timeout"] = GEMINI_HOOK_TIMEOUT_MS # Gemini timeouts are milliseconds
1071
+ return {"matcher": "", "hooks": [handler]}
1072
+ if dialect == "copilot-settings-json":
1073
+ handler["timeoutSec"] = COPILOT_HOOK_TIMEOUT_SEC # Copilot timeouts are seconds
1074
+ return handler # Copilot stores the handler directly in the event list
1075
+ if dialect == "antigravity-hooks-json":
1076
+ handler["timeout"] = ANTIGRAVITY_HOOK_TIMEOUT_SEC # agy timeouts are seconds
1077
+ # agy's Stop event value is a flat list of handler objects — the handler
1078
+ # sits directly in the event list, with no matcher/hooks wrapper (unlike
1079
+ # gemini's grouped shape).
1080
+ return handler
1081
+ # claude-settings-json and codex-hooks-json share the schema
1082
+ return {"hooks": [handler]}
1083
+
1084
+
1085
+ def hook_event_container(config: dict, dialect: str) -> dict:
1086
+ """The `native event -> handlers` map inside a parsed hook config.
1087
+
1088
+ Most dialects nest it under "hooks". agy instead keys the file by hook GROUP
1089
+ name at the top level, so our relay lives under ANTIGRAVITY_HOOK_GROUP —
1090
+ reading "hooks" there yields {} and reports a correctly-installed relay as
1091
+ unregistered (issue #159). Every reader must go through this, or it drifts.
1092
+ """
1093
+ if dialect == "antigravity-hooks-json":
1094
+ container = config.get(ANTIGRAVITY_HOOK_GROUP, {})
1095
+ else:
1096
+ container = config.get("hooks", {})
1097
+ return container if isinstance(container, dict) else {}
1098
+
1099
+
1100
+ def _relay_in_handlers(handlers) -> bool:
1101
+ """True if any handler in a native-event list carries the relay command."""
1102
+ return RELAY_MARKER in json.dumps(handlers)
1103
+
1104
+
1105
+ def _managed_hook_in_handlers(handlers) -> bool:
1106
+ """merge_hooks' dedup: a relay OR probe-capture command is already present."""
1107
+ dumped = json.dumps(handlers)
1108
+ return RELAY_MARKER in dumped or PROBE_MARKER in dumped
1109
+
1110
+
1111
+ def strip_relay_hooks(config: dict, dialect: str) -> bool:
1112
+ """Drop every relay registration from a parsed hook config. True if any went.
1113
+
1114
+ The inverse of :func:`merge_hooks`, for the one caller that needs its own
1115
+ registration to be authoritative rather than additive: a worktree seeded with
1116
+ the main repo's hook config (``provision_worktree``). That config already
1117
+ carries a relay command written for the main repo — `$CLAUDE_PROJECT_DIR`-relative
1118
+ for the claude dialect, which resolves inside the worktree, where no relay
1119
+ exists. `merge_hooks` will not replace it, since `_managed_hook_in_handlers`
1120
+ reports the event as already registered, so the stale command has to go first.
1121
+
1122
+ Only RELAY_MARKER commands are removed, at command granularity: a matcher
1123
+ entry whose nested list holds a project command beside the relay keeps the
1124
+ entry and loses only the relay command. A probe-capture hook is a deliberate,
1125
+ temporary registration that no worktree seeding produces, and is left alone.
1126
+ Empty event lists are dropped; an empty container is left in place for
1127
+ `merge_hooks` to refill.
1128
+ """
1129
+ container = hook_event_container(config, dialect)
1130
+ removed = False
1131
+ for native_event in list(container):
1132
+ handlers = container.get(native_event)
1133
+ if not isinstance(handlers, list):
1134
+ continue
1135
+ kept = []
1136
+ for handler in handlers:
1137
+ if RELAY_MARKER not in json.dumps(handler):
1138
+ kept.append(handler)
1139
+ continue
1140
+ # claude/codex/gemini wrap commands in a nested "hooks" list, and a
1141
+ # user may have added their own command beside the relay inside ONE
1142
+ # matcher entry — strip inside the list so theirs survives. copilot
1143
+ # and agy store the command dict flat in the event list, so a marker
1144
+ # match means the entry IS the relay and it drops whole.
1145
+ nested = handler.get("hooks") if isinstance(handler, dict) else None
1146
+ if isinstance(nested, list):
1147
+ surviving = [c for c in nested if RELAY_MARKER not in json.dumps(c)]
1148
+ if surviving:
1149
+ if len(surviving) != len(nested):
1150
+ handler["hooks"] = surviving
1151
+ removed = True
1152
+ kept.append(handler)
1153
+ continue
1154
+ removed = True
1155
+ if len(kept) != len(handlers):
1156
+ if kept:
1157
+ container[native_event] = kept
1158
+ else:
1159
+ del container[native_event]
1160
+ return removed
1161
+
1162
+
1163
+ def relay_registered(config: dict, dialect: str, events: Iterable[str]) -> bool:
1164
+ """True if the froid-loop relay is registered for any of `events`."""
1165
+ container = hook_event_container(config, dialect)
1166
+ return any(_relay_in_handlers(container.get(event, [])) for event in events)
1167
+
1168
+
1169
+ def merge_hooks(config: dict, registrations: dict[str, str], dialect: str) -> tuple[dict, bool]:
1170
+ """Add relay registrations (native event -> command) to a hook config dict."""
1171
+ changed = False
1172
+ if dialect == "antigravity-hooks-json":
1173
+ # agy keys .agents/hooks.json by hook NAME at the top level (no "hooks"
1174
+ # wrapper); register every handler under one ANTIGRAVITY_HOOK_GROUP group.
1175
+ # Other named groups (user/plugin hooks) sit alongside and are preserved.
1176
+ group = config.setdefault(ANTIGRAVITY_HOOK_GROUP, {})
1177
+ if not isinstance(group, dict):
1178
+ raise ProfileError(
1179
+ f"{ANTIGRAVITY_HOOK_GROUP!r} in the hooks file is not a table; "
1180
+ "fix or remove it before registering the Stop hook"
1181
+ )
1182
+ for native_event, command in registrations.items():
1183
+ handlers = group.setdefault(native_event, [])
1184
+ if not isinstance(handlers, list):
1185
+ raise ProfileError(
1186
+ f"hook event {native_event!r} under {ANTIGRAVITY_HOOK_GROUP!r} "
1187
+ "is not a list; fix the hooks file before re-running init"
1188
+ )
1189
+ if not _managed_hook_in_handlers(handlers):
1190
+ handlers.append(_hook_entry(dialect, command))
1191
+ changed = True
1192
+ return config, changed
1193
+ if dialect == "copilot-settings-json":
1194
+ config.setdefault("version", 1) # Copilot hook configs are versioned
1195
+ hooks = config.setdefault("hooks", {})
1196
+ for native_event, command in registrations.items():
1197
+ matchers = hooks.setdefault(native_event, [])
1198
+ # claude/codex/gemini nest handlers under "hooks"; copilot stores the
1199
+ # handler dict directly in the event list — the serialized scan covers
1200
+ # both shapes so a re-run stays idempotent for every dialect.
1201
+ if not _managed_hook_in_handlers(matchers):
1202
+ matchers.append(_hook_entry(dialect, command))
1203
+ changed = True
1204
+ return config, changed
1205
+
1206
+
1207
+ def _confined_to(target: Path, root: Path) -> bool:
1208
+ """True if ``target`` resolves *strictly below* ``root``.
1209
+
1210
+ The profile/manifest guards (``names_tree_root``/``is_absolute_path``/
1211
+ ``has_parent_ref``) are lexical and run at load, so they cannot see a *link*:
1212
+ a `skill_tree` or `hooks.config_path` naming a perfectly project-relative
1213
+ directory that happens to be a symlink out of the tree passes all three. This
1214
+ is the resolve-time backstop the worktree side already had inline
1215
+ (``worktree_flow`` compares resolved-vs-raw before writing); the ``init`` path
1216
+ reached mkdir/rmtree/write with no such check. Refuses on OSError rather than
1217
+ degrading: a slot that cannot be inspected is not safe to write through.
1218
+
1219
+ Strictly below, not merely inside, because ``is_relative_to`` is true for
1220
+ equal paths — the same "a path is relative to itself" hole this guard family
1221
+ exists to close in `provision_worktree`'s seed loop. `skill_tree = "skills"`
1222
+ where `project/skills` links back to `project` resolves to the root itself, and
1223
+ an equality-inclusive check would hand `_copy_skills` the project root: the
1224
+ bundled skill dirs land at top level, and `--force-skills` `rmtree`s any root
1225
+ directory whose name collides with one of them."""
1226
+ try:
1227
+ resolved, base = target.resolve(), root.resolve()
1228
+ return resolved != base and resolved.is_relative_to(base)
1229
+ except (OSError, RuntimeError):
1230
+ return False
1231
+
1232
+
1233
+ def _register_hooks(project: Path, profile: CLIProfile) -> int:
1234
+ if profile.hookless:
1235
+ print(f" no hooks needed ({profile.name}): HTTP/SSE transport")
1236
+ return 0
1237
+ config_path = project / profile.hooks.config_path
1238
+ if not _confined_to(config_path, project):
1239
+ print(f"FAIL: hooks config_path escapes the project ({profile.name}): {config_path}")
1240
+ return 1
1241
+ config_path.parent.mkdir(parents=True, exist_ok=True)
1242
+ config: dict = {}
1243
+ if config_path.is_file():
1244
+ try:
1245
+ config = json.loads(config_path.read_text(encoding="utf-8"))
1246
+ except json.JSONDecodeError:
1247
+ print(f"FAIL: {config_path} is not valid JSON; fix it and re-run init")
1248
+ return 1
1249
+ registrations = {
1250
+ native: _hook_command(project, profile, canonical)
1251
+ for native, canonical in profile.hooks.events.items()
1252
+ }
1253
+ config, changed = merge_hooks(config, registrations, profile.hooks.dialect)
1254
+ if changed:
1255
+ # atomic_write_text, never write_text (#379), the same rule
1256
+ # `_worktree_local_exclude` states for its bytes sibling. This is a
1257
+ # read-modify-REWRITE of a file `init` does not own: the parse above kept
1258
+ # the operator's permission allowlist, env, MCP entries and their own
1259
+ # hooks, and every one of them is re-serialized here. `"w"` TRUNCATES
1260
+ # before writing, so a short write (ENOSPC, a full quota) publishes a
1261
+ # PREFIX of that JSON — and unlike the ledgers, this failure is loud in
1262
+ # the worst way: the next `init` reads it back, fails json.loads, and
1263
+ # prints "is not valid JSON; fix it and re-run init" (above) at a human
1264
+ # whose file this tool just shredded. The helper leaves the original
1265
+ # untouched on any raise. follow_symlinks stays at the default, matching
1266
+ # the `write_text` it replaces: `_confined_to` above resolves the path and
1267
+ # refuses anything landing outside the project, so the only links reaching
1268
+ # this write point back INSIDE it — an in-repo indirection the operator
1269
+ # arranged, which a name-replacement would orphan on the first init. That
1270
+ # rules out the confined writers too: they are no-follow by construction,
1271
+ # so this site takes the #597 flag and nothing else.
1272
+ # `require_writable_target=True` is that flag. `os.replace` needs write
1273
+ # permission on the parent DIRECTORY, never on the entry it replaces, so a
1274
+ # settings.json the operator had marked read-only was rewritten anyway and,
1275
+ # because the mode is inherited, came back reading `0444` with nothing in
1276
+ # the permission bits to record it. This file is the operator's own — the
1277
+ # parse above kept their allowlist, env and MCP entries — so a read-only one
1278
+ # is a `PermissionError`, which is what the `write_text` this replaced
1279
+ # raised (#597).
1280
+ atomic_write_text(
1281
+ config_path, json.dumps(config, indent=2) + "\n", require_writable_target=True
1282
+ )
1283
+ print(f" hooks registered ({profile.name}): {config_path}")
1284
+ else:
1285
+ print(f" hooks already registered ({profile.name})")
1286
+ return 0
1287
+
1288
+
1289
+ def _walk_traversable_files(
1290
+ src,
1291
+ rel: str = "",
1292
+ _seen: frozenset[str] = frozenset(),
1293
+ _should_descend=None,
1294
+ _visit_dir=None,
1295
+ _suppress_errors: bool = True,
1296
+ ) -> Iterator[tuple[str, Traversable]]:
1297
+ """Yield the leaves of a Traversable tree as deterministic POSIX rels.
1298
+
1299
+ ``iterdir`` recursion deliberately descends symlinked source directories, unlike
1300
+ the renderer's ``rglob`` enumeration in :func:`_absent_renderer_sources`. A
1301
+ branch-local real-path set terminates cycles without dropping a second sibling
1302
+ that points at the same shared tree.
1303
+
1304
+ A directory the filesystem refuses to list is yielded as a leaf. That lets a
1305
+ copier decline content it cannot confirm while a result-side completeness check
1306
+ can still name the unreadable rel. Wheel Traversables have no real-path cycle leg.
1307
+
1308
+ ``_should_descend`` and ``_visit_dir`` are private copier plumbing. The first may
1309
+ prune before ``iterdir`` (preserving no-clobber when a destination file stands
1310
+ where the source has a directory); the second sees every successfully enumerated
1311
+ directory, including empty ones. Keeping materialization after enumeration means
1312
+ an unreadable source directory never leaves an empty destination behind.
1313
+ """
1314
+ if _is_dir(src):
1315
+ try:
1316
+ real = str(src.resolve()) if isinstance(src, Path) else None
1317
+ except (OSError, RuntimeError):
1318
+ if not _suppress_errors:
1319
+ raise
1320
+ yield rel, src
1321
+ return
1322
+ if real is not None and real in _seen:
1323
+ return
1324
+ if _should_descend is not None and not _should_descend(rel, src):
1325
+ return
1326
+ try:
1327
+ children = sorted(src.iterdir(), key=lambda entry: entry.name)
1328
+ except OSError:
1329
+ if not _suppress_errors:
1330
+ raise
1331
+ yield rel, src
1332
+ return
1333
+ if _visit_dir is not None and not _visit_dir(rel, src):
1334
+ return
1335
+ inner = _seen if real is None else _seen | {real}
1336
+ for child in children:
1337
+ child_rel = f"{rel}/{child.name}" if rel else child.name
1338
+ yield from _walk_traversable_files(
1339
+ child,
1340
+ child_rel,
1341
+ inner,
1342
+ _should_descend,
1343
+ _visit_dir,
1344
+ _suppress_errors,
1345
+ )
1346
+ return
1347
+ yield rel, src
1348
+
1349
+
1350
+ def _is_file(path) -> bool:
1351
+ """Answer whether ``path`` is a usable file, total over filesystem faults.
1352
+
1353
+ A refused probe and an absent/non-file path all mean the same thing to a reader
1354
+ or copier: there are no bytes it can promise to consume. Python <=3.13 raises on
1355
+ an entry below an unsearchable parent while 3.14 answers false; both fold to false
1356
+ here. The directory reading is deliberately different — see :func:`_is_dir`.
1357
+ """
1358
+ try:
1359
+ return path.is_file()
1360
+ except OSError:
1361
+ return False
1362
+
1363
+
1364
+ def _is_dir(path) -> bool:
1365
+ """Answer whether a walk may have unseen content below ``path``.
1366
+
1367
+ Refusal is true, not false: an unreadable source must reach the walk as a named
1368
+ leaf instead of collapsing into absence. Python 3.14 made ``Path.is_dir`` return
1369
+ false for faults older interpreters raise, so a false is re-asked with ``stat``.
1370
+ """
1371
+ try:
1372
+ if path.is_dir():
1373
+ return True
1374
+ except OSError:
1375
+ return True
1376
+ return _probe_refused(path)
1377
+
1378
+
1379
+ # pathlib's version-dependent private ignored-error sets, copied here so absence and
1380
+ # refusal retain the same meaning on every supported interpreter and platform.
1381
+ _ABSENCE_ERRNOS = (errno.ENOENT, errno.ENOTDIR, errno.EBADF, errno.ELOOP)
1382
+ _ABSENCE_WINERRORS = (
1383
+ 21, # ERROR_NOT_READY: drive exists but is not accessible
1384
+ 123, # ERROR_INVALID_NAME
1385
+ 1921, # ERROR_CANT_RESOLVE_FILENAME: broken self-referential symlink
1386
+ )
1387
+
1388
+
1389
+ def _probe_refused(path) -> bool:
1390
+ """True when a false pathlib convenience probe actually means I/O refusal."""
1391
+ if not isinstance(path, Path):
1392
+ # A wheel Traversable has no stat() method and no permission mode to recover.
1393
+ return False
1394
+ try:
1395
+ path.stat()
1396
+ except OSError as exc:
1397
+ return (
1398
+ exc.errno not in _ABSENCE_ERRNOS
1399
+ and getattr(exc, "winerror", None) not in _ABSENCE_WINERRORS
1400
+ )
1401
+ except ValueError:
1402
+ # Embedded-null and otherwise invalid paths are absent, not refused.
1403
+ return False
1404
+ return False
1405
+
1406
+
1407
+ def _occupied(path: Path) -> bool:
1408
+ """Whether a destination slot is taken, dangling symlinks included."""
1409
+ try:
1410
+ return path.exists() or path.is_symlink()
1411
+ except OSError:
1412
+ # A slot that cannot even be inspected is not safe to write through.
1413
+ return True
1414
+
1415
+
1416
+ def _copy_traversable(
1417
+ src,
1418
+ dst: Path,
1419
+ *,
1420
+ skip_existing: bool = False,
1421
+ worktree: Path | None = None,
1422
+ repo_root: Path | None = None,
1423
+ copied_paths: list[Path] | None = None,
1424
+ ) -> bool:
1425
+ """Recursively copy a Traversable tree, optionally confined to a worktree.
1426
+
1427
+ ``skip_existing`` remains the install helper's opt-in per-file no-clobber mode.
1428
+ Supplying ``worktree`` makes no-clobber mandatory and adds destination
1429
+ containment plus per-entry OSError degradation: provisioning never escapes the
1430
+ worktree through a link or crashes the run because one entry cannot be read.
1431
+ ``repo_root`` additionally refuses real source entries resolving outside the main
1432
+ checkout. Wheel Traversables have no source containment leg.
1433
+
1434
+ The shared walk classifies every source leaf through :func:`_is_file`; FIFOs,
1435
+ dangling links, and refused entries therefore have no copy/read path. Directory
1436
+ visits preserve main's existing empty-directory behavior and its boolean result:
1437
+ true means at least one directory or file actually landed, false means total no-op.
1438
+
1439
+ ``copied_paths``, when supplied, receives every destination path that actually
1440
+ landed — each file written and each directory this call created — in walk order.
1441
+ The boolean answers the whole call, which under ``skip_existing`` is only ever
1442
+ "at least one descendant landed"; a caller that must know whether ONE named path
1443
+ landed cannot recover that from the boolean, and must not infer it from the
1444
+ entry's presence in a copied-something ledger either, because the no-clobber legs
1445
+ above skip occupied destinations one at a time and silently. Recording per path is
1446
+ what makes that question answerable: the returned boolean is exactly
1447
+ ``bool(appended entries)``, and membership is exact rather than parent-scoped
1448
+ (#592). Appends only — the caller owns the list and may share one across calls.
1449
+ """
1450
+ copied = False
1451
+ no_clobber = skip_existing or worktree is not None
1452
+
1453
+ def source_contained(entry) -> bool:
1454
+ if repo_root is None or not isinstance(entry, Path):
1455
+ return True
1456
+ try:
1457
+ return entry.resolve().is_relative_to(repo_root)
1458
+ except (OSError, RuntimeError):
1459
+ return False
1460
+
1461
+ def target_for(rel: str) -> Path:
1462
+ return dst.joinpath(*rel.split("/"))
1463
+
1464
+ def target_contained(target: Path) -> bool:
1465
+ if worktree is None:
1466
+ return True
1467
+ try:
1468
+ return target.resolve().is_relative_to(worktree)
1469
+ except (OSError, RuntimeError):
1470
+ return False
1471
+
1472
+ def should_descend(rel: str, entry) -> bool:
1473
+ target = target_for(rel)
1474
+ if not source_contained(entry) or not target_contained(target):
1475
+ return False
1476
+ if no_clobber and _occupied(target) and not _is_dir(target):
1477
+ # A file or dangling link sits where the source has a directory. It wins;
1478
+ # mkdir must never replace it or write through its target.
1479
+ return False
1480
+ return True
1481
+
1482
+ def visit_dir(rel: str, entry) -> bool:
1483
+ nonlocal copied
1484
+ # Recheck after enumeration: iterdir may block while a source or destination
1485
+ # component is replaced, so the pre-walk containment result can go stale.
1486
+ if not should_descend(rel, entry):
1487
+ return False
1488
+ target = target_for(rel)
1489
+ existed = _occupied(target)
1490
+ try:
1491
+ target.mkdir(parents=True, exist_ok=True)
1492
+ except OSError:
1493
+ if worktree is None:
1494
+ raise
1495
+ return False
1496
+ if not existed:
1497
+ copied = True
1498
+ if copied_paths is not None:
1499
+ copied_paths.append(target)
1500
+ return True
1501
+
1502
+ for rel, child in _walk_traversable_files(
1503
+ src,
1504
+ _should_descend=should_descend,
1505
+ _visit_dir=visit_dir,
1506
+ _suppress_errors=worktree is not None,
1507
+ ):
1508
+ if not _is_file(child) or not source_contained(child):
1509
+ continue
1510
+ target = target_for(rel)
1511
+ if not target_contained(target) or (no_clobber and _occupied(target)):
1512
+ continue
1513
+ try:
1514
+ target.parent.mkdir(parents=True, exist_ok=True)
1515
+ if isinstance(child, Path):
1516
+ # Preserve modes for filesystem sources (notably vendor/bin/*).
1517
+ shutil.copy2(child, target)
1518
+ else:
1519
+ # Zip-imported Traversables expose no stat: content only.
1520
+ target.write_bytes(child.read_bytes())
1521
+ except OSError:
1522
+ if worktree is None:
1523
+ raise
1524
+ # `copy2` is `copyfile` FOLLOWED BY `copystat`, so a destination
1525
+ # filesystem that refuses the utime/chmod raises with the bytes already
1526
+ # fully written (measured). Reading that as "nothing happened"
1527
+ # under-reports twice: the entry is journaled as a no-op seed and loses
1528
+ # its `git add -A` shield, and the file's provenance goes missing — so an
1529
+ # unparseable config seeding really did supply would be blamed on the
1530
+ # branch (#592). Deleting the survivor instead would be worse: the
1531
+ # content is correct and only its metadata was refused, and dropping it
1532
+ # is the absent-config stall (#471). Count whatever survived — the
1533
+ # no-clobber leg above proved this slot was empty, so anything here now
1534
+ # is ours. A partial `write_bytes` on the zip leg counts too: the bytes
1535
+ # came from that source, and a re-arm re-seeds them whole.
1536
+ if not _occupied(target):
1537
+ continue
1538
+ copied = True
1539
+ if copied_paths is not None:
1540
+ copied_paths.append(target)
1541
+ return copied
1542
+
1543
+
1544
+ # The shield's gate is the PROJECT support floor, `verify.GIT_FLOOR`, not a floor of
1545
+ # its own. `extensions.worktreeConfig` and `git config --worktree` — the two things
1546
+ # this shield is built out of — arrived in git 2.20 (git-worktree(1)), and that fact
1547
+ # is still true; it simply stopped being the threshold. Keeping a second, lower
1548
+ # number here is what used to force this module to write 2.20-era code, and it also
1549
+ # read as the project's supported range from the outside (verify.py cited it as one).
1550
+ # One floor, named once, in the module that owns the git chokepoint.
1551
+
1552
+ # The shield's mutual exclusion, held in the repository's COMMON dir so every
1553
+ # worktree of a repo contends on one file — a per-worktree gitdir would give each
1554
+ # concurrent run its own lock and exclude nothing. A dedicated file, never config
1555
+ # or the exclude itself, per `file_lock`'s contract: the lock rides an open fd's
1556
+ # inode, so anything written through `atomic_replace` swaps that inode out from
1557
+ # under later acquirers. Zero bytes, and inside `.git` — never in the working tree,
1558
+ # so it is not something the operator's `git add -A` can see.
1559
+ _SHIELD_LOCK_NAME = "froid-loop-shield.lock"
1560
+
1561
+ # THE ALTERNATIVE ARCHITECTURE, EVALUATED AND REJECTED — recorded here the way the
1562
+ # `--includes` note below is, so a later reader does not re-propose it as a
1563
+ # simplification of the whole block.
1564
+ #
1565
+ # Every finding in this family (an empty seed plus an activation that shadows what
1566
+ # the seed failed to copy) exists because the shield SHADOWS a config key. One
1567
+ # design dissolves the class outright: drop `core.excludesFile` entirely and instead
1568
+ # unstage the tool paths in the orchestrator's own commit path — `git reset --
1569
+ # <paths>` after the `add -A` at `verify.py:1891` and `:1932`. No shadow, so no
1570
+ # seed, no `_shield_inherited_excludes`, no permanent `extensions.worktreeConfig`,
1571
+ # no version floor. It is MECHANICALLY REACHABLE: those adds are the orchestrator's
1572
+ # own, not an LLM's (no skill in `data/skills/` runs git).
1573
+ #
1574
+ # Rejected on the merits, for two reasons that are about correctness rather than
1575
+ # scope:
1576
+ #
1577
+ # - It converts AMBIENT protection into CALL-SITE protection. An ignore binds
1578
+ # whoever runs the add; a reset covers only the call sites we remember to patch.
1579
+ # These adds run around disposable coding-CLI sessions that have shell access and
1580
+ # can stage and commit on their own initiative, so the shield has to hold for a
1581
+ # `git add -A` froid-loop never issued. That actor is the one we cannot constrain,
1582
+ # and the reset design is the one that stops protecting against it.
1583
+ # - It leaves the tool files permanently UNTRACKED-VISIBLE in the worktree. Every
1584
+ # `git status` a session runs would list the skill trees, the per-CLI hook config
1585
+ # and the seeded configs as untracked — inviting exactly the commit the shield
1586
+ # exists to prevent, from exactly that actor.
1587
+ #
1588
+ # The shadow is also structural, not an artifact of this implementation, so there is
1589
+ # no third way to look for: gitignore(5) documents four pattern sources and the
1590
+ # per-repo one is `$GIT_COMMON_DIR/info/exclude` — the COMMON dir, shared by every
1591
+ # worktree. There is no per-worktree exclude file, `core.excludesFile` is singular,
1592
+ # and git never concatenates the key across scopes. Checked against current sources
1593
+ # at git 2.55.0, the newest release.
1594
+
1595
+
1596
+ def _shield_shared_config(worktree: Path, shared: str, key: str, *opts: str) -> bytes | None:
1597
+ """`key`'s raw value in the SHARED config, or None when it is definitely ABSENT.
1598
+
1599
+ Raises `GitError` when git did not answer. **rc 1 is the only non-zero rc that
1600
+ means "no such key"** (#384) — reading any other non-zero rc as an absence
1601
+ degrades the `core.bare` / `core.worktree` gates OPEN, and what they guard is
1602
+ irreversible: enabling `extensions.worktreeConfig` drops the exception that
1603
+ confined those keys to the main worktree, after which a genuine `core.worktree`
1604
+ starts applying to every linked worktree.
1605
+
1606
+ The rc mapping, stable across the supported range, `GIT_FLOOR` to current git:
1607
+
1608
+ absent key ......................... 1 malformed config file ..... 128
1609
+ missing file ....................... 1 --type=bool on a non-bool . 128
1610
+ `--file` naming a directory ........ 1 key present ................ 0
1611
+ unreadable file (EACCES, non-root) . 1 empty or valueless key ..... 0
1612
+
1613
+ git-config(1) prefaces its own list with "Some exit codes are:", i.e. the list is
1614
+ not closed, and it documents a malformed file as ret=3 while git actually answers
1615
+ 128 — so a `{0,1}`-closed reading is wrong on git's documentation AND on its
1616
+ behavior. Hence a funnel rather than a per-rc taxonomy.
1617
+
1618
+ THE RESIDUAL THIS CANNOT CLOSE: a momentarily missing or unreadable `.git/config`
1619
+ answers **rc 1**, byte-identical to "no such key" — all four rc-1 rows above are
1620
+ the same answer from here. git exposes no way to separate them, so this is a
1621
+ bound on the interface rather than a defect. Deliberately NOT narrowed with a
1622
+ pre-`--get` stat: that is TOCTOU, which shrinks the window instead of closing it,
1623
+ adds a syscall to every probe, and trades a very rare silent-wrong for a less
1624
+ rare noisy-degrade. Bounded in practice because git rewrites config atomically
1625
+ (lock + rename), so only a non-git tool truncating in place can produce it.
1626
+
1627
+ Returns the value RAW and undecoded — `core.worktree` is a path — so callers test
1628
+ presence with `is not None`, never truthiness. See the call sites for why.
1629
+ """
1630
+ answer = git_bytes(worktree, "config", "--file", shared, *opts, "--get", key)
1631
+ if answer.returncode == 0:
1632
+ return answer.stdout
1633
+ if answer.returncode == 1:
1634
+ return None
1635
+ detail = os.fsdecode(answer.stderr).strip() or f"git exited {answer.returncode}"
1636
+ raise GitError(f"git could not read {key} from the shared config: {detail}")
1637
+
1638
+
1639
+ _SHARED_REPOSITORY_OCTAL = re.compile(r"[0-7]+")
1640
+
1641
+
1642
+ def _shared_repository_is_private(value: str) -> bool:
1643
+ """Does `core.sharedRepository = value` leave the repository private to its owner?
1644
+
1645
+ git's parse, mirrored (`setup.c::git_config_perm`): the keyword `umask` and the
1646
+ false booleans are private, `group`/`all`/`world`/`everybody` and the true
1647
+ booleans are not, and an octal value is a FILEMODE with the legacy 0/1/2
1648
+ special-cased ahead of it.
1649
+
1650
+ For a filemode git masks the value with 0666, so private is `& 0066 == 0` — NOT
1651
+ "no execute bits" and NOT "equals 0600". `0711` IS private, because the mask
1652
+ discards the execute bits; `0755` is NOT, because its group/other READ bits
1653
+ survive it. Those two cases are why this is a mask test and not a literal set.
1654
+
1655
+ `(mode & 0600) != 0600` is git's own `die()` condition, so such a value reaches
1656
+ us only in a repository git already refuses to operate on. It stays off the
1657
+ accept side with every other value git rejects.
1658
+
1659
+ THE PATTERN IS DELIBERATELY STRICTER THAN `strtol`, in the refusing direction.
1660
+ git accepts leading whitespace and a leading `+` (`+0600` is private to git)
1661
+ which `[0-7]+` refuses — a false refusal, which this gate is contracted to
1662
+ prefer. Strictness in the other direction is the load-bearing half: Python's
1663
+ `int(value, 8)` accepts `0o600` and `0_600`, which git REJECTS, so parsing with
1664
+ Python's own rules would accept a repository git cannot open.
1665
+
1666
+ THE EMPTY STRING IS EXCLUDED ON PURPOSE, and the `+` quantifier is what excludes
1667
+ it. `strtol("")` converts nothing, leaves `*end == 0` and yields 0, so git reads
1668
+ an explicitly empty value as PERM_UMASK, i.e. private. It is refused anyway,
1669
+ because `--get` answers an empty value and a VALUELESS `sharedRepository` line
1670
+ (PERM_GROUP, shared) with the same rc 0 and the same lone newline. What is
1671
+ refused there is the ambiguity, not the value — see the caller.
1672
+ """
1673
+ if value == "umask" or value.lower() in ("false", "no", "off"):
1674
+ return True
1675
+ if not _SHARED_REPOSITORY_OCTAL.fullmatch(value):
1676
+ return False
1677
+ mode = int(value, 8)
1678
+ if mode == 0: # PERM_UMASK
1679
+ return True
1680
+ # The legacy 1 (PERM_GROUP) and 2 (PERM_EVERYBODY) need no special case here,
1681
+ # though git special-cases them ahead of its filemode branch: neither satisfies
1682
+ # `& 0600`, so both land on the refusal this line already returns. A
1683
+ # `mode in (1, 2)` arm would be indistinguishable from dead code (#384).
1684
+ return mode & 0o600 == 0o600 and mode & 0o066 == 0
1685
+
1686
+
1687
+ def _shield_shared_repository(worktree: Path, common_dir: Path) -> str | None:
1688
+ """Refuse a repository configured to be SHARED BETWEEN OS USERS, else None.
1689
+
1690
+ A repository shared between OS users is not a supported configuration
1691
+ (maintainer decision, #384): the shield creates files of its own inside `.git`,
1692
+ and making those work across OS users is out of scope. Refusing up front fails
1693
+ LOUDLY and identically for every user — journaled and notified — rather than
1694
+ behaving differently depending on who provisioned first.
1695
+
1696
+ The gate runs ABOVE the shield's lock, so nothing is created in a shared
1697
+ repository at all. That is safe here and would not be for
1698
+ `extensions.worktreeConfig`: this key is static configuration the tool never
1699
+ writes, so there is no shared mutable state to serialize. It adds no version
1700
+ floor either — a plain `--get` with no `--type=`. `--type=` was itself a reason to
1701
+ keep the version gate above the probes when the gate was 2.20; at `GIT_FLOOR` it
1702
+ is far below the floor, and the gate leads for its unreadable-answer arm instead
1703
+ (see `_shield_enable_worktree_config`).
1704
+
1705
+ AN ALLOWLIST, NOT AN ENUMERATION OF SHARED SHAPES: git accepts keywords,
1706
+ booleans, the legacy 0/1/2 and bare octal modes, so enumerating the shared ones
1707
+ leaves every value git adds later — and every value git rejects — silently
1708
+ opening the gate. Values git rejects really do reach here, since `rev-parse
1709
+ --absolute-git-dir` answers rc 0 for every one of them, `banana` included; the
1710
+ "it would have fatalled earlier" reasoning that fits other probes in this file
1711
+ does not hold. They fall off the allowlist deliberately: such a repository is
1712
+ already unusable (`git status` exits 128), so refusing costs nothing while
1713
+ guessing at a malformed value would.
1714
+
1715
+ AN EMPTY VALUE AND A VALUELESS `sharedRepository` LINE ARE INDISTINGUISHABLE AND
1716
+ MEAN OPPOSITE THINGS: explicitly empty is PERM_UMASK (private), a valueless line
1717
+ is PERM_GROUP (shared), and `--get` answers both rc 0 with a lone b"\\n". Nothing
1718
+ separates them, so the empty answer REFUSES — a false refusal is a reported skip,
1719
+ a false accept is the bug this gate prevents.
1720
+
1721
+ `removesuffix("\\n")`, NEVER `.strip()`: `--get` returns edge whitespace VERBATIM
1722
+ (a quoted `" umask"` comes back as b" umask\\n"), so stripping would widen a value
1723
+ git itself rejects straight into the accept set below. The case handling is
1724
+ asymmetric because git's is — keywords compared with strcmp, booleans with
1725
+ strcasecmp — so `FALSE` and `Off` are accepted while `UMASK` and `GROUP` are
1726
+ values git REJECTS.
1727
+
1728
+ SCOPE, NARROWER THAN GIT'S OWN RESOLUTION: the read names the repository's
1729
+ SHARED CONFIG. git honors `core.sharedRepository` from a GLOBAL config too, so
1730
+ an operator's global setting is not consulted here and such a repository is
1731
+ still shielded — deliberately, since a global preference is not a statement that
1732
+ THIS repository is shared. What is refused is a repository CONFIGURED as shared,
1733
+ the shape `git init --shared` writes (`--shared=group` records the legacy
1734
+ `sharedrepository = 1`).
1735
+
1736
+ WORKTREE SCOPE is unread too, and git DOES honor `core.sharedRepository` from a
1737
+ linked worktree's own `config.worktree`, which the `--file` read below answers
1738
+ rc 1 for. What makes that unreachable is the provisioning flow rather than
1739
+ anything here: the shield only ever runs on a worktree this process just created
1740
+ (`workspace.open_unit_workspace` -> `verify.worktree_add` ->
1741
+ `worktree_flow.provision_worktree`), `git worktree add` creates the admin dir
1742
+ with NO `config.worktree`, a re-mount prunes `.git/worktrees/<id>` whole, the
1743
+ MAIN worktree's `.git/config.worktree` does not reach a linked one, and the
1744
+ tree's only worktree-scoped write is the shield's own `core.excludesFile`. So
1745
+ the value has no writer and no window. If a re-provision path onto an EXISTING
1746
+ worktree is ever added, or anything upstream starts writing the unit worktree's
1747
+ config, this read must grow a second `--file <git_dir>/config.worktree` probe in
1748
+ the same change.
1749
+ """
1750
+ raw = _shield_shared_config(worktree, str(common_dir / "config"), "core.sharedRepository")
1751
+ if raw is None:
1752
+ return None
1753
+ value = os.fsdecode(raw).removesuffix("\n")
1754
+ if _shared_repository_is_private(value):
1755
+ return None
1756
+ # {value!r}: operator-authored text going into a journaled reason, and repr
1757
+ # neutralizes a newline in it — as the version gate above does.
1758
+ return (
1759
+ f"skipped the git-add shield ({worktree}): the repository's shared config sets "
1760
+ f"core.sharedRepository = {value!r}, and a repository shared between OS users is "
1761
+ "not a supported configuration — the provisioned tool files are not shielded "
1762
+ "from the unit's `git add -A`"
1763
+ )
1764
+
1765
+
1766
+ def _shield_enable_worktree_config(worktree: Path, common_dir: Path) -> tuple[str | None, bool]:
1767
+ """May `git config --worktree` be made writable in this repo? `(reason, needs_enable)`.
1768
+
1769
+ PROBE ONLY — it does not write. `needs_enable` is True when every gate passed
1770
+ and the caller must still set `extensions.worktreeConfig` itself; False when the
1771
+ repo already carries it (or when `reason` is set and nothing may be written).
1772
+
1773
+ Answering is split from writing because enabling the extension is a PERMANENT
1774
+ repo-format change: performed here it precedes the seed, the write and the
1775
+ activation, so any degrade below it left the repo marked for a shield that never
1776
+ applied. The caller enables it immediately before the activation and rolls the
1777
+ flag back when the shield then fails to hold — including when the ENABLE ITSELF
1778
+ fails in a way that leaves the outcome unknown. The flag outlives a failed
1779
+ shield only where the rollback was DECLINED because a sibling worktree depends
1780
+ on it, or could not be made at all; the reason says which.
1781
+
1782
+ `extensions.worktreeConfig` is what makes a per-worktree config file exist at
1783
+ all; without it `git config --worktree` either refuses outright (a repo with
1784
+ linked worktrees) or silently writes the SHARED config.
1785
+
1786
+ Enabling it is refused in the two shapes git-worktree(1) (CONFIGURATION FILE)
1787
+ calls out: with `core.bare = true` or `core.worktree` in the shared config,
1788
+ enabling drops the exception that confines those keys to the main worktree, so
1789
+ they would start applying to every worktree. Git's remedy is a repo-layout edit
1790
+ the installer will not make behind an operator's back, so the shield degrades
1791
+ instead.
1792
+
1793
+ It is refused a THIRD way, which is this project's rather than git's: an
1794
+ `extensions.worktreeConfig` already PRESENT but not `true` is an operator's
1795
+ explicit disable, and enabling over it would rewrite that declaration
1796
+ permanently — the same discipline as above, applied to the flag itself (#396).
1797
+
1798
+ The version gate and the refusal probes STAY HERE, first. The gate is the
1799
+ PROJECT support floor (`verify.GIT_FLOOR`), not a capability threshold of this
1800
+ shield's own: `extensions.worktreeConfig` and `git config --worktree` arrived in
1801
+ git 2.20 and `--type=` in 2.18, all well below the floor, so on any supported git
1802
+ every probe below can answer. Refusing an unsupported git is therefore a POLICY
1803
+ decision, not a capability one — the enable is a permanent repo-format change,
1804
+ and git-worktree(1) says older git refuses a repository carrying the extension,
1805
+ so a git this project neither tests nor supports is exactly the one to withhold
1806
+ that write from. `_shield_shared_config`'s raise on any rc but 0 or 1 is defence
1807
+ in depth BEHIND this gate, not licence to relax it. The caller's `--type=path`
1808
+ excludesFile read rests on it too.
1809
+
1810
+ The run, sweep and resume entrypoints already refuse an under-floor git outright
1811
+ (`cli._reject_under_floor_git`), so no supported path reaches this gate with one.
1812
+ It stays because its OTHER arm still fires on a perfectly current host: a git
1813
+ that cannot be spawned, times out, or answers unparseably reads as below the
1814
+ floor, and none of those may authorize the write.
1815
+ """
1816
+ # Polarity note: this gate reads an unreadable answer as REFUSE, while the funnel
1817
+ # below reads a bad rc as "git did not answer" and raises. Both fail closed, by
1818
+ # opposite-looking means, and unifying them would reverse one — an unanswerable
1819
+ # `git version` must refuse here, not degrade into a question about a key. Nor is
1820
+ # it a repo-config check: `git version` does no repository setup, so it exits 0
1821
+ # where `rev-parse` fatals 128 on a malformed `.git/config`. `git_below_floor`
1822
+ # folds all three unreadable shapes (bad rc, unparseable text, empty text) into
1823
+ # the one refusal this needs.
1824
+ if (found := git_below_floor(worktree)) is not None:
1825
+ return (
1826
+ f"skipped the git-add shield ({worktree}): froid-loop supports git "
1827
+ f"{git_floor_text()} and newer, but git answered {found!r} — the shield "
1828
+ "enables extensions.worktreeConfig, a permanent repo-format change, and "
1829
+ "that is not made on an unsupported git"
1830
+ ), False
1831
+ # EVERY read below names the SHARED config as a file rather than going by scope
1832
+ # (`--local` resolves to it today; naming it keeps the checks honest if that
1833
+ # stops being true). Load-bearing for the first of them: `extensions.*` is
1834
+ # repo-format state git honors only from the repo's own config, so a
1835
+ # `worktreeConfig = true` in `~/.gitconfig` answering an unscoped read would be a
1836
+ # false "already enabled" — the write below skipped, the activation left to fatal
1837
+ # for want of the extension.
1838
+ #
1839
+ # And NO `--includes` on any of the three, which is the asymmetry a later reader
1840
+ # will want to "fix" — `--file` without it does miss a value an `include.path`
1841
+ # supplies. Adding it breaks both gates, because git's worktree SETUP reads
1842
+ # `core.bare` / `core.worktree` from the common config LITERALLY: it goes through
1843
+ # `git_config_from_file`, which does not respect includes (config.h).
1844
+ #
1845
+ # - The REFUSAL probes would start refusing repos git is perfectly happy to
1846
+ # shield, leaving the provisioned tool files unshielded: include-supplied
1847
+ # values of either key do nothing, while literal ones move a linked worktree's
1848
+ # toplevel to the main checkout or fatal every command. (`core.bare` only
1849
+ # because `is_bare_repository()` also requires no work tree, which every
1850
+ # worktree here has — its inertness is a conjunction, not an absence.)
1851
+ # - The ENABLED probe is worse: an include-supplied `extensions.worktreeConfig`
1852
+ # is not honored either (`git config --worktree` still fatals), because the
1853
+ # `config.worktree` read is gated on the flag harvested by that same
1854
+ # include-blind setup read — so `--includes` there is the same false "already
1855
+ # enabled", and nothing is shielded.
1856
+ #
1857
+ # All of that is undocumented IMPLEMENTATION DETAIL, not a compatibility
1858
+ # contract: git has converted reads to respect includes before (2.39, protected
1859
+ # config). Flag availability was never the question: `--includes` dates to git
1860
+ # 1.7.10, far below the floor the gate above enforces.
1861
+ shared = str(common_dir / "config")
1862
+ carried = _shield_shared_config(worktree, shared, "extensions.worktreeConfig", "--type=bool")
1863
+ if carried is not None and os.fsdecode(carried).strip() == "true":
1864
+ return None, False # already carried: nothing for the caller to write
1865
+ if carried is not None:
1866
+ # Present but NOT true: enabling over an operator's explicit disable is a stronger
1867
+ # intervention than enabling from ABSENT, and it is the SUCCESS path that does the
1868
+ # lasting damage — rewriting that declaration to `true` forever, unjournaled. The
1869
+ # shield degrades instead, which also puts #396's rollback deletion out of reach.
1870
+ #
1871
+ # `--type=bool` normalized the spelling away (`off`/`no`/`0`/`FALSE` all read back
1872
+ # `false`, measured at 2.34.1 and 2.55.0), so re-read it RAW for the reason and
1873
+ # neutralize it as the sharedRepository arm above does. A GitError from that read
1874
+ # propagates — the caller's tail degrades, and still nothing is enabled. `carried`
1875
+ # stands in only if the key stops existing between the two reads.
1876
+ raw = _shield_shared_config(worktree, shared, "extensions.worktreeConfig")
1877
+ value = os.fsdecode(carried if raw is None else raw).removesuffix("\n")
1878
+ return (
1879
+ f"skipped the git-add shield ({worktree}): the repository's shared config sets "
1880
+ f"extensions.worktreeConfig = {value!r}, explicitly disabling it, and the shield "
1881
+ "will not override an operator's declaration — the provisioned tool files are "
1882
+ "not shielded from the unit's `git add -A`"
1883
+ ), False
1884
+ bare = _shield_shared_config(worktree, shared, "core.bare", "--type=bool")
1885
+ if bare is not None and os.fsdecode(bare).strip() == "true":
1886
+ refused = "core.bare = true"
1887
+ elif _shield_shared_config(worktree, shared, "core.worktree") is not None:
1888
+ # `is not None`, NEVER truthiness: any value refuses, including an empty one.
1889
+ # `core.worktree = ""` and a valueless `worktree` line both answer rc 0 with a
1890
+ # lone b"\n", so truthiness holds today — but one `.strip()` makes it b"",
1891
+ # which is falsy, re-opening this gate for a key that IS set. The value is
1892
+ # never decoded or inspected: this key is a path and its mere PRESENCE refuses,
1893
+ # unlike `core.bare`, where only a true value disqualifies.
1894
+ refused = "core.worktree"
1895
+ else:
1896
+ # Cleared, not enabled. The write itself is the caller's, deferred to the
1897
+ # last moment before the activation (see the docstring).
1898
+ return None, True
1899
+ return (
1900
+ f"skipped the git-add shield ({worktree}): the repository's shared config sets "
1901
+ f"{refused}, which git requires moving into the main worktree's config.worktree "
1902
+ "before extensions.worktreeConfig may be enabled"
1903
+ ), False
1904
+
1905
+
1906
+ def _shield_undo_extension(worktree: Path, git_dir: Path, common_dir: Path) -> str:
1907
+ """Undo the `extensions.worktreeConfig` THIS provisioning just enabled.
1908
+
1909
+ Returns `""` when the repository is back in the state it was found in, or a
1910
+ clause naming the fault otherwise — never raises: every caller is already
1911
+ reporting a degrade, and this says what it could not undo.
1912
+
1913
+ Callers MUST gate this on having enabled the flag themselves, or on not having
1914
+ been able to FIND OUT whether they did — which is what the enable's own raise
1915
+ means. Unsetting one the repository already carried would stop git reading the
1916
+ `config.worktree` files other worktrees may depend on, so the rollback is
1917
+ conditional, never unconditional cleanup.
1918
+
1919
+ That caller-side gate is necessary and NOT sufficient (#384): `needs_enable`
1920
+ records what the PROBE saw, and the enable is an idempotent rc-0 no-op against a
1921
+ flag already `true`, so two concurrent provisionings that both probed an absent
1922
+ flag both believe they own it, and whichever one's activation fails would unset a
1923
+ flag the other's LIVE shield depends on — leaving its tool files stageable
1924
+ mid-run. The caller serializes the transaction under a repository lock, but a lock
1925
+ binds only its holders: an operator, an older froid-loop, or `git sparse-checkout`
1926
+ can set the same flag outside it.
1927
+
1928
+ EXCLUDING OUR OWN `git_dir` IS LOAD-BEARING: a `config.worktree` there is the
1929
+ half-written product of the very activation whose failure prompted this rollback
1930
+ (a timeout can leave one behind), so counting it as a dependent would suppress
1931
+ the rollback.
1932
+
1933
+ WHAT IT DOES NOT COVER: a non-lock-taking party between its own enable and its
1934
+ own activation owns the flag while having written nothing yet, so the scan cannot
1935
+ see it and this rollback unsets it. Bounded — its `git config --worktree` then
1936
+ fatals for want of the extension (rc 128 — a sibling exists by construction here;
1937
+ in a single-worktree repo `--worktree` instead exits 0 and writes the SHARED
1938
+ config), so its shield degrades with a REPORTED reason rather than going silently
1939
+ inert. The opposite error is accepted too: a stale admin dir git has not pruned yet
1940
+ counts as a dependent, costing a flag left enabled and nothing else.
1941
+ """
1942
+ try:
1943
+ # The main worktree's own, then every SIBLING's — never ours (above).
1944
+ others = [common_dir / "config.worktree"]
1945
+ others += [
1946
+ d / "config.worktree"
1947
+ for d in (common_dir / "worktrees").iterdir()
1948
+ if d.resolve() != git_dir
1949
+ ]
1950
+ dependent = next((str(p) for p in others if p.exists()), None)
1951
+ except (OSError, RuntimeError, UnicodeError) as e:
1952
+ # Conservative on purpose, and the asymmetry is the argument: skipping
1953
+ # leaves a reported flag this call did not earn, while unsetting one that IS
1954
+ # depended on silently un-shields a live worktree. So an unreadable scan
1955
+ # counts as a dependent.
1956
+ #
1957
+ # The tuple mirrors the caller's tail minus `GitError` (nothing here spawns
1958
+ # git), and the members beyond `OSError` are what keeps the "never raises"
1959
+ # promise true: on the 3.11/3.12 floor `Path.resolve()` raises `RuntimeError`
1960
+ # for a symlink loop rather than `OSError`, and `UnicodeError` covers the
1961
+ # `fsdecode` of a worktree admin-dir name on Windows.
1962
+ dependent = f"the scan for sibling worktrees failed ({e})"
1963
+ if dependent is not None:
1964
+ return (
1965
+ "; extensions.worktreeConfig was deliberately LEFT enabled — "
1966
+ f"{dependent} exists, so another worktree's shield depends "
1967
+ "on the flag and unsetting it would stop git reading that file"
1968
+ )
1969
+ try:
1970
+ # `--unset-all`, and rc 5 counts as SUCCESS: an uncertain ENABLE routed here
1971
+ # makes an absent flag reachable, and `--unset` of an absent key exits 5 —
1972
+ # reporting that as a failed rollback would be false.
1973
+ #
1974
+ # `--unset` alone could not simply treat 5 as success, because git-config(1)
1975
+ # gives that code TWO meanings — the key did not exist, or MULTIPLE LINES
1976
+ # matched — so a doubled key that SURVIVED would report a clean rollback.
1977
+ # Stable across the supported range to current git: `--unset` against a doubled key
1978
+ # exits 5 and removes NOTHING, while `--unset-all` exits 0 and removes both
1979
+ # lines, collapsing rc 5 to the single meaning "no line matched".
1980
+ undone = git_bytes(worktree, "config", "--unset-all", "extensions.worktreeConfig")
1981
+ if undone.returncode in (0, 5):
1982
+ return ""
1983
+ detail = os.fsdecode(undone.stderr).strip() or f"git exited {undone.returncode}"
1984
+ except (GitError, UnicodeError) as e:
1985
+ # the rollback's OWN git can time out or fail to spawn: a read-only `.git` or
1986
+ # a dead git fails this unset for the same reason it failed the activation.
1987
+ #
1988
+ # `UnicodeError` is the `fsdecode` of git's stderr one line up (#394): Windows
1989
+ # decodes utf-8/surrogatepass, which REJECTS a lone invalid byte, so without it
1990
+ # a codec fault escapes a function contracted never to raise (POSIX decodes
1991
+ # with surrogateescape and never raises). `OSError`/`RuntimeError` are
1992
+ # deliberately absent, so this is NOT a copy of the sibling scan's tuple above:
1993
+ # nothing in this block resolves a path, which is what those two are there for.
1994
+ detail = str(e)
1995
+ # Both clauses HEDGE whether this shield set the flag, and must: reached from the
1996
+ # enable's own raise, a spawn failure can kill the enable and this unset alike,
1997
+ # having written nothing at all. No precision is lost — `needs_enable` is
1998
+ # probe-derived and cannot tell "we enabled it" from "we both thought we did".
1999
+ return (
2000
+ "; extensions.worktreeConfig could NOT be "
2001
+ f"rolled back ({detail}) — if this shield set the flag, the repository keeps "
2002
+ "a permanent format change that shields nothing"
2003
+ )
2004
+
2005
+
2006
+ # The Git for Windows FORK patches `xdg_config_home_for` (`path.c`) to prefer
2007
+ # `%APPDATA%/Git/<file>` over the `$HOME/.config/git/<file>` upstream computes,
2008
+ # from this version onward (absent at 2.45, absent upstream at every version).
2009
+ # `_shield_home_git_ignore` gates on it (#403).
2010
+ _APPDATA_IGNORE_GIT = (2, 46)
2011
+
2012
+
2013
+ def _shield_file_exists(candidate: Path) -> bool:
2014
+ """git's OWN existence predicate, which is `lstat(f, &sb) == 0` (`dir.c`).
2015
+
2016
+ Deliberately NOT `Path.is_file()`. `lstat` succeeds on a DIRECTORY and on a BROKEN
2017
+ SYMLINK, so `xdg_config_home_for` selects those exactly as it selects a regular
2018
+ file, and a narrower test here would reject what the fork accepts — sending this
2019
+ module down the `$HOME` arm git is NOT reading, which is #403's over-ignore
2020
+ direction rather than a safe fall-through.
2021
+
2022
+ Selecting is not the same as being able to MIRROR, and the two non-regular shapes
2023
+ part company right there — the caller distinguishes them:
2024
+
2025
+ - a BROKEN SYMLINK is dropped by git's own `access_or_warn(..., R_OK)` gate, whose
2026
+ `ENOENT` counts as an ignorable missing file, so git loads no patterns and runs
2027
+ on. The caller's `is_file()` seeds nothing, which mirrors that exactly.
2028
+ - a DIRECTORY passes that same `access(R_OK)` gate, so git goes on to
2029
+ `add_patterns_from_file_1` and `die("cannot use %s as an exclude file")`. Git
2030
+ does not run at all, and an empty seed would model a FATAL as a permissive
2031
+ success — see `_shield_home_git_ignore`, which refuses that shape.
2032
+
2033
+ Swallows `OSError` alone — a candidate this process cannot stat is one the shield
2034
+ must treat as absent, exactly as `file_exists` reports a failed `lstat`.
2035
+ """
2036
+ try:
2037
+ os.lstat(candidate)
2038
+ except OSError:
2039
+ return False
2040
+ return True
2041
+
2042
+
2043
+ def _shield_home_git_ignore(worktree: Path) -> Path:
2044
+ """`$HOME/.config/git/ignore` — git's XDG fallback — asked of GIT, not of Python.
2045
+
2046
+ `Path.home()` is the obvious spelling and it is WRONG on Windows, the one
2047
+ platform where git's home and Python's are derived differently. Git locates this
2048
+ fallback with `getenv("HOME")` everywhere (`path.c::xdg_config_home`), but on
2049
+ Windows `compat/mingw.c::setup_windows_environment()` DERIVES `HOME` inside the
2050
+ git process first, preferring `HOMEDRIVE`+`HOMEPATH` (only when that names a real
2051
+ directory) over `USERPROFILE`; Python's `ntpath.expanduser` has the OPPOSITE
2052
+ precedence and never consults `HOME` at all. Git Bash and MSYS2 set `HOME`; a
2053
+ domain-joined machine's `HOMEDRIVE`+`HOMEPATH` may be a network home share.
2054
+ Either makes the two disagree.
2055
+
2056
+ The miss is SILENT, and it is #384's own harm through the platform split: the
2057
+ wrong path is simply not a file, the seed comes back empty, and the caller's
2058
+ activation then SHADOWS the global ignore file git really reads.
2059
+
2060
+ So ask git. `--type=path` interpolates a leading `~/` through the same
2061
+ `getenv("HOME")` git uses for the fallback itself, so the answer is correct by
2062
+ construction on every platform and names no environment variable. `-c` is COMMAND
2063
+ scope, the most specific git has, so the probe's value cannot be outranked by the
2064
+ operator's config. The answer comes back NUL-terminated at rc 0; with `HOME`
2065
+ unset git exits 128 and applies no fallback at all.
2066
+
2067
+ That `$HOME` answer is the whole of the fallback UPSTREAM, and is not on **Git
2068
+ for Windows >= 2.46** (#403). The fork patches `xdg_config_home_for`
2069
+ (`git-for-windows/git`, `path.c`) to prefer `%APPDATA%/Git/<file>` whenever that
2070
+ file EXISTS, warning that it ignored the `$HOME` one when both are there. Counted
2071
+ per tag: present at 2.46.0.windows.1 and 2.55.0.windows.3, absent at
2072
+ 2.45.0.windows.1 and 2.20.0.windows.1, absent from upstream `git/git` entirely.
2073
+ PROVENANCE: source-read through #403, **NOT measured on a Windows machine** — no
2074
+ runtime observation of Git for Windows was available, and Windows CI cannot supply
2075
+ one either (the runners carry no `%APPDATA%\\Git\\ignore`, so they can show only
2076
+ that nothing broke).
2077
+
2078
+ The APPDATA arm below closes a harm that ran in BOTH directions, each silent.
2079
+ APPDATA file only: this returned a `$HOME` path that is typically not a file, the
2080
+ seed came back empty with `reason is None`, and the caller then activated a
2081
+ worktree-scoped `core.excludesFile` SHADOWING the file git really reads — so
2082
+ everything the operator globally ignores became visible to `git add -A` and swept
2083
+ into the story commit. BOTH files present: git uses the APPDATA one and says so,
2084
+ while this seeded the `$HOME` one — copying patterns git is not applying, so the
2085
+ worktree OVER-ignored and session-created files went silently missing instead.
2086
+
2087
+ Raises `GitError` on any non-zero rc, INCLUDING the `HOME`-unset one. Proceeding
2088
+ would be a guess, and a guess here is silent: the caller seeds nothing and
2089
+ activates over whatever git does read. git's message is deliberately NOT matched
2090
+ to tell "no HOME" apart from a transient fault, because that wording is
2091
+ version-dependent. A `HOME`-less environment therefore skips the shield with a
2092
+ reported reason; only a definite absent may be silent in this caller.
2093
+ """
2094
+ # Cheap-first, and every arm that does not MATCH falls through to the `$HOME`
2095
+ # probe below — no APPDATA, no such file, an unanswerable `git version`, upstream
2096
+ # git, a fork below 2.46. That fall-through is the conservative direction: it is
2097
+ # exactly the pre-fix behavior, and a git too dead to report its version is not
2098
+ # absolved by it, because the probe below raises its own `GitError` on the same
2099
+ # git and the caller degrades with a reason.
2100
+ appdata = os.environ.get("APPDATA")
2101
+ if appdata:
2102
+ candidate = Path(appdata) / "Git" / "ignore"
2103
+ # LOAD-BEARING, and it mirrors the fork's own `file_exists` precondition
2104
+ # EXACTLY rather than approximately — see `_shield_file_exists` for why an
2105
+ # `is_file()` here would reject a directory and a broken symlink that git
2106
+ # itself selects. It is also what keeps the cost at one `lstat` on every other
2107
+ # platform: only a candidate that exists is worth the extra spawn below.
2108
+ if _shield_file_exists(candidate):
2109
+ # Gated on the FORK STRING, deliberately NOT `sys.platform` (#403). The
2110
+ # preference is a patch carried by one FORK, not a property of the OS:
2111
+ # Cygwin, MSYS2 and WSL gits run on Windows hardware without it, and a
2112
+ # `win32` test would hand them the wrong file. This module has no
2113
+ # `sys.platform` branch anywhere else, and asking git what it is keeps the
2114
+ # tests honest — they fake a version string, never a platform.
2115
+ version = git_bytes(worktree, "version")
2116
+ if version.returncode == 0:
2117
+ reported = os.fsdecode(version.stdout)
2118
+ if ".windows." in reported and git_version_at_least(reported, _APPDATA_IGNORE_GIT):
2119
+ if candidate.is_dir():
2120
+ # SELECTED by git and then UNUSABLE by it: `access(R_OK)`
2121
+ # succeeds on a readable directory, so git reaches
2122
+ # `add_patterns_from_file_1` and dies ("cannot use %s as an
2123
+ # exclude file"). Returning it would seed nothing — and an
2124
+ # empty seed here is not the faithful mirror it is for a broken
2125
+ # symlink, it is a FATAL rendered as a permissive success:
2126
+ # activating a worktree-scoped `core.excludesFile` SHADOWS the
2127
+ # broken path, so the unit's `git add -A` would run happily
2128
+ # where the operator's own git refuses to run at all, and the
2129
+ # misconfiguration would never surface.
2130
+ #
2131
+ # `is_dir()` FOLLOWS the link deliberately: a symlink to a
2132
+ # directory is the same fatal. A broken one is not a directory
2133
+ # and falls through to be seeded as empty, which is what git
2134
+ # does with it.
2135
+ raise GitError(
2136
+ f"git's global ignore path is a directory ({candidate}) — "
2137
+ "git for Windows selects it and then cannot read it"
2138
+ )
2139
+ return candidate
2140
+ key = "froidloop.xdghomeprobe"
2141
+ probe = git_bytes(
2142
+ worktree, "-c", f"{key}=~/.config/git/ignore", "config", "-z", "--type=path", "--get", key
2143
+ )
2144
+ if probe.returncode != 0:
2145
+ detail = os.fsdecode(probe.stderr).strip() or f"git exited {probe.returncode}"
2146
+ raise GitError(f"git could not resolve its own home directory: {detail}")
2147
+ return Path(os.fsdecode(probe.stdout.split(b"\0", 1)[0]))
2148
+
2149
+
2150
+ def _shield_inherited_excludes(worktree: Path) -> bytes:
2151
+ """The BYTES of whatever `core.excludesFile` this worktree resolves to now.
2152
+
2153
+ A worktree-scoped `core.excludesFile` SHADOWS the operator's own: git reads the
2154
+ key from the most specific scope that sets it, never concatenating scopes. Not
2155
+ carrying their patterns over would un-ignore, inside the worktree, everything
2156
+ they ignore globally — and `git add -A` would commit it. The caller seeds once,
2157
+ at creation, so the copy never fights later edits to the private file.
2158
+
2159
+ BYTES, never decoded text: exclude entries are path patterns, POSIX paths are
2160
+ arbitrary bytes, and one non-UTF-8 byte collapsed a `read_text` seed to empty —
2161
+ after which the activation SHADOWED what it failed to copy. Copying verbatim
2162
+ also keeps every pattern intact for the caller's dedupe; git reads the result
2163
+ either way, stripping a trailing `\\r` from an exclude line and skipping a
2164
+ UTF-8 BOM.
2165
+
2166
+ FOUR outcomes (#384). The first two are silent; the other two RAISE, and the
2167
+ caller's tail turns that into the degrade reason that skips the activation.
2168
+
2169
+ - ABSENT is silent, empty, and the ONLY outcome that falls back: `--get` of an
2170
+ unset key exits 1, and `is_file()` false says the fallback file is not there.
2171
+ Reaching that fallback can itself fail — `_shield_home_git_ignore` raises, and
2172
+ an unresolvable home is UNKNOWN, not absent.
2173
+ - DISABLED is silent and empty too, and is NOT absent. An explicitly EMPTY
2174
+ `core.excludesFile` means "no excludes file at all", and git honors that
2175
+ literally: `-z --get` answers rc 0 with a lone NUL, git loads no patterns, and
2176
+ does NOT reach for XDG (`dir.c` guards that fallback on a NULL `excludes_file`;
2177
+ an empty value is not NULL — unchanged across the supported range to master).
2178
+ Reading it as unset instead seeds and activates the XDG file's patterns —
2179
+ OVER-ignoring, so files the operator deliberately stopped ignoring go silently
2180
+ missing from `git add -A`.
2181
+ - PRESENT BUT UNREADABLE propagates: a read `OSError` (EACCES, EIO) reaches the
2182
+ caller's degrade arm — activating over patterns that could not be copied
2183
+ shadows them exactly as an empty seed did.
2184
+ - UNKNOWN propagates too, in TWO shapes: a raised `GitError`, and any returncode
2185
+ that is neither 0 nor 1. A fault means we did not FIND OUT; swallowing it maps
2186
+ UNKNOWN onto ABSENT and activates a key shadowing the file it failed to read.
2187
+ """
2188
+ # -z, not a bare read plus `.strip()`. `--type=path --get` returns leading and
2189
+ # trailing whitespace VERBATIM, because git writes such a value quoted and it
2190
+ # round-trips. So an operator whose excludesFile ended in a space had the strip
2191
+ # mangle the path, `is_file()` read absent, the seed come back empty, and the
2192
+ # activation shadow the file it never copied. `-z` terminates the value with NUL
2193
+ # instead.
2194
+ #
2195
+ # The answers below hold at `GIT_FLOOR` (the version refusal in
2196
+ # `_shield_enable_worktree_config` has already run, so the floor is the oldest git
2197
+ # that reaches this line) and at 2.55: `-z` expands `~`, gives an unset key rc 1 +
2198
+ # empty stdout, an empty value a lone NUL at rc 0, a multi-valued key the LAST
2199
+ # value + NUL at rc 0, and a relative value verbatim.
2200
+ answer = git_bytes(worktree, "config", "-z", "--type=path", "--get", "core.excludesFile")
2201
+ raw = answer.stdout.split(b"\0", 1)[0]
2202
+ if answer.returncode == 0:
2203
+ if not raw:
2204
+ # DISABLED: an explicit "no excludes file", so there is nothing to copy
2205
+ # and NO fallback to reach for — see the docstring. The arm is inert on
2206
+ # its own (`Path(os.fsdecode(b""))` is `.`, which reads `is_file()` false),
2207
+ # so what separates DISABLED from ABSENT is the rc-FIRST condition above,
2208
+ # and restoring `and raw` on it is the only faithful ablation.
2209
+ return b""
2210
+ source = Path(os.fsdecode(raw))
2211
+ if not source.is_absolute():
2212
+ # `--type=path` expands `~` and stops there: a RELATIVE value comes back
2213
+ # verbatim. Git resolves such a value against the worktree's TOP LEVEL —
2214
+ # observed rather than specified (it falls out of git's setup chdir), and
2215
+ # it holds here because every git call goes through `_run_git`'s
2216
+ # `git -C <worktree>`. Python would resolve it against this process's cwd,
2217
+ # wherever the orchestrator was launched — the wrong file or, far more
2218
+ # often, none, and `is_file()` false is SILENT: the operator's patterns
2219
+ # would not be carried and the activation below would shadow them anyway.
2220
+ source = worktree / source
2221
+ elif answer.returncode != 1:
2222
+ # UNKNOWN, through git's OTHER failure shape. rc 1 is the ONLY non-zero rc
2223
+ # that means "no such key"; every other one is git saying it could not answer,
2224
+ # and routing those to the fallback below is the same UNKNOWN-as-ABSENT
2225
+ # mistake the raised shape is caught for.
2226
+ #
2227
+ # No STATIC config value reaches this branch: a `core.excludesFile` of
2228
+ # `~nosuchuser/ignore` does make THIS read exit 128, but git expands that same
2229
+ # value while parsing core config for any command that sets the repository up,
2230
+ # so `rev-parse --absolute-git-dir` in the caller fatals identically and the
2231
+ # shield SILENTLY SKIPS above this line. What occupies this branch is a fault
2232
+ # arriving BETWEEN that rev-parse and this read, and the caller sets that
2233
+ # window's width: the rev-parse runs before the repo-scoped lock, this read
2234
+ # after it, and POSIX `flock` blocks INDEFINITELY, so a run waiting on a
2235
+ # sibling worktree's shield sits in it for the sibling's whole git
2236
+ # transaction. Such a fault can CLEAR while an activation performed over it
2237
+ # lasts as long as the worktree.
2238
+ #
2239
+ # A FUNNEL rather than a per-rc taxonomy: git-config(1) allocates several
2240
+ # failure codes, and anything that is not "answered" or "no such key" is not
2241
+ # an answer.
2242
+ detail = os.fsdecode(answer.stderr).strip() or f"git exited {answer.returncode}"
2243
+ raise GitError(f"git could not resolve core.excludesFile: {detail}")
2244
+ else:
2245
+ # ABSENT (rc 1, an unset key): git's documented fallback (gitignore(5)), and
2246
+ # the ONLY outcome that gets one.
2247
+ #
2248
+ # `XDG_CONFIG_HOME` this process may read for itself: git reads the same
2249
+ # variable from the same environment (`_run_git` passes `os.environ`
2250
+ # through), and set-but-empty counts as unset for BOTH — git's guard is
2251
+ # `if (config_home && *config_home)`, the falsy check below is the same
2252
+ # test. The `$HOME` limb is the one Python cannot answer; see
2253
+ # `_shield_home_git_ignore` for why it is asked of git instead.
2254
+ xdg = os.environ.get("XDG_CONFIG_HOME")
2255
+ source = Path(xdg) / "git" / "ignore" if xdg else _shield_home_git_ignore(worktree)
2256
+ if not source.is_absolute():
2257
+ # THE SAME DEFECT AS THE RELATIVE `core.excludesFile` ABOVE: this branch
2258
+ # reproduces git's fallback, so it must reproduce git's RESOLUTION too. A
2259
+ # relative XDG_CONFIG_HOME is invalid per the XDG base-directory spec, but
2260
+ # git does not ignore it: it HONORS the value and resolves it against the
2261
+ # worktree's TOP LEVEL, while Python would resolve it against the
2262
+ # orchestrator's launch cwd. The miss is SILENT — `is_file()` false, an
2263
+ # empty seed, and then the activation shadows the file git really reads.
2264
+ source = worktree / source
2265
+ # `is_file()` is the ABSENT test and swallows its own OSError; `read_bytes`
2266
+ # on a file that exists but cannot be read raises, deliberately (docstring).
2267
+ return source.read_bytes() if source.is_file() else b""
2268
+
2269
+
2270
+ def _shield_verify_activation(worktree: Path, exclude: Path) -> str | None:
2271
+ """Why the just-written `core.excludesFile` is NOT the one git reads, or None.
2272
+
2273
+ A successful `git config --worktree core.excludesFile` proves the value was
2274
+ WRITTEN, not that it is in FORCE. git resolves the key from the most specific
2275
+ scope that carries it, and `command` — a scope fed by the ENVIRONMENT the
2276
+ orchestrator was launched with — outranks `worktree`. So an operator carrying an
2277
+ ambient `core.excludesFile` gets a shield that reports success and whose private
2278
+ file is never read: the provisioned tool files stay stageable by the unit's
2279
+ `git add -A`, with no degrade reason to journal. The check has git NAME the winning
2280
+ scope rather than leaving it inferred from the value that came back.
2281
+
2282
+ So the post-condition is asked of git. Detecting the ORIGIN instead —
2283
+ `GIT_CONFIG_COUNT` and friends in `os.environ` — is the enumeration this function
2284
+ exists to avoid, and `--show-scope` strengthens that argument rather than retiring
2285
+ it: nothing here reads `os.environ`, and git folds the whole channel family into
2286
+ the single token `command` on the same single call, so every channel — and whatever
2287
+ git adds next — arrives already LABELED, and the degrade reason names the family
2288
+ without this code enumerating it. The evidence for why enumerating was never viable
2289
+ stands: `GIT_CONFIG_PARAMETERS` is carried in two mutually incompatible encodings
2290
+ across the supported range, `GIT_CONFIG_COUNT` is one more channel to remember (it
2291
+ sits below `GIT_FLOOR`, so it is always present and always another thing to read),
2292
+ and a `git -c` on a session's own command line never appears in our environment at
2293
+ all. Asking git what it RESOLVED costs one call and covers all of them.
2294
+
2295
+ `--show-scope` is git 2.26, which was above the old 2.20 gate this shield used to
2296
+ carry; at `GIT_FLOOR` it is present on every supported git, which is what unblocked
2297
+ it (#692). Under `-z` the answer is `scope NUL value NUL` — measured at BOTH ends of
2298
+ the supported range, git 2.34.1 (the floor itself) and git 2.55.0, where the flag
2299
+ also leaves the rc taxonomy alone: an absent key is still rc 1 with or without it.
2300
+ The scope tokens git documents are `system`, `global`, `local`, `worktree` and
2301
+ `command`. The scope refines the MESSAGE and nothing else: a byte-identical value
2302
+ returns None whatever scope supplied it, since the post-condition is that git reads
2303
+ the file we wrote and provenance is not a fault; every mismatch degrades; the scope
2304
+ only decides what the reason tells the operator to go looking for.
2305
+
2306
+ The read shape is the seed read's: `-z` because a legal POSIX path may carry edge
2307
+ whitespace, `--type=path` because that is how git itself resolves the key. Any
2308
+ non-zero rc is a fault here, not an ABSENT answer — this call asks about a key we
2309
+ have just written, so "there is no such key" is not good news about it. That is a
2310
+ DIFFERENT taxonomy from `_shield_inherited_excludes`, which reads a key the
2311
+ operator may never have set; the two must not be unified. rc 0 carries a fault of
2312
+ its own now: a well-formed `-z --show-scope` answer always holds the seam NUL
2313
+ between scope and value, so an answer without one is not an answer and degrades
2314
+ fail-closed rather than being parsed as a scope. Whatever shape an unmeasured git
2315
+ might return lands there or in the unknown-token branch, and both degrade. The two
2316
+ reads are also no longer byte-identical in ARGV — `--show-scope` is on this one
2317
+ alone, retiring a trap the tests documented — and the seed read must NOT grow it,
2318
+ since rc 1 there means ABSENT.
2319
+
2320
+ The comparison is byte-exact and stays that way: `git config` round-trips a path
2321
+ verbatim through every hazard this shield has been burned by — edge whitespace,
2322
+ an embedded newline, a non-UTF-8 byte, an interior `~` — so loosening it would
2323
+ buy nothing and could only mask a real mismatch.
2324
+ """
2325
+ resolved = git_bytes(
2326
+ worktree, "config", "-z", "--show-scope", "--type=path", "--get", "core.excludesFile"
2327
+ )
2328
+ if resolved.returncode != 0:
2329
+ detail = os.fsdecode(resolved.stderr).strip() or f"git exited {resolved.returncode}"
2330
+ return f"git would not confirm which excludes file now applies: {detail}"
2331
+ scope, sep, rest = resolved.stdout.partition(b"\0")
2332
+ if not sep:
2333
+ return (
2334
+ "git answered the activation check without naming a scope "
2335
+ f"({resolved.stdout!r}), so which excludes file applies is unconfirmed"
2336
+ )
2337
+ effective = rest.split(b"\0", 1)[0]
2338
+ if effective == os.fsencode(str(exclude)):
2339
+ return None
2340
+ shown = os.fsdecode(effective)
2341
+ if scope == b"command":
2342
+ return (
2343
+ "the write succeeded but an ambient command-scope override — a `git -c` "
2344
+ "this process was launched inside of, GIT_CONFIG_PARAMETERS, or "
2345
+ f"GIT_CONFIG_COUNT — outranks it, so git reads {shown!r} instead and the "
2346
+ "shield's patterns never apply"
2347
+ )
2348
+ if scope == b"worktree":
2349
+ return (
2350
+ "the write succeeded but worktree scope answers a different value, so git "
2351
+ f"reads {shown!r} instead of the path just written and the shield's "
2352
+ "patterns never apply"
2353
+ )
2354
+ if scope in (b"local", b"global", b"system"):
2355
+ return (
2356
+ "the write succeeded but git still resolves core.excludesFile from "
2357
+ f"{os.fsdecode(scope)} scope — the worktree-scoped write is not in force "
2358
+ f"at all, so git reads {shown!r} and the shield's patterns never apply"
2359
+ )
2360
+ return (
2361
+ f"the write succeeded but a scope this code does not know, {os.fsdecode(scope)!r}, "
2362
+ f"outranks it, so git reads {shown!r} instead and the shield's patterns never apply"
2363
+ )
2364
+
2365
+
2366
+ def _worktree_local_exclude(worktree: Path, patterns: Sequence[str]) -> str | None:
2367
+ """Shield the just-provisioned tool files from the unit's `git add -A`, in a
2368
+ private exclude scoped to THIS worktree.
2369
+
2370
+ SCOPE — the patterns land in `<worktree gitdir>/info/exclude`, activated by
2371
+ `git config --worktree core.excludesFile`. Git's only per-repo exclude is
2372
+ `$GIT_COMMON_DIR/info/exclude`, so the private file is inert without that key,
2373
+ which applies it to this worktree alone — main checkout and siblings untouched.
2374
+
2375
+ LIFETIME — `git worktree remove`/`prune` deletes the per-worktree gitdir, taking
2376
+ the exclude and the `config.worktree` pointing at it: the shield expires with
2377
+ what it shields.
2378
+
2379
+ The repository-wide `.git/info/exclude` is NEVER written again on any path
2380
+ (#384): a refusal SKIPS the shield rather than falling back to it.
2381
+
2382
+ PRICE — `extensions.worktreeConfig` is repo-wide format state a successful shield
2383
+ leaves permanently enabled, and git older than 2.20 refuses to access a repository
2384
+ carrying it (git-worktree(1)). It is enabled at the LAST possible moment,
2385
+ immediately before the activation. The ACTIVATION rolls it back on a non-zero rc,
2386
+ on a raise, and on a write that succeeded without taking effect
2387
+ (`_shield_verify_activation`); the ENABLE only on the raise, since an rc means git
2388
+ declined to write and an unset would remove a concurrent writer's flag. Both are
2389
+ gated on this call having set the flag and no sibling worktree depending on it
2390
+ (`_shield_undo_extension`).
2391
+
2392
+ Every git call goes through `verify.git_bytes`: the returncode is an ANSWER, not
2393
+ a raise, and stdout arrives as BYTES.
2394
+
2395
+ Best-effort in two arms, separate because a fault means the opposite in each:
2396
+
2397
+ - git unqueryable BEFORE GIT HAS ANSWERED AT ALL (not a repo, git missing, a
2398
+ timeout or spawn failure on the FIRST `rev-parse` — both `GitError`) is an
2399
+ EXPECTED skip: returns None silently. That scope is the whole of it, and
2400
+ nothing is DECODED in this arm — git's stdout stays bytes until the tail
2401
+ below, because a decode fault is a degrade, not this arm's silent skip (#374).
2402
+ - anything AFTER git answered degrades to a returned reason string; only a
2403
+ definite ABSENT answer stays silent. Silence is not cosmetic: without the
2404
+ exclude the unit's `git add -A` commits the tool files just provisioned.
2405
+
2406
+ The exclude PAYLOAD is bytes end-to-end, so the tail's `UnicodeError` has two
2407
+ sources: `os.fsdecode` of git's stdout, Windows-only (#374/#377), and
2408
+ `os.fsencode` of a pattern carrying a surrogate POSIX surrogateescape never
2409
+ produced.
2410
+ """
2411
+ # Callers pass POSIX-slash patterns (glob rels via as_posix; config strings as
2412
+ # authored); git's exclude is POSIX-slash on every platform, so nothing to fix here.
2413
+ try:
2414
+ # ONE path per call, never both in one answer. `rev-parse` separates its
2415
+ # answers with a newline, a legal BYTE IN A POSIX PATH: asking for both at once
2416
+ # mis-split a repo directory carrying one, degrading the shield away *after*
2417
+ # provisioning had copied the very files it exists to hide. No NUL-delimited
2418
+ # mode to reach for instead — `rev-parse` has no `-z`. A path ENDING in a
2419
+ # newline is beyond this parse either way.
2420
+ answered = git_bytes(worktree, "rev-parse", "--absolute-git-dir")
2421
+ if answered.returncode != 0:
2422
+ # Not a repo (rc 128), the expected skip — NOT "a git too old for
2423
+ # `--absolute-git-dir`": rev-parse ECHOES an option it does not know and
2424
+ # exits 0, so a git predating the flag lands in the version refusal in
2425
+ # `_shield_enable_worktree_config` — a degrade, not this silent skip.
2426
+ return None
2427
+ except GitError:
2428
+ # GitError alone is the whole taxonomy here: this `try` wraps ONE git call, and
2429
+ # the chokepoint raises a timeout as GitError and a spawn failure as its
2430
+ # GitSpawnError subclass — the translated form of the `OSError` this arm once
2431
+ # caught.
2432
+ return None
2433
+ try:
2434
+ # The COMMON-DIR probe belongs in THIS try, not the silent one above: once
2435
+ # `--absolute-git-dir` has answered, git has identified the repository, so every
2436
+ # later fault degrades with a reason instead of skipping the shield silently. No
2437
+ # `except` of its own: a non-zero rc returns the reason below, a raise lands in
2438
+ # this try's tail — which also catches the `UnicodeError` the `fsdecode` of
2439
+ # git's stderr can raise on Windows, hence that read sits inside the guard.
2440
+ # The sibling decode in `_shield_undo_extension`'s rollback carries that same
2441
+ # guard, for the same reason (#394).
2442
+ shared_answer = git_bytes(worktree, "rev-parse", "--git-common-dir")
2443
+ if shared_answer.returncode != 0:
2444
+ detail = (
2445
+ os.fsdecode(shared_answer.stderr).strip()
2446
+ or f"git exited {shared_answer.returncode}"
2447
+ )
2448
+ return (
2449
+ f"skipped the git-add shield ({worktree}): git identified the "
2450
+ f"repository but could not name its common dir: {detail} — the "
2451
+ "provisioned tool files are not shielded from the unit's `git add -A`"
2452
+ )
2453
+ # fsdecode, so a non-UTF-8 repo path round-trips back to the filesystem instead
2454
+ # of faulting; it can still raise on Windows (utf-8/surrogatepass rejects a lone
2455
+ # invalid byte), which is why it sits inside this try.
2456
+ #
2457
+ # removesuffix("\n"), NOT strip() — rev-parse terminates its answer with one
2458
+ # newline and every other byte belongs to the path. A BARE repo's common dir IS
2459
+ # the repository directory, so one at `…/common ` comes back with its trailing
2460
+ # space (`--absolute-git-dir` stays safe: git sanitizes the admin id). Stripping
2461
+ # pointed every later step at `…/common`, which does not exist, where `--file
2462
+ # <that>/config` reads rc 1, i.e. ABSENT — so the `core.bare = true` refusal was
2463
+ # MISSED and the shield made its permanent format change on a bare repository.
2464
+ #
2465
+ # CRLF is deliberately not absorbed: git writes LF here on every platform, and a
2466
+ # path legitimately ending in `\r` is indistinguishable from a CRLF terminator.
2467
+ raw_git_dir = os.fsdecode(answered.stdout).removesuffix("\n")
2468
+ raw_common = os.fsdecode(shared_answer.stdout).removesuffix("\n")
2469
+ if not raw_git_dir or not raw_common:
2470
+ # Defensive, and deliberately a REASON rather than a silent skip: git
2471
+ # answered. Unreachable with real git — an rc 0 from `rev-parse` always
2472
+ # prints a path.
2473
+ return (
2474
+ f"could not read this worktree's git dirs ({worktree}): "
2475
+ f"{raw_git_dir!r}, {raw_common!r}"
2476
+ )
2477
+ git_dir = Path(raw_git_dir)
2478
+ common_dir = Path(raw_common)
2479
+ if not common_dir.is_absolute():
2480
+ # a PLAIN checkout answers with a relative ".git"; a linked worktree
2481
+ # answers with the main repo's absolute .git.
2482
+ common_dir = worktree / common_dir
2483
+ # resolve BOTH before comparing: --absolute-git-dir is absolute but not
2484
+ # canonical, so a symlinked repo path would otherwise read as two
2485
+ # different dirs and a main checkout would pass for a linked worktree.
2486
+ git_dir, common_dir = git_dir.resolve(), common_dir.resolve()
2487
+ if git_dir == common_dir:
2488
+ # The main checkout itself: its gitdir IS the common dir, so the only
2489
+ # exclude file to write would be the shared one — the #384 bug.
2490
+ return (
2491
+ f"skipped the git-add shield ({worktree}): not a linked worktree, and the "
2492
+ "shield never writes the repository-wide exclude (#384)"
2493
+ )
2494
+ # ABOVE THE LOCK DELIBERATELY: a repository shared between OS users is refused
2495
+ # before anything is created in it, so every user gets the same reason rather
2496
+ # than the second one meeting a lock file the first left behind. Safe there
2497
+ # because `core.sharedRepository` is static config the tool never writes — no
2498
+ # shared mutable state to serialize. A `GitError` from the read hits this tail.
2499
+ shared_repository = _shield_shared_repository(worktree, common_dir)
2500
+ if shared_repository is not None:
2501
+ return shared_repository
2502
+ # EVERYTHING BELOW IS ONE TRANSACTION, serialized per repository.
2503
+ # `_shield_enable_worktree_config` only PROBES the flag, so two concurrent
2504
+ # provisionings both read it absent, both believe they enabled it, and whichever
2505
+ # one's activation fails rolls the flag back out from under the other's LIVE
2506
+ # shield — its `core.excludesFile` goes inert and its shielded tool files become
2507
+ # stageable again mid-run. That flag is the one piece of state two runs share;
2508
+ # every other per-run name is keyed on the run id. Concurrent runs in one repo
2509
+ # are permitted by design — `cmd_run` has no liveness check and the TUI offers
2510
+ # "launch anyway".
2511
+ #
2512
+ # The span cannot be narrowed to those writes: the enable must stay immediately
2513
+ # above the activation, the private exclude must exist before it, and the
2514
+ # version + `core.bare`/`core.worktree` gates inside the probe must stay above
2515
+ # the seed read — the version gate for its unreadable-answer arm (see
2516
+ # `_shield_enable_worktree_config`), the other two because the seed read must
2517
+ # not run against a repo they would refuse. So the file write sits inside the
2518
+ # locked span.
2519
+ #
2520
+ # The acquisition's own `OSError` is caught HERE only so the operator is told
2521
+ # which step failed: POSIX `flock` blocks indefinitely, but `msvcrt.locking`
2522
+ # bounds the wait at ~10 s and then raises, so Windows contention is a real,
2523
+ # reportable outcome.
2524
+ held = ExitStack()
2525
+ try:
2526
+ held.enter_context(file_lock(common_dir / _SHIELD_LOCK_NAME))
2527
+ except OSError as e:
2528
+ return (
2529
+ f"skipped the git-add shield ({worktree}): could not take this "
2530
+ f"repository's shield lock ({e}) — another froid-loop run may hold it"
2531
+ )
2532
+ with held:
2533
+ refusal, needs_enable = _shield_enable_worktree_config(worktree, common_dir)
2534
+ if refusal is not None:
2535
+ return refusal
2536
+ exclude = git_dir / "info" / "exclude"
2537
+ exclude.parent.mkdir(parents=True, exist_ok=True)
2538
+ # Zero bytes is an INTERRUPTED creation (a kill or ENOSPC between the
2539
+ # `touch()` and the fill below), not an initialized exclude: read as
2540
+ # authoritative it skips the seed below and then points a shadowing
2541
+ # `core.excludesFile` at a file missing the operator's own excludes. Sized
2542
+ # rather than unlinked on the way out, because no handler runs on a KILL.
2543
+ existed = exclude.is_file() and exclude.stat().st_size > 0
2544
+ # On creation the operator's own excludes are copied in, because the
2545
+ # activation below shadows them (see _shield_inherited_excludes); on a
2546
+ # re-provision a non-empty file is authoritative, so the two are idempotent.
2547
+ #
2548
+ # A COPY FAULT SKIPS THE ACTIVATION: `_shield_inherited_excludes` raises on
2549
+ # anything but a definite ANSWER — an unreadable file, or a git that will
2550
+ # not say which file applies — and the raise lands in this function's tail,
2551
+ # which returns a reason. It raises rather than returning empty
2552
+ # because an empty seed plus the activation below is not a lost seed but
2553
+ # the operator's global ignores SHADOWED inside this worktree, with `git
2554
+ # add -A` staging what they told git to ignore. The raise is above
2555
+ # `exclude.touch()`, so no placeholder is left behind.
2556
+ #
2557
+ # BYTES, never decoded text: an exclude holds path patterns, POSIX paths
2558
+ # are arbitrary bytes, and an operator's own file may be in any legacy
2559
+ # 8-bit encoding. `str.splitlines()` would also break on \x0b, \x0c, \x1c,
2560
+ # \x1d, \x1e and \x85, none of which git treats as a line boundary, so a
2561
+ # legitimate pattern carrying one fragments into wrong dedupe keys.
2562
+ #
2563
+ # SPLIT THE WAY GIT DOES (#472): \n boundaries, with exactly ONE trailing
2564
+ # \r trimmed per line. `bytes.splitlines()` is CLOSE but not identical —
2565
+ # it also breaks on a LONE \r, which git treats as ordinary content
2566
+ # (measured, 2.55.0: `/hidden\rjunk` ignores nothing, while `/hidden\r\n`
2567
+ # ignores `hidden` and `/hidden\r\r\n` does not). That difference is not
2568
+ # cosmetic here: an operator line carrying an embedded \r fragments, and a
2569
+ # fragment byte-equal to a wanted pattern reads as ALREADY PRESENT. Where
2570
+ # that fragment sits after the last negation the settled rule below skips
2571
+ # the append — the shield then writes a file that does not shield, with no
2572
+ # degrade reason, because nothing failed. The mirror direction is benign
2573
+ # (a fragmented key only ever costs a duplicate append; last match wins),
2574
+ # so this split is chosen for the SKIP direction alone.
2575
+ #
2576
+ # A trailing b"" (the file ended in \n) rides along unfiltered: no wanted
2577
+ # pattern is empty and b"" does not start with b"!", so it is inert in both
2578
+ # consumers below.
2579
+ existing = exclude.read_bytes() if existed else _shield_inherited_excludes(worktree)
2580
+ lines = [ln.removesuffix(b"\r") for ln in existing.split(b"\n")]
2581
+ # PRESENT IS NOT THE SAME AS EFFECTIVE (#384). gitignore is LAST MATCH
2582
+ # WINS, so a pattern this file already contains can be cancelled by a `!`
2583
+ # line below it, and a plain set-membership dedupe then declined to append
2584
+ # the shield's own pattern — leaving the provisioned tool files stageable
2585
+ # with NO degrade reason at all, because nothing failed. So a pattern may
2586
+ # be skipped only where it is GUARANTEED effective: it sits after the LAST
2587
+ # negation. No later line can negate it, so the last pattern matching any
2588
+ # path it covers is either this one or a positive below it, and both
2589
+ # ignore. Anything earlier is appended, which is always safe — the appended
2590
+ # copy is last, so it decides.
2591
+ #
2592
+ # Deliberately CONSERVATIVE rather than exact: the only negation that
2593
+ # actually defeats the shield re-includes the pattern's own directory
2594
+ # (`!.claude/skills` does it too — it need not repeat the positive's
2595
+ # spelling), while `!*.md` below it does not, since git never descends into
2596
+ # an excluded directory, and neither does `!/.claude`, since re-including a
2597
+ # parent leaves the child matched. Telling those apart is git's matcher, so
2598
+ # this appends a duplicate in the harmless cases instead of guessing.
2599
+ #
2600
+ # `startswith(b"!")` with NO lstrip is git-correct twice over: git does not
2601
+ # strip leading whitespace from a pattern, and `\!` escapes a literal
2602
+ # leading `!` — both then read as positives here, as they do in git.
2603
+ last_negation = max(
2604
+ (i for i, line in enumerate(lines) if line.startswith(b"!")), default=-1
2605
+ )
2606
+ settled = set(lines[last_negation + 1 :])
2607
+ # fsencode, the inverse of the fsdecode above: a pattern derived from a real
2608
+ # filename round-trips to its exact original bytes. Both sides of the `in`
2609
+ # MUST be bytes — with `patterns` left as str every one of them reads as
2610
+ # absent and gets re-appended on every re-provision.
2611
+ wanted = [os.fsencode(p) for p in patterns]
2612
+ new = [p for p in wanted if p not in settled]
2613
+ # `not existed` keeps a re-provision that adds no pattern from leaving the
2614
+ # config below pointing at a file that was never created.
2615
+ if new or not existed:
2616
+ # b"\n", not "\n": `bytes.endswith(str)` is a TypeError, and TypeError is
2617
+ # not in the tail's except tuple — it would escape a function contracted
2618
+ # never to propagate.
2619
+ prefix = existing if not existing or existing.endswith(b"\n") else existing + b"\n"
2620
+ # atomic_write_bytes, never write_bytes onto `exclude` directly (#375):
2621
+ # "wb" TRUNCATES before writing, and this is a read-modify-REWRITE
2622
+ # carrying the operator's own excludes in `prefix`, so a short write
2623
+ # (ENOSPC) left the file truncated mid-content — losing shield lines —
2624
+ # while the degrade reason still said "could not update". A truncation
2625
+ # landing on a bare "/" is inert: git strips it to a zero-length
2626
+ # pattern that matches nothing, so only the LOST lines matter. The helper
2627
+ # rather than a hand-rolled tmp+replace: it fsyncs before the replace
2628
+ # and carries the target's mode over, which a bare replace resets.
2629
+ # #381's interleaving race is moot here — the file is one worktree's
2630
+ # private gitdir, so no two runs share the target — but the atomic
2631
+ # write stays for #375, whose fault needs no second writer.
2632
+ if not existed:
2633
+ # Create it first: O_CREAT under the umask gives the helper
2634
+ # a mode to carry. `atomic_write_bytes` preserves a mode only
2635
+ # when the target already EXISTS (a fresh one gets mkstemp's
2636
+ # private 0600), so dropping this line would narrow every
2637
+ # private exclude's mode — and git treats an exclude it
2638
+ # cannot read as one that is not there: it warns, exits 0,
2639
+ # and the shield silently protects nothing, with no degrade
2640
+ # reason because the write SUCCEEDED. A fault right after
2641
+ # this leaves an empty exclude, which git treats as absent.
2642
+ exclude.touch()
2643
+ atomic_write_bytes(exclude, prefix + b"".join(p + b"\n" for p in new))
2644
+ # The PERMANENT repo-format change, deliberately the last-but-one thing
2645
+ # this function does. `_shield_enable_worktree_config` probed the gates and
2646
+ # reported the write still owed; paying it here is what keeps every degrade
2647
+ # above from charging the operator's repo a format change it carries
2648
+ # forever for a shield that never applied. Two writes can still set the
2649
+ # flag without shielding anything — this enable's own raise and the
2650
+ # activation's failure — and each rolls it back. It cannot move BELOW the
2651
+ # activation either: `git config --worktree` is what the extension unlocks.
2652
+ if needs_enable:
2653
+ # BOTH of the chokepoint's failure shapes classified into one value
2654
+ # before anything is decided: handling only the rc left a raise (a
2655
+ # timeout, a spawn failure) to the tail, which cannot roll anything
2656
+ # back, and `git config` can be killed AFTER renaming the new config
2657
+ # into place — flag set, no shield ever activated.
2658
+ #
2659
+ # THE ROLLBACK FIRES ON THE RAISE ONLY, and that asymmetry is
2660
+ # deliberate: an rc IS an answer — git refused, the lock file was never
2661
+ # renamed into place, there is nothing to undo, and the likeliest cause
2662
+ # is `.git/config.lock` contention with the very concurrent writer whose
2663
+ # flag an `--unset` would then remove. A raise is NOT an answer, and
2664
+ # only the uncertain case may trigger repair; do not "finish" this by
2665
+ # rolling back the rc too. `needs_enable` is True on this branch, so
2666
+ # `_shield_undo_extension`'s caller-side gate holds by construction.
2667
+ rolled_back = ""
2668
+ try:
2669
+ enabled = git_bytes(worktree, "config", "extensions.worktreeConfig", "true")
2670
+ enable_fault = (
2671
+ None
2672
+ if enabled.returncode == 0
2673
+ else os.fsdecode(enabled.stderr).strip()
2674
+ or f"git exited {enabled.returncode}"
2675
+ )
2676
+ except GitError as e:
2677
+ enable_fault = str(e)
2678
+ rolled_back = _shield_undo_extension(worktree, git_dir, common_dir)
2679
+ if enable_fault is not None:
2680
+ return (
2681
+ f"could not enable extensions.worktreeConfig ({worktree}): "
2682
+ f"{enable_fault} — the provisioned tool files are not shielded "
2683
+ f"from the unit's `git add -A`{rolled_back}"
2684
+ )
2685
+ # LAST, after the file it names exists: git reads core.excludesFile lazily,
2686
+ # but a config pointing at a file a fault above left unwritten would be a
2687
+ # shield that silently excludes nothing.
2688
+ #
2689
+ # ONE rollback covering EVERY way this step can fail to leave a working
2690
+ # shield. `git_bytes` fails two ways — a non-zero rc (git refused) and a
2691
+ # RAISE (a timeout, or a spawn failure as its GitSpawnError subclass) — and
2692
+ # a third outcome is not a failure at all: a write that succeeds without
2693
+ # taking effect (`_shield_verify_activation`, below). So the `try` spans the
2694
+ # write AND the verification, every shape converges on one `fault` string
2695
+ # before anything is decided, and rc 0 is no longer an exit from this block.
2696
+ # The rule: confirm the POST-CONDITION rather than enumerate the ways the
2697
+ # call in front of you can go wrong.
2698
+ #
2699
+ # The rollback exists because the enable one line above is a PERMANENT
2700
+ # repo-format change, and the guarantee this code owes (docstring,
2701
+ # CHANGELOG, docs/FEATURES.md) is that the flag outlives a failed shield
2702
+ # only where a rollback was DECLINED or could not be made, and the reason
2703
+ # says which. `needs_enable` gates it — a safety property, not a
2704
+ # micro-optimization, and see `_shield_undo_extension` for the second gate
2705
+ # the callee adds: `needs_enable` records only what the PROBE saw, so it
2706
+ # cannot tell "we enabled it" from "we and a concurrent run both thought
2707
+ # we did".
2708
+ try:
2709
+ activated = git_bytes(
2710
+ worktree, "config", "--worktree", "core.excludesFile", str(exclude)
2711
+ )
2712
+ fault = (
2713
+ None
2714
+ if activated.returncode == 0
2715
+ else os.fsdecode(activated.stderr).strip()
2716
+ or f"git exited {activated.returncode}"
2717
+ )
2718
+ if fault is None:
2719
+ # An rc 0 means the value was WRITTEN, not that it is in
2720
+ # FORCE — `command` scope outranks `worktree` and is fed by
2721
+ # the environment we were launched with. Verified INSIDE this
2722
+ # same `try` and above the one decision below, so the
2723
+ # verification's own failure shapes reach the existing
2724
+ # rollback instead of growing a second one.
2725
+ fault = _shield_verify_activation(worktree, exclude)
2726
+ except GitError as e:
2727
+ # Deliberately NOT left to the tail, which cannot roll the flag back: a
2728
+ # GitError from EITHER call in this block means what a refusal means —
2729
+ # the shield is not in force. Putting this except inside the `try` is
2730
+ # what extends that to the verification's own fault. The tail's GitError
2731
+ # guard stays load-bearing for the seed read above, so this narrows what
2732
+ # reaches it rather than bypassing it.
2733
+ fault = str(e)
2734
+ if fault is not None:
2735
+ if needs_enable:
2736
+ fault += _shield_undo_extension(worktree, git_dir, common_dir)
2737
+ return f"could not activate the worktree git exclude ({worktree}): {fault}"
2738
+ # GitError is every fault a chokepoint git call can raise here — a timeout, or a
2739
+ # spawn failure as its GitSpawnError subclass. OSError and RuntimeError cover the
2740
+ # mkdir and read/write faults plus `.resolve()`'s pre-3.13 symlink loop, which
2741
+ # raises RuntimeError rather than OSError.
2742
+ #
2743
+ # UnicodeError, not UnicodeDecodeError, and NOT for the exclude file — that payload
2744
+ # is bytes end-to-end. What is left is the `os.fsdecode` of git's stdout
2745
+ # (Windows-only) and the `os.fsencode` of a pattern carrying a surrogate POSIX
2746
+ # surrogateescape never produced: ENCODE or DECODE depending on which, hence the
2747
+ # shared base class, and still far short of ValueError-broad.
2748
+ #
2749
+ # TypeError is deliberately absent: a str/bytes mixup on the payload path is a
2750
+ # programming error, and letting it crash beats reporting it as an operator's
2751
+ # degraded shield once per run.
2752
+ except (GitError, OSError, RuntimeError, UnicodeError) as e:
2753
+ return f"could not update the worktree-local git exclude ({worktree}): {e}"
2754
+ return None
2755
+
2756
+
2757
+ def _copy_skills(project: Path, trees: Sequence[str], force: bool) -> bool:
2758
+ """Install the bundled froid-loop-* skills into each project skill tree.
2759
+
2760
+ A skill directory that already exists is skipped unless ``force`` (so the
2761
+ FROID installer's copy or local edits are never clobbered silently). Returns
2762
+ True if any skill was skipped because it already existed.
2763
+ """
2764
+ skills_root = resources.files("froid_loop.data").joinpath("skills")
2765
+ skipped_any = False
2766
+ for tree in trees:
2767
+ tree_dir = project / tree
2768
+ # This loop rmtree's under `force` and writes unconditionally, and its
2769
+ # `_copy_traversable` call passes neither `worktree` nor `repo_root`, so
2770
+ # both of that helper's containment legs are inert here by construction.
2771
+ # The containment check therefore has to live at this level. Not folded
2772
+ # into `_copy_traversable`'s own guards on purpose: passing `worktree=`
2773
+ # would also force no-clobber and switch per-entry OSError to degrade,
2774
+ # and `init` must fail loudly on a write it cannot complete.
2775
+ #
2776
+ # Raises rather than skipping: a skipped tree leaves the install missing
2777
+ # the skills every later session dispatches, and an `init` that prints
2778
+ # `init complete` and exits 0 over that is an unattended-setup trap. Same
2779
+ # severity as `_register_hooks`' refusal, which already fails the install.
2780
+ if not _confined_to(tree_dir, project):
2781
+ raise ProfileError(f"skill tree does not resolve inside the project: {tree_dir}")
2782
+ installed: list[str] = []
2783
+ skipped: list[str] = []
2784
+ for skill in MODULE_SKILLS:
2785
+ dst = tree_dir / skill
2786
+ if dst.exists() and not force:
2787
+ skipped.append(skill)
2788
+ continue
2789
+ if dst.exists():
2790
+ shutil.rmtree(dst)
2791
+ _copy_traversable(skills_root.joinpath(skill), dst)
2792
+ installed.append(skill)
2793
+ parts: list[str] = []
2794
+ if installed:
2795
+ parts.append(f"installed {', '.join(installed)}")
2796
+ if skipped:
2797
+ parts.append(f"skipped {', '.join(skipped)} (exist)")
2798
+ skipped_any = True
2799
+ print(f" skills -> {tree}/: {'; '.join(parts) if parts else 'nothing to do'}")
2800
+ return skipped_any
2801
+
2802
+
2803
+ def _warn_if_policy_tracked(project: Path) -> None:
2804
+ """One-time migration hint: a .gitignore entry does not untrack an
2805
+ already-committed policy.toml, so repos initialized before the file was
2806
+ gitignored keep sharing it (and this machine's [mux] backend choice) until
2807
+ the dev runs `git rm --cached` once. Best-effort — not a repo, or no git,
2808
+ means nothing to warn about."""
2809
+ try:
2810
+ tracked = (
2811
+ git_bytes(
2812
+ project,
2813
+ "ls-files",
2814
+ "--error-unmatch",
2815
+ ".froid-loop/policy.toml",
2816
+ timeout_s=10, # the pre-#390 bound: a hint must not stall init
2817
+ ).returncode
2818
+ == 0
2819
+ )
2820
+ except GitError:
2821
+ return
2822
+ if tracked:
2823
+ print(
2824
+ " note: .froid-loop/policy.toml is tracked by git; run "
2825
+ "`git rm --cached .froid-loop/policy.toml` once to stop sharing it "
2826
+ "(your local copy is kept)"
2827
+ )
2828
+
2829
+
2830
+ def install_into(
2831
+ project: Path,
2832
+ clis: Sequence[str] = ("claude",),
2833
+ *,
2834
+ skills: bool = True,
2835
+ force_skills: bool = False,
2836
+ ) -> int:
2837
+ project = project.resolve()
2838
+ try:
2839
+ available = load_profiles(project)
2840
+ profiles = []
2841
+ for name in clis:
2842
+ key = ALIASES.get(name, name)
2843
+ if key not in available:
2844
+ raise ProfileError(
2845
+ f"unknown CLI profile: {name!r} (available: {sorted(available)})"
2846
+ )
2847
+ profiles.append(available[key])
2848
+ except ProfileError as e:
2849
+ print(f"FAIL: {e}")
2850
+ return 1
2851
+
2852
+ froid_loop_dir = project / ".froid-loop"
2853
+ froid_loop_dir.mkdir(parents=True, exist_ok=True)
2854
+
2855
+ # 1. hook relay script (shared by all CLIs)
2856
+ script_target = project / HOOK_SCRIPT_REL
2857
+ script_source = resources.files("froid_loop.data").joinpath("froid_loop_hook.py")
2858
+ script_target.write_text(script_source.read_text(encoding="utf-8"), encoding="utf-8")
2859
+ print(f" hook script: {script_target}")
2860
+
2861
+ # 2. per-CLI hook registration
2862
+ for profile in profiles:
2863
+ if _register_hooks(project, profile) != 0:
2864
+ return 1
2865
+
2866
+ # 3. bundled skills into each CLI's skill tree (deduped: codex+gemini share
2867
+ # .agents/skills)
2868
+ skills_skipped = False
2869
+ if skills:
2870
+ trees = list(dict.fromkeys(p.skill_tree for p in profiles))
2871
+ try:
2872
+ skills_skipped = _copy_skills(project, trees, force_skills)
2873
+ except ProfileError as e:
2874
+ print(f"FAIL: {e}")
2875
+ return 1
2876
+
2877
+ # 4. policy template
2878
+ policy_path = froid_loop_dir / "policy.toml"
2879
+ if policy_path.is_file():
2880
+ print(" policy exists, leaving untouched")
2881
+ else:
2882
+ policy_path.write_text(POLICY_TEMPLATE, encoding="utf-8")
2883
+ print(f" policy written: {policy_path}")
2884
+
2885
+ # 5. gitignore generated/machine-local state: per-run state (.froid-loop/runs/),
2886
+ # the game-engine plugins' rebuildable caches, e.g. the per-worktree Unity
2887
+ # Library (.froid-loop/cache/), and the policy file itself — policy.toml is
2888
+ # per-machine-per-repo (it carries this machine's [mux] backend choice, and
2889
+ # the TUI settings editor rewrites it), so it must never travel to teammates.
2890
+ gitignore = project / ".gitignore"
2891
+ existing = gitignore.read_text(encoding="utf-8") if gitignore.is_file() else ""
2892
+ have = set(existing.splitlines())
2893
+ to_add = [
2894
+ line
2895
+ for line in (
2896
+ ".froid-loop/runs/",
2897
+ ".froid-loop/cache/",
2898
+ ".froid-loop/policy.toml",
2899
+ f"{RENDER_DIR_REL}/",
2900
+ )
2901
+ if line not in have
2902
+ ]
2903
+ if to_add:
2904
+ with gitignore.open("a", encoding="utf-8") as f:
2905
+ if existing and not existing.endswith("\n"):
2906
+ f.write("\n")
2907
+ f.write("\n".join(to_add) + "\n")
2908
+ for line in to_add:
2909
+ print(f" gitignored: {line}")
2910
+ _warn_if_policy_tracked(project)
2911
+
2912
+ if skills_skipped:
2913
+ print(" some skills already present; re-run with --force-skills to overwrite")
2914
+
2915
+ print(
2916
+ "init complete. One-time setup before `froid-loop run` — spawned "
2917
+ "sessions cannot answer first-run dialogs, and a pending dialog reads "
2918
+ "as a session timeout:"
2919
+ )
2920
+ for profile in profiles:
2921
+ if profile.first_run_note:
2922
+ print(f" {profile.name}: {profile.first_run_note}")
2923
+ return 0
2924
+
2925
+
2926
+ def __getattr__(name: str):
2927
+ # `provision_worktree` now lives in `worktree_flow` (issue #244 F-9a): the
2928
+ # runtime control loop must not import the installer. It is re-exported here
2929
+ # lazily — a module-level `from .worktree_flow import provision_worktree` would
2930
+ # form an import cycle (worktree_flow imports installer helpers from this
2931
+ # module), so resolve it on first attribute access, once both modules exist.
2932
+ if name == "provision_worktree":
2933
+ from .worktree_flow import provision_worktree
2934
+
2935
+ return provision_worktree
2936
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")