Repository navigation
Expand file tree
/
Copy pathworktree_flow.py
More file actions
2421 lines (2255 loc) · 130 KB
/
Copy pathworktree_flow.py
File metadata and controls
2421 lines (2255 loc) · 130 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""Per-unit worktree isolation + integration flow.
Extracted from :class:`bmad_loop.engine.Engine` (issue #244, findings F-3/F-9a):
``Engine`` was a god-class whose worktree/integration cluster is an independent
state machine. It lives here as a collaborator built from narrow dependencies
(repo paths, the policy, run state, journal, plugin registry, loaded adapters)
plus a handful of engine callbacks (emit a plugin hook, save state, run the
per-unit ready gate, carry isolated ledger writes, escalate-pause, and get/set
the engine's active workspace).
The collaborator never receives the whole ``Engine`` — it cannot reach engine
internals beyond those callables.
``Engine`` keeps same-name private methods that delegate here, so its tests and
the ``SweepEngine``/``StoriesEngine`` subclasses see an unchanged surface.
``provision_worktree`` (previously in ``install.py``) is rehomed here too so the
runtime control loop no longer imports the installer; ``install.py`` re-exports
it lazily for its own tests.
"""
from __future__ import annotations
import copy
import json
from collections.abc import Collection, Sequence
from importlib import resources
from pathlib import Path
from typing import TYPE_CHECKING, Callable, NoReturn
from . import gates, verify
from .install import (
_REVIEW_LAYER_SKILLS,
BASE_SKILLS,
BMAD_DIR,
BMAD_SCRIPTS_SEED_REL,
BMAD_SEED_EXCLUDES,
CENTRAL_CONFIG_REL,
DEV_PRIMITIVE_MARKERS,
DEV_PRIMITIVE_ROLES,
HOOK_SCRIPT_REL,
MERGED_REVIEW_SKILL,
MODULE_SKILLS,
RENDER_DIR_REL,
RENDERER_SCRIPT_MARKER,
RENDERER_SCRIPT_UNIT_REL,
RENDERER_SEED_SENTINELS,
_absent_renderer_sources,
_copy_traversable,
_is_dir,
_is_file,
_occupied,
_renderer_unit_required,
_walk_traversable_files,
_worktree_local_exclude,
dev_primitive_or_default,
merge_hooks,
missing_stories_support,
renderer_stub_resolved,
resolve_review_layers,
strip_relay_hooks,
)
from .model import Phase
from .platform_util import atomic_write_text
from .process_host import get_process_host
from .workspace import (
UnitWorkspace,
Workspace,
close_unit_workspace,
discard_worktree,
unit_worktrees_dir,
)
if TYPE_CHECKING:
from importlib.resources.abc import Traversable
from .adapters.base import CodingCLIAdapter
from .adapters.profile import CLIProfile
from .bmadconfig import ProjectPaths
from .journal import Journal
from .model import RunState, StoryTask
from .plugins import PluginRegistry
from .policy import Policy
# CLI profile name -> the agent id the Unity-MCP CLI's `setup-mcp` expects (see
# `unity-mcp-cli setup-mcp --list`). All but claude differ only by claude's
# "-code" suffix; codex/gemini/cursor and any custom profile pass through as-is.
_SETUP_MCP_AGENT_IDS = {"claude": "claude-code"}
def _setup_mcp_agent_id(profile_name: str) -> str:
"""Map a CLI profile name to its Unity-MCP `setup-mcp` agent id."""
return _SETUP_MCP_AGENT_IDS.get(profile_name, profile_name)
def _worktree_skill_copy_candidates(repo_root: Path, tree: str) -> tuple[str, ...]:
"""Every upstream skill worth best-effort copying into ``tree``."""
resolved = resolve_review_layers(repo_root, tree)
return tuple(dict.fromkeys((*BASE_SKILLS, *(resolved.skills() if resolved else ()))))
def _required_worktree_skills(repo_root: Path, tree: str) -> tuple[str, ...]:
"""Upstream skills whose absence deterministically stalls this tree's run.
Match :func:`install.missing_base_skills`: gate the selected dev primitive and
resolved required review skills only. If the review shape is unknown, prefer a
present merged reviewer; otherwise require the standalone fallback reviewers.
Catalog-only and advisory skills remain copy candidates but never arm the fatal
pre-dispatch completeness gate.
"""
resolved = resolve_review_layers(repo_root, tree)
if resolved is not None:
review_skills = tuple(resolved.required)
elif (repo_root / tree / MERGED_REVIEW_SKILL / "SKILL.md").is_file():
review_skills = (MERGED_REVIEW_SKILL,)
else:
review_skills = tuple(sorted(_REVIEW_LAYER_SKILLS))
return tuple(dict.fromkeys((dev_primitive_or_default(repo_root, tree), *review_skills)))
def _escape_exclude_pattern(pattern: str) -> str:
"""Render one shield pattern so it names the literal path it spells (#476).
Patterns are built as ``f"/{rel}"`` from real on-disk rels, but git reads them
as gitignore(5) patterns, so a rel carrying pattern syntax names something
else entirely — and it goes wrong in both directions at once. Measured on git
2.55.0, identical at 2.20.4 (the shield's floor):
* ``/cfg[env]`` leaves ``cfg[env]/conf.json`` STAGEABLE. The path we seeded is
never shielded, which is the one job the shield has (#476).
* The same line hides ``cfge/`` and ``cfgn/``, because ``[env]`` is a class
over ``e``/``n``/``v``. A broken pattern silently hides an UNRELATED file
that the unit meant to commit (#401, consolidated into #476).
An unescaped trailing space does both in one line: git drops it, so ``/kept``
plus a space shields ``kept/`` and leaves ``kept /`` visible.
gitignore(5)'s own escape rule is the fix — a backslash before a wildmatch
special (``*``, ``?``, ``[``, and the backslash itself) or before a trailing
space makes git match that character literally. ``]`` is deliberately left
alone: it is not special without an opening ``[``, which this escapes. ``!``
and ``#`` matter only at line start, and every pattern starts with ``/``.
One trailing ``/`` is split off and re-appended unescaped: it is the MUSTBEDIR
marker (``RENDER_DIR_REL``), not part of the name.
Ordinary rels come back byte-identical, which is what makes this inert for
every path the shield has ever written.
"""
body, suffix = (pattern[:-1], "/") if pattern.endswith("/") else (pattern, "")
for special in ("\\", "*", "?", "["):
body = body.replace(special, "\\" + special)
stripped = body.rstrip(" ")
return stripped + "\\ " * (len(body) - len(stripped)) + suffix
def _fits_one_exclude_line(pattern: str) -> bool:
"""True when ``pattern`` survives a round trip through the exclude file.
The exclude is a line-oriented format with NO escape for its own line
boundary, so two characters in a real filename cannot be written as a pattern
at all — unlike the wildmatch specials, which :func:`_escape_exclude_pattern`
can quote. `_worktree_local_exclude` writes each pattern `\n`-terminated
(`install.py`), and git reads lines back the way #472 measured them at 2.55.0:
`\n` boundaries with exactly ONE trailing `\r` trimmed.
* An embedded ``\n`` SPLITS the pattern into two. Neither half names the file,
so it is not shielded — and the orphaned second half is a live, unanchored
pattern that can hide an UNRELATED file, which is #401's harm direction
arriving through the one character #476's escaping cannot quote.
* A TRAILING ``\r`` is eaten as the line terminator's other half, so the
pattern names the path WITHOUT it — #476's harm direction, same cause.
An embedded ``\r`` is content to git (`/hidden\rjunk` ignores nothing but a
file spelled that way), so it needs no exclusion here.
"""
return "\n" not in pattern and not pattern.endswith("\r")
def _reconcile_tracked_patterns(
worktree: Path, patterns: set[str], written: Collection[str]
) -> tuple[set[str], str | None]:
"""Reshape the shield patterns that name a TRACKED path; keep the rest as written.
Git consults ignore rules only for UNTRACKED paths, so what a pattern is worth
depends on what it names — and the two tracked shapes need opposite answers.
A pattern naming a tracked REGULAR FILE shields nothing and costs something
(#392, reported from production by an external user whose repo-hygiene gate then
blocked the story's commit). Measured on git 2.55.0 with the shield's own
private-exclude + worktree-scoped `core.excludesFile` shape, a tracked
`.codex/hooks.json`, and its pattern present:
* `git add -A` STAGES a modification to it anyway — ignore rules are consulted only
for untracked paths, so the pattern never did the job it is here for;
* `git ls-files -ci --exclude-standard` inside the worktree REPORTS it, which is the
tracked-and-ignored state hygiene gates reject.
So the pattern is pure cost and it is DROPPED. This is the second half of #384
— its reporter proposed it as their option 3 ("skip any pattern whose path already
contains tracked files … costs nothing and removes the surprising case entirely");
PR #385 landed option 1 (scope + lifetime) and this half was dropped rather than
rejected, which is how a second reporter hit it.
A pattern naming a tracked DIRECTORY is that reporter's shape one step out (#484),
and its answer is SUBSTITUTION. The measurement that used to justify keeping it
still stands and is kept here: a dir pattern really does hide new children, and no
pattern SHAPE both hides them and keeps the report clean — `dir/*`, `dir/**` and
`dir` plus a trailing negation all measured identical to `dir`, because gitignore
cannot re-include anything under an excluded parent, while the one shape that did
clear the report (`dir/*` with per-child negations) leaked a new file into the
commit. What reverses is the VERDICT, on the #392 physics: over a tracked tree the
pattern's protection is already mostly inert — every modification to a tracked
child stages regardless — so it was buying new-child coverage alone at the price of
a false tracked-and-ignored report for the whole tree.
The fix is therefore a different set of PATHS rather than a different shape: drop
the dir pattern and substitute one pattern per file THIS provisioning run actually
wrote below it (``written``). Those files are untracked by construction —
copy-when-absent cannot land on top of a tracked child — which is why they are
exactly the case an ignore rule works for, and why none of them is re-probed here:
the spawn count stays one per pattern the caller built. A tracked directory that
received nothing at all drops to no pattern, which is the same answer for the same
reason: there is nothing of ours below it to shield.
ACCEPTED RESIDUAL (maintainer decision on #484, 2026-08-08, which is this
function's design authority): a file the SESSION creates under a tracked tool
directory can be staged, where the dir pattern would have hidden it. That is the
trade, deliberately taken — it matches the project's own decision to TRACK that
tree, and everything the orchestrator put there keeps a pattern of its own. It is
not a defect to fix by widening back to a dir pattern.
APPEND-ONLY RESIDUE: `_worktree_local_exclude` never removes a line, by design —
operator lines ride in its rewrite prefix and its last-match-wins reasoning depends
on nothing being deleted, with no marker separating our stale line from theirs. So
a `/dir` line an OLDER provisioning of the SAME worktree wrote survives a
re-provision that would no longer write it. Accepted rather than fixed: the residue
can only be too wide, never too narrow, and the private exclude dies with the
worktree.
Patterns ending in `/` are directory-shaped by construction (`RENDER_DIR_REL`) and
are never probed.
A substituted rel that cannot be spelled as ONE exclude line
(:func:`_fits_one_exclude_line`) sends the WHOLE directory back to its dir
pattern, journaled. That is the same trade as the degrade below and for the same
reason: no per-file pattern exists for such a name, so substituting would leave
the file stageable and — for a newline — write an orphan half-pattern that hides
unrelated files.
Degrades by KEEPING every pattern it could not resolve, in its ORIGINAL shape —
the dir shape included, never the substitution. A shield staying too wide is
cosmetic, while dropping or narrowing a pattern on a guess can leak the
orchestrator's own seeded files into a story commit. Returns the surviving patterns
and a reason for the caller to journal, or None when nothing went wrong.
NOT-A-REPO IS SILENT, and it is the ordinary case rather than an edge one: many
callers provision plain non-repo directories, where there is no index, no shield and
no `git add -A` to be wrong about. The same `rev-parse --absolute-git-dir` gate
`_worktree_local_exclude` skips on is asked FIRST, so this cannot emit a degrade per
call and train operators past the one that matters. Only a repo that answers that
probe and then fails the per-path one is reported."""
try:
if verify.git_bytes(worktree, "rev-parse", "--absolute-git-dir").returncode != 0:
return patterns, None
except (verify.GitError, OSError):
return patterns, None
kept: set[str] = set()
unprobed: list[str] = []
unwritable: list[str] = []
for pattern in patterns:
rel = pattern.lstrip("/")
if not rel or pattern.endswith("/"):
kept.add(pattern)
continue
try:
kind = verify.path_tracked_kind(worktree, rel)
except (verify.GitError, OSError) as e:
kept.add(pattern)
unprobed.append(f"{rel} ({e})")
continue
if kind == "untracked":
kept.add(pattern)
elif kind == "dir":
subs = {f"/{w}" for w in written if w.startswith(rel + "/")}
unrepresentable = sorted(s for s in subs if not _fits_one_exclude_line(s))
if unrepresentable:
# Substituting here would write a pattern the exclude cannot carry,
# leaving that file stageable AND (for a `\n`) loosing an unanchored
# orphan pattern on unrelated files. The dir pattern is the only shape
# that still covers it, so this degrades the way every other
# unanswerable case does: KEEP the original, journal the reason. Too
# wide is cosmetic — this tree keeps its tracked-and-ignored report —
# while dropping or half-substituting leaks provisioned work into the
# unit's commit.
kept.add(pattern)
unwritable.extend(unrepresentable)
else:
kept |= subs
# "file": dropped. The pattern is measurably inert over a tracked file and
# only feeds it to repo-hygiene gates as tracked-and-ignored (#392).
reasons: list[str] = []
if unprobed:
reasons.append(
"worktree git-add shield could not check whether these paths are tracked, "
"so their patterns were kept as built; a tracked path among them will read "
f"as ignored to repo-hygiene checks (#392, #484): {'; '.join(sorted(unprobed))}"
)
if unwritable:
reasons.append(
"worktree git-add shield kept a tracked directory's whole-dir pattern "
"because a file provisioning wrote below it cannot be spelled as one "
"exclude line (a newline, or a trailing carriage return); that directory "
"will read as ignored to repo-hygiene checks (#484): "
f"{'; '.join(sorted(unwritable))}"
)
return kept, (" ".join(reasons) if reasons else None)
def _pin_tracked_config_rewrite(worktree: Path, rel: str) -> str | None:
"""Keep a rewritten TRACKED hook config out of the unit's story commits.
The worktree-local exclude cannot: git consults ignore rules only for
untracked paths (#392). When a project tracks its hook config, the relay
rewrite — a machine-specific absolute command — would ride every
`git add -A` (the skill's own commits and finalize_commit alike) into the
story commit and merge back to the target branch, handing every other
checkout a relay path that does not exist there. The worktree's own index
carries a skip-worktree bit that `add -A`, `status` and checkout all honor
(it is the sparse-checkout mechanism) and that dies with the worktree, so
the rewrite stays session-local.
While the pin holds, the config is orchestrator-owned: a story's own edit to
the pinned file stays session-local and is discarded with the worktree. That
is deliberate — before this pin the tracked case stalled outright (#352), so
there is no prior working behavior to preserve, and any file-level hiding
that keeps OUR rewrite out of `add -A` hides a story's edit with it.
NOT-A-REPO IS SILENT for the same reason the shield's tracked-probe is:
provisioning a plain directory is ordinary, and there is no index and no
`git add -A` to be wrong about. An untracked config needs nothing — the
exclude shield owns it. A tracked-probe that cannot answer is returned for
the caller to journal (observation degrades; the rewrite stands, since a
stalled session is the worse outcome, #352). But a failed update-index on a
KNOWN-tracked config raises: the pin is a repair write, and continuing
without it knowingly leaves `git add -A` free to commit the machine-specific
command and merge it back.
"""
try:
if verify.git_bytes(worktree, "rev-parse", "--absolute-git-dir").returncode != 0:
return None
except (verify.GitError, OSError):
return None
try:
if not verify.path_tracked_file(worktree, rel):
return None
except (verify.GitError, OSError) as e:
return (
f"could not check whether the rewritten hook config {rel} is tracked "
f"({e}); if the project tracks it, the worktree's machine-specific relay "
"command may be committed and merged back (#352)"
)
pinned = verify.git_bytes(worktree, "update-index", "--skip-worktree", "--", rel)
if pinned.returncode != 0:
raise verify.GitError(
f"git update-index --skip-worktree {rel} failed in the worktree; the hook "
"config is tracked, so without the pin its machine-specific relay rewrite "
"would reach story commits and merge back (#352)"
)
return None
def _seed_bmad_tree(worktree: Path, repo_root: Path) -> tuple[list[str], list[str]]:
"""Merge the repo's project-local BMAD surface into an isolated worktree.
Renderer-backed skills receive the worktree as their project root and do not
walk upward for ``_bmad``. Copy every usable file except generated render output,
per-file and without clobbering checkout content. The shared Traversable walk is
intentional: unlike ``rglob``, it descends a symlinked child directory, allowing
the result-side completeness predicates to see every file the copier considered.
Returns ``(shield_rels, written)``, two readings of the same copy:
* ``shield_rels`` — what the caller turns into git-add shield patterns: ``[]``
when nothing landed, ``[BMAD_DIR]`` when the root was absent before seeding
(one pattern covers a tree that is wholly ours), otherwise each file that
actually landed. A fresh worktree checkout materializes every tracked path, so
"root absent AND tracked" cannot arise and this collapse is already correct for
the tracked-directory rule (#484): the collapsed shape only ever names an
untracked root.
* ``written`` — always the per-file landed rels, never the collapse. This is the
substitution ledger the shield reads when a tool directory turns out to be
TRACKED (#484): the dir pattern is dropped and these files get patterns of
their own, so the answer has to stay per-file even when ``shield_rels``
collapsed.
"""
src_root = repo_root / BMAD_DIR
if not _is_dir(src_root):
return [], []
dst_root = worktree / BMAD_DIR
had_bmad = _is_dir(dst_root)
try:
tops = sorted(src_root.iterdir(), key=lambda entry: entry.name)
except OSError:
return [], []
seeded: list[str] = []
for top in tops:
if top.name in BMAD_SEED_EXCLUDES:
continue
for rel, src in _walk_traversable_files(top, top.name):
if not _is_file(src):
continue
dst = dst_root.joinpath(*rel.split("/"))
if _copy_traversable(
src,
dst,
skip_existing=True,
worktree=worktree,
repo_root=repo_root,
):
seeded.append(f"{BMAD_DIR}/{rel}")
if not seeded:
return [], []
return ([BMAD_DIR] if not had_bmad else seeded), seeded
def _record_seeded(
seeded_from: dict[Path, Path],
landed: Sequence[Path],
src: Path,
dst: Path,
) -> None:
"""Record where each path a seed entry just wrote was copied FROM.
`_copy_traversable` reproduces the source tree's shape under ``dst``, so a landed
path's rel against ``dst`` is also its source's rel against ``src``; the root of a
file entry gives ``Path(".")``, which pathlib drops, mapping ``dst`` to ``src``.
``src`` is the RESOLVED source, so a seed entry reached through a symlink names
the file that really holds the bytes rather than the link the operator listed.
First writer wins: nothing overwrites an earlier entry, because a path is only
ever written once — copy-when-absent makes the second entry naming it a skip, and
a skip lands nothing to record (#592).
"""
for path in landed:
seeded_from.setdefault(path, src / path.relative_to(dst))
def _written_rels(worktree: Path, landed: Sequence[Path]) -> list[str]:
"""Worktree-relative rels of the FILES a copy call just wrote.
Feeds the shield's tracked-directory substitution (#484): over a tracked tool
dir the whole-dir pattern is replaced by one pattern per file provisioning
actually landed there, so this is the ledger of what there is to shield.
The :func:`_is_file` filter is load-bearing rather than defensive.
``_copy_traversable``'s ``copied_paths`` records every destination that landed —
each file written AND each directory the call created (``install.py:1427-1436``).
A directory rel reaching the substitution would render as another whole-dir
pattern, reintroducing the exact shape #484 removes, one level down.
``as_posix`` so a substituted pattern anchors on Windows too; ``os.sep`` would
not, and the exclude is read by git, not by the platform.
"""
return [p.relative_to(worktree).as_posix() for p in landed if _is_file(p)]
def _bmad_scripts_seed_incomplete(worktree: Path, repo_root: Path) -> bool:
"""Whether a required repo renderer unit member missed the worktree.
Match the renderer preflight's content-keyed required-file predicate. Arbitrary
sibling scripts are still merge-seeded, but their absence cannot prove the
renderer will HALT and therefore must not arm the CRITICAL escalation gate.
"""
return any(
_renderer_unit_required(repo_root, rel)
and _is_file(repo_root / rel)
and not _is_file(worktree / rel)
for rel in RENDERER_SCRIPT_UNIT_REL
)
def _central_config_seed_incomplete(worktree: Path, repo_root: Path) -> bool:
"""Whether the repo's required renderer config failed to reach the worktree."""
return _is_file(repo_root / CENTRAL_CONFIG_REL) and not _is_file(worktree / CENTRAL_CONFIG_REL)
def base_skills_seed_incomplete(worktree: Path, repo_root: Path, trees: Sequence[str]) -> list[str]:
"""Required upstream skill rels present in the repo but absent in the worktree.
``BASE_SKILLS`` is a copy-if-present catalog, not a requirement set. Gate only
the selected primitive and required review skills; advisory/conditional and
inactive catalog entries are still provisioned best-effort, but their absence
cannot prove the dev/review session will stall. Unknown review shapes use the
same merged-or-standalone fallback as the run-start preflight.
Within those active skills, mirror the deterministic run-start contract rather
than treating every descendant as fatal: reviewers require ``SKILL.md``; the dev
primitive additionally requires its defined markers and any renderer snapshot
sources its worktree copy resolves. Other descendants still copy best-effort,
but their absence does not prove the outer session will write no artifact.
A missing ``SKILL.md`` reports the coarse skill rel. A repo directory without
``SKILL.md`` remains the run-start preflight's concern and cannot produce a false
CRITICAL escalation here. Stories mode adds its content-keyed dispatch probe at
the caller, where the run mode is available.
"""
missing: list[str] = []
for tree in dict.fromkeys(trees):
primitive = dev_primitive_or_default(repo_root, tree)
for skill in _required_worktree_skills(repo_root, tree):
repo_skill = repo_root / tree / skill
worktree_skill = worktree / tree / skill
if not _is_file(repo_skill / "SKILL.md"):
# Distinguish an unreadable directory from a skill the repo simply
# does not carry. The walk yields only the former as a directory leaf.
if any(_is_dir(src) for _, src in _walk_traversable_files(repo_skill)):
missing.append(f"{tree}/{skill}")
continue
if not _is_file(worktree_skill / "SKILL.md"):
missing.append(f"{tree}/{skill}")
continue
if skill != primitive:
continue
missing.extend(
f"{tree}/{skill}/{rel}"
for rel in DEV_PRIMITIVE_MARKERS
if _is_file(repo_skill / rel) and not _is_file(worktree_skill / rel)
)
missing.extend(
f"{tree}/{skill}/{rel}"
for rel in _absent_renderer_sources(worktree_skill)
if _is_file(repo_skill.joinpath(*rel.split("/")))
)
return missing
def worktree_seed_undelivered(
worktree: Path,
repo_root: Path,
seed_files: Sequence[str] = (),
seed_globs: Sequence[str] = (),
config_paths: Sequence[str] = (),
) -> list[str]:
"""Seed rels the repo carries that never reached the worktree.
Source containment is deliberately *not* an eligibility requirement: a source
symlink resolving outside the repo is the canonical entry the seed loop refuses
and this result check must report. Delivery does require every usable source
entry to remain within the repo, matching the copier. Destination containment is
likewise required so a path outside the worktree cannot masquerade as delivery.
Hook configs need a different result question because provisioning writes the
hook registration itself after seeding. For those rels, existence proves nothing;
source escape, a symlinked destination, or destination escape proves the seed was
refused. This report is informational and is never an escalation gate.
"""
unresolved_repo_root = repo_root
try:
worktree = worktree.resolve()
repo_root = repo_root.resolve()
except (OSError, RuntimeError):
# Observation only: root uncertainty cannot prove delivery, but it must
# not turn an informational journal probe into a run-wide failure.
rels = [str(rel) for rel in seed_files]
for pattern in seed_globs:
try:
matches = sorted(unresolved_repo_root.glob(pattern))
except (OSError, RuntimeError):
continue
rels.extend(match.relative_to(unresolved_repo_root).as_posix() for match in matches)
return list(dict.fromkeys(rels))
rels = [str(rel) for rel in seed_files]
for pattern in seed_globs:
rels.extend(
match.relative_to(repo_root).as_posix() for match in sorted(repo_root.glob(pattern))
)
hook_configs = {Path(rel) for rel in config_paths}
def contained(path: Path, root: Path) -> bool:
try:
return path.resolve().is_relative_to(root)
except (OSError, RuntimeError):
return False
def delivered(src: Path, dst: Path) -> bool:
"""Whether every usable source descendant has a matching destination."""
if not contained(src, repo_root) or not contained(dst, worktree):
return False
if _is_file(src):
return _is_file(dst)
if not _is_dir(src) or not _is_dir(dst):
return False
complete = True
def visit_dir(rel: str, source) -> bool:
nonlocal complete
if not isinstance(source, Path) or not contained(source, repo_root):
complete = False
return False
target = dst.joinpath(*rel.split("/")) if rel else dst
if not contained(target, worktree) or not _is_dir(target):
complete = False
return True
for child_rel, child in _walk_traversable_files(src, _visit_dir=visit_dir):
target = dst.joinpath(*child_rel.split("/")) if child_rel else dst
if _is_file(child):
if (
not isinstance(child, Path)
or not contained(child, repo_root)
or not contained(target, worktree)
or not _is_file(target)
):
complete = False
elif _is_dir(child):
# Directories are yielded only when enumeration was refused, so
# their unknown descendants cannot be claimed as delivered.
complete = False
return complete
undelivered: list[str] = []
for rel in dict.fromkeys(rels):
src = repo_root / rel
if not (_is_file(src) or _is_dir(src)):
continue
dst = worktree / rel
if Path(rel) in hook_configs:
try:
destination_is_link = dst.is_symlink()
except OSError:
destination_is_link = True
if not contained(src, repo_root) or not contained(dst, worktree) or destination_is_link:
undelivered.append(rel)
continue
if delivered(src, dst):
continue
undelivered.append(rel)
return undelivered
def module_skills_seed_undelivered(
worktree: Path,
trees: Sequence[str],
skills_root: Traversable | None = None,
) -> list[str]:
"""Wheel-bundled ``MODULE_SKILLS`` whose content never reached the worktree.
Re-probes DISK, never the copier's bookkeeping: a user-authored
``scm.worktree_seed`` entry that happens to spell a skill rel can therefore
neither forge nor mask a report. The source is the wheel's own skills tree, which
may be a zip Traversable rather than a real directory, so enumeration goes through
the shared :func:`install._walk_traversable_files` walk instead of ``rglob``.
Presence is the whole contract; content is NEVER compared. Seeding is per-FILE
no-clobber, so a checkout carrying its own divergent fork of a bundled skill keeps
those bytes while its absent siblings are filled in — comparing content would
report that healthy shape as undelivered. A skill the wheel itself lacks is
skipped: a broken wheel is the module-skills sync test's concern, not a run's.
Informational, and NEVER an escalation gate. No ``MODULE_SKILLS`` entry has a
worktree-resident consumer — sweep triage dispatches ``/bmad-loop-sweep`` at the
MAIN checkout, ``bmad-loop-resolve`` runs at the main checkout too, and
``bmad-loop-setup`` has no session consumer at all — so an absence here cannot
prove a stall, and a CRITICAL gate would refuse healthy runs. Extension point: if
a consumer ever becomes worktree-resident (e.g. sweep triage moving into unit
worktrees), arm the gate by routing this predicate's result into
:meth:`WorktreeFlow.escalate_unit` for that consumer's required subset.
Returns coarse ``"{tree}/{skill}"`` posix rels in iteration order.
"""
if skills_root is None:
skills_root = resources.files("bmad_loop.data").joinpath("skills")
try:
worktree = worktree.resolve()
except (OSError, RuntimeError):
# This is a journal-only observation. Root uncertainty means every
# bundled skill the wheel actually carries is coarsely undelivered.
return [
f"{tree}/{skill}"
for tree in dict.fromkeys(trees)
for skill in MODULE_SKILLS
if _is_file(skills_root.joinpath(skill)) or _is_dir(skills_root.joinpath(skill))
]
def contained(target: Path) -> bool:
try:
return target.resolve().is_relative_to(worktree)
except (OSError, RuntimeError):
return False
def delivered(src: Traversable, dst: Path) -> bool:
"""Whether every usable wheel entry has a matching worktree destination."""
for rel, entry in _walk_traversable_files(src):
target = dst.joinpath(*rel.split("/")) if rel else dst
if _is_file(entry):
if contained(target) and _is_file(target):
continue
return False
if _is_dir(entry):
# Directories are yielded only when enumeration was refused, so
# their unknown descendants cannot be claimed as delivered.
return False
# Neither a readable file nor a directory: the copier has no path for
# such an entry either, so its absence is not a delivery failure.
return True
undelivered: list[str] = []
for tree in dict.fromkeys(trees):
for skill in MODULE_SKILLS:
src = skills_root.joinpath(skill)
if not (_is_file(src) or _is_dir(src)):
continue
if not delivered(src, worktree / tree / skill):
undelivered.append(f"{tree}/{skill}")
return undelivered
def provision_worktree(
worktree: Path,
profiles: Sequence[CLIProfile],
repo_root: Path,
seed_files: Sequence[str] = (),
seed_globs: Sequence[str] = (),
*,
on_degraded: Callable[[str], None] | None = None,
) -> list[str]:
"""Make a freshly-created git worktree a self-sufficient bmad-loop project.
A worktree checks out tracked files only, but the skill trees (.claude/skills,
.agents/skills), the hook config, and the project's gitignored MCP/CLI configs
are absent from the checkout. Without them the bundled bmad-loop-* skills are missing,
the Stop-signal hook never fires, and isolated sessions can't reach their MCP
server. Lay the bundled skills + signal hook into the worktree for the active
CLI profiles, and copy the `seed_files` configs in from the main repo. The
upstream skills the orchestrator drives (BASE_SKILLS: bmad-build-auto + the review
hunters, plus whatever review layers this project's own config names) are not
bundled in the wheel, so they are copied from the MAIN REPO's installed tree
instead — together with `_bmad/custom/`, the customization those layers resolve
through, so the isolated run resolves the same layer set the preflight
validated. Quiet (no stdout) — unlike `install_into` this runs inside the
engine loop under a TUI. No-op when there's nothing to do.
seed_globs are project-relative glob patterns (e.g. ".claude/skills/*") expanded
against the main repo; every match is copied into the worktree under the same
relative path, copy-when-absent like seed_files. A game-engine plugin uses these
to pull its MCP-generated skill tree (gitignored, so absent from the checkout)
into a per_worktree Editor's checkout.
A `seed_files` entry naming a DIRECTORY whose destination already exists is
seeded child by child: the children the checkout lacks are copied in, the ones
it carries are left untouched. A worktree checks out tracked files, so such a
dir always exists and the entry would otherwise be a total no-op (issue #230).
Kept safe against the unit's eventual `git add -A` commit:
- skills + seed files are copied only when ABSENT — at FILE granularity, so a
project that commits its own skill tree (e.g. .agents/) or config keeps it
untouched (no diff merged back);
- the hook points at the MAIN repo's already-installed relay via an absolute
path (the relay locates its events directory from $BMAD_LOOP_EVENTS_DIR,
falling back to $BMAD_LOOP_RUN_DIR/events — never from its own location),
so nothing is written into the worktree's .bmad-loop/. Since #494 the
primary channel is out of the project tree entirely, so a worktree cannot
carry a run's control plane at all — but the fallback still resolves under
the MAIN run dir, so the guarantee holds for an older installed relay too;
- everything we wrote is excluded from git, in a file private to THIS worktree
(`.git/worktrees/<id>/info/exclude`, activated per-worktree) that dies with it
when the worktree is removed. It is never the repository-wide
`.git/info/exclude`: that file is shared with the operator's own checkout and
permanent, so shielding through it hid every new file under a tool dir from
their `git add -A` forever (#384). That write is best-effort: when git can't
be queried at all it is skipped silently, but any fault after that — including
a refusal to scope the shield, and a shield that was written but which git does
not resolve to — is reported to `on_degraded` (once, with the reason) rather
than swallowed, and the shield is skipped rather than widened back to the
shared file. An unshielded worktree lets `git add -A` stage the tool files, so
it must not fail invisibly.
Skill trees, the per-CLI hook config, and the seeded configs all live in dirs
projects gitignore — but the exclude shields them even when a project doesn't.
seed_files are copied BEFORE the hook step so a seeded settings file that is
also a hook config_path (.claude/settings.json, .gemini/settings.json) keeps its
real content rather than being created empty. Its relay entry is replaced, not
kept: the seeded copy carries the main repo's $CLAUDE_PROJECT_DIR-relative relay
command, which resolves to the worktree, so the hook step strips it and registers
its own absolute command in its place (#352). A config that is already there but
cannot be parsed refuses provisioning outright — `verify.GitError`, which the
caller escalates as CRITICAL and pauses the run — rather than being replaced by
a hooks-only file: an unparseable config is evidence of an earlier fault, and the
operator's bytes are left intact for inspection. The refusal sends the repair to
whichever source supplied those bytes — read from the per-path record of what
seeding actually wrote, never inferred from the seed entry that covers the path
(#592).
The repo's `_bmad/` surface is also merge-seeded, excluding generated render
output. Renderer and upstream-skill completeness failures share the return
channel with ordinary no-op seeds so they are journaled, but the caller re-probes
the skill result and content-gates the renderer sentinels before escalating.
Returns the `seed_files` entries that copied NOTHING because everything they
name was already present, plus reserved completeness reports. A directory entry
that seeded even one child is not a no-op and is not reported.
"""
if not profiles and not seed_files and not seed_globs and not _is_dir(repo_root / BMAD_DIR):
return []
unresolved_worktree = worktree
unresolved_repo_root = repo_root
try:
worktree = worktree.resolve()
repo_root = repo_root.resolve()
except (OSError, RuntimeError) as e:
raise verify.GitError(
"cannot resolve worktree provisioning roots safely "
f"(worktree={unresolved_worktree}, repo_root={unresolved_repo_root}): {e}"
) from e
relay = repo_root / HOOK_SCRIPT_REL
skills_root = resources.files("bmad_loop.data").joinpath("skills")
# project gitignored MCP/CLI configs: copy from the main repo when absent.
# Resolve-and-contain guards against an `..`/absolute entry escaping either tree.
seeded: list[str] = []
# Every FILE provisioning actually wrote, worktree-relative. `seeded` answers per
# ENTRY and collapses a directory to its root; this stays per-file, because over a
# TRACKED tool directory the shield drops the dir pattern and substitutes one
# pattern per file we landed under it (#484). A path is here only if this run
# wrote it, which is what makes the substituted patterns untracked by construction
# — copy-when-absent cannot land on top of a tracked child.
written: set[str] = set()
# Which source supplied each path seeding actually WROTE, keyed by where it
# landed. `seeded` answers per ENTRY and cannot be read per FILE: a directory
# entry records only its own rel below, and copy-when-absent skips occupied
# children one at a time, so a dir rel here means "at least one child landed",
# never "this child did". The hook step reads this map to name the real source of
# an unparseable config, where guessing sends the operator to repair the wrong
# file (#592). `_copy_traversable` mirrors the source layout under `dst`, so each
# landed path's rel is its source's rel too.
seeded_from: dict[Path, Path] = {}
# Entries that named a real source but copied nothing, because every path they
# name already exists. Reported to the caller (this function is quiet by
# contract — it runs under a TUI) because the no-op is otherwise silent: an
# entry that reads as applied configuration is not. Per-CHILD skips inside a
# directory entry are deliberately not reported — the checkout is expected to
# carry its tracked children, so that is routine rather than a
# misconfiguration, exactly like the glob-expanded matches below.
skipped: list[str] = []
for rel in seed_files:
raw = worktree / rel
try:
src = (repo_root / rel).resolve()
dst = raw.resolve()
except (OSError, RuntimeError):
continue
if not src.is_relative_to(repo_root) or not dst.is_relative_to(worktree):
continue
if not (_is_file(src) or _is_dir(src)):
continue
if _occupied(dst):
# A live symlink remains the ordinary existing-destination no-op. This
# arm must precede the raw-vs-resolved refusal below so it stays named in
# `skipped` rather than turning into a silent drop.
if dst != raw:
skipped.append(str(rel))
continue
# File entries keep the classic copy-when-absent skip. A destination
# that is not a directory while the source is (the checkout carries a
# FILE where the seed names a dir) is a type mismatch: recursing would
# try to mkdir over the file, so the entry is skipped whole instead.
if not _is_dir(src) or not _is_dir(raw):
skipped.append(str(rel))
continue
# A DIRECTORY whose destination exists is the case #230 reported: the
# checkout carries some tracked child, so the whole entry used to be a
# no-op — including the gitignored children that are absent and would
# clobber nothing. Recurse instead, copying only what is missing.
landed: list[Path] = []
if not _copy_traversable(
src,
raw,
skip_existing=True,
worktree=worktree,
repo_root=repo_root,
copied_paths=landed,
):
# every child was already present: still a total no-op, still
# reported. Only a PARTIAL seed stops being reported.
skipped.append(str(rel))
continue
# Partially seeded, so the entry must still reach `patterns` below:
# the children we just wrote have to stay out of the unit's
# `git add -A`. Excluding the whole dir is safe — an exclude does not
# untrack the tracked children that were already there.
seeded.append(rel)
_record_seeded(seeded_from, landed, src, raw)
written.update(_written_rels(worktree, landed))
continue
# `resolve()` is non-strict, so a dangling leaf or parent link answers for
# its target. Never mkdir/copy through it. Existing live links were handled
# by the skip arm above, preserving copy-when-absent reporting.
if dst != raw:
continue
landed = []
if _copy_traversable(
src,
raw,
skip_existing=True,
worktree=worktree,
repo_root=repo_root,
copied_paths=landed,
):
seeded.append(rel)
_record_seeded(seeded_from, landed, src, raw)
written.update(_written_rels(worktree, landed))
# glob-seeded trees (e.g. an engine plugin's MCP skill dirs): expand each
# pattern against the main repo and copy matches in, same contain guard +
# copy-when-absent semantics. rel is taken from the unresolved match so the
# worktree path mirrors the repo layout; resolve only guards containment.
for pattern in seed_globs:
for match in sorted(repo_root.glob(pattern)):
rel = match.relative_to(repo_root)
raw = worktree / rel
try:
src = match.resolve()
dst = raw.resolve()
except (OSError, RuntimeError):
continue
if not src.is_relative_to(repo_root) or not dst.is_relative_to(worktree):
continue
if not (_is_file(src) or _is_dir(src)) or _occupied(dst):
continue
if dst != raw:
continue
landed = []
if not _copy_traversable(
src,
raw,
skip_existing=True,
worktree=worktree,
repo_root=repo_root,
copied_paths=landed,
):
continue
# as_posix so the exclude pattern anchors on Windows too (os.sep would not)
seeded.append(rel.as_posix())
_record_seeded(seeded_from, landed, src, raw)
written.update(_written_rels(worktree, landed))
# Renderer-backed skills are handed the worktree as their project root. Merge
# the repo's project-local BMAD surface after explicit seeds (operator intent wins
# on collisions) and reserve the two renderer sentinels for result-side checks.
seeded_bmad, bmad_written = _seed_bmad_tree(worktree, repo_root)
written.update(bmad_written)
skipped = [rel for rel in skipped if rel not in RENDERER_SEED_SENTINELS]
if _bmad_scripts_seed_incomplete(worktree, repo_root):
skipped.append(BMAD_SCRIPTS_SEED_REL)
if _central_config_seed_incomplete(worktree, repo_root):
skipped.append(CENTRAL_CONFIG_REL)
# bundled skills into each CLI's skill tree (deduped: codex+gemini share one);
# never clobber a skill the checkout already carries (tracked or pre-existing).
for tree in dict.fromkeys(p.skill_tree for p in profiles):
tree_dir = worktree / tree
for skill in MODULE_SKILLS:
dst = tree_dir / skill
# `copied_paths` is read only by the shield: when this tree turns out to
# be TRACKED, these files are what gets a pattern in place of the dropped
# dir pattern (#484). Completeness is still re-probed on disk below —
# copy bookkeeping never arms a gate.
landed = []
_copy_traversable(
skills_root.joinpath(skill),
dst,
skip_existing=True,
worktree=worktree,
copied_paths=landed,
)
written.update(_written_rels(worktree, landed))
# Wheel MODULE_SKILLS get the same result-side completeness re-probe as every
# other seeded surface (`module_skills_seed_undelivered`, journaled by the
# caller as `worktree-module-skills-dropped`) — but journal-only, never the
# CRITICAL gate below: no MODULE_SKILLS entry has a worktree-resident consumer
# (sweep triage and bmad-loop-resolve dispatch at the MAIN checkout;
# bmad-loop-setup has no session consumer), so a partial wheel copy cannot
# prove a stall the way a missing upstream dev/review skill does. A future
# worktree-resident consumer arms the gate via escalate_unit over that
# predicate's result.
# The orchestrator-driven upstream skills are not in the wheel; copy them
# from the MAIN REPO's installed tree (same tree path) so an isolated
# worktree can still resolve the dev primitive and the review layers. Skip
# silently when the main repo lacks them — the run-start preflight reports
# it.
#
# BASE_SKILLS names BOTH primitive eras, which is what carries the skill
# across the rename: the resolution below returns REVIEW skills only, so a
# primitive the catalog did not name would be silently left behind (the
# is_dir guard swallows the miss) and every isolated session would stall on
# an Unknown command.
#