@danmoisan/drm-copilot-mcp 1.1.14 → 1.1.16

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 (39) hide show
  1. package/package.json +1 -1
  2. package/resources/claude-customizations/.claude/agents/powershell-typed-engineer.md +4 -5
  3. package/resources/claude-customizations/.claude/hooks/enforce-powershell-batch-budget-route.ps1 +137 -0
  4. package/resources/claude-customizations/.claude/hooks/enforce-powershell-batch-budget.ps1 +95 -66
  5. package/resources/claude-customizations/.claude/lib/ci-gate/Invoke-CiGateParser.ps1 +330 -0
  6. package/resources/claude-customizations/.claude/rules/powershell.md +3 -3
  7. package/resources/claude-customizations/.claude/rules/shell.md +8 -7
  8. package/resources/claude-customizations/.claude/skills/cleanup-merged-worktrees/SKILL.md +8 -8
  9. package/resources/claude-customizations/.claude/skills/cleanup-merged-worktrees/scripts/cleanup-worktrees.sh +234 -0
  10. package/resources/claude-customizations/.claude/skills/cleanup-merged-worktrees/scripts/cleanup_worktrees_actions_lib.sh +451 -0
  11. package/resources/claude-customizations/.claude/skills/cleanup-merged-worktrees/scripts/cleanup_worktrees_detached_lib.sh +301 -0
  12. package/resources/claude-customizations/.claude/skills/cleanup-merged-worktrees/scripts/cleanup_worktrees_dirt_lib.sh +495 -0
  13. package/resources/claude-customizations/.claude/skills/cleanup-merged-worktrees/scripts/cleanup_worktrees_enumerate_lib.sh +252 -0
  14. package/resources/claude-customizations/.claude/skills/cleanup-merged-worktrees/scripts/cleanup_worktrees_lib.sh +496 -0
  15. package/resources/claude-customizations/.claude/skills/cleanup-merged-worktrees/scripts/cleanup_worktrees_preserve_eol_lib.sh +212 -0
  16. package/resources/claude-customizations/.claude/skills/cleanup-merged-worktrees/scripts/cleanup_worktrees_preserve_lib.sh +492 -0
  17. package/resources/claude-customizations/.claude/skills/cleanup-merged-worktrees/scripts/cleanup_worktrees_report_records_lib.sh +476 -0
  18. package/resources/claude-customizations/.claude/skills/cleanup-merged-worktrees/scripts/cleanup_worktrees_scan_helper.sh +182 -0
  19. package/resources/claude-customizations/.claude/skills/epic-orchestrate/SKILL.md +1 -1
  20. package/resources/claude-customizations/.claude/skills/invoke-powershell-engineer/SKILL.md +3 -4
  21. package/resources/claude-customizations/.claude/skills/orchestrate/SKILL.md +1 -1
  22. package/resources/claude-customizations/.claude/skills/powershell-change-budget-router/SKILL.md +6 -6
  23. package/resources/claude-customizations/pack-manifests/core.json +11 -0
  24. package/resources/claude-customizations/pack-manifests/powershell.json +1 -0
  25. package/resources/codex-and-agents-customizations/.agents/skills/invoke-powershell-engineer/SKILL.md +1 -1
  26. package/resources/codex-and-agents-customizations/.agents/skills/powershell/SKILL.md +1 -2
  27. package/resources/codex-and-agents-customizations/.codex/agents/powershell-typed-engineer-c1.toml +2 -3
  28. package/resources/codex-and-agents-customizations/.codex/agents/powershell-typed-engineer-c2.toml +2 -3
  29. package/resources/codex-and-agents-customizations/.codex/agents/powershell-typed-engineer-c3-elevated.toml +2 -3
  30. package/resources/codex-and-agents-customizations/.codex/agents/powershell-typed-engineer-c3.toml +2 -3
  31. package/resources/codex-and-agents-customizations/.codex/agents/powershell-typed-engineer-c4.toml +2 -3
  32. package/resources/codex-and-agents-customizations/.codex/agents/powershell-typed-engineer.toml +2 -3
  33. package/resources/codex-and-agents-customizations/.codex/config.toml +1 -1
  34. package/resources/codex-and-agents-customizations/.codex/hooks/enforce-powershell-batch-budget.ps1 +285 -80
  35. package/resources/customizations/.github/agents/powershell-orchestrator.agent.md +7 -7
  36. package/resources/customizations/.github/agents/powershell-typed-engineer.agent.md +8 -10
  37. package/resources/customizations/.github/prompts/orchestrate-powershell-work.prompt.md +2 -2
  38. package/resources/customizations/.github/skills/powershell-change-budget-router/SKILL.md +3 -3
  39. package/resources/powershell/PoshQC/settings/pester.runsettings.psd1 +6 -0
@@ -0,0 +1,476 @@
1
+ #!/usr/bin/env bash
2
+ # cleanup_worktrees_report_records_lib.sh: sourceable report-record function group for
3
+ # the cleanup-worktrees tool. It adds the report-mode visibility records that the
4
+ # classification ladder cannot see on its own: filesystem directories that are no
5
+ # longer registered worktrees, remote-tracking refs whose remote is gone, worktrees
6
+ # whose gitdir pointer no longer resolves, and the ancestor relationship between two
7
+ # branches that both resolve NOT_MERGED. The enumeration/protection seam
8
+ # (cleanup_wt_git, enumerate_branches, parse_worktree_list, normalize_wt_path,
9
+ # compute_protected, check_main_freshness) lives in cleanup_worktrees_enumerate_lib.sh
10
+ # and the classification ladder (classify_branch) lives in cleanup_worktrees_lib.sh;
11
+ # both MUST be sourced before this file. Keeping these records in a sibling file keeps
12
+ # every file within the 500-line cap.
13
+ #
14
+ # Sourcing contract: this library defines functions only; it never runs work at source
15
+ # time, so the wrapper and the bats suites can source it without side effects.
16
+ #
17
+ # Report line contract added by this library (pipe-delimited, LC_ALL=C ordered, one
18
+ # record per line; all four are advisory/read-only and never unlock a destructive
19
+ # action):
20
+ # ORPHAN_DIR|<path>|<size> a directory under a worktree-tracking root
21
+ # with no `.git` file and no registration.
22
+ # <size> is best-effort and may be the
23
+ # literal `unknown`.
24
+ # STALE_REF|<refname> a refs/remotes/<name>/* ref whose <name> is
25
+ # not a configured remote. <refname> is the
26
+ # full ref form (refs/remotes/...).
27
+ # CHILD_OF|<branch>|<ancestor> advisory, informational: <branch> is a git
28
+ # ancestor of <ancestor>, and BOTH resolved
29
+ # exactly NOT_MERGED through their own
30
+ # unchanged ladders. Emitted alongside, never
31
+ # instead of, the branch's own BRANCH| line.
32
+ # It names a containment relationship only; no
33
+ # ladder rung is skipped for either branch and
34
+ # neither verdict is derived from the other.
35
+ # WARN|registration-lost|<path> a worktree directory whose `.git` gitdir
36
+ # pointer names a target that does not exist.
37
+ #
38
+ # Filesystem-scan seam: every filesystem read goes through cleanup_wt_scan_bin so the
39
+ # bats suites can stub the scan deterministically via CLEANUP_WT_SCAN_BIN, mirroring
40
+ # the CLEANUP_WT_GIT_BIN seam in cleanup_worktrees_enumerate_lib.sh. Unlike that seam,
41
+ # the fallback target is the bundled real implementation
42
+ # .claude/skills/cleanup-merged-worktrees/scripts/cleanup_worktrees_scan_helper.sh rather than a PATH lookup, because no
43
+ # single standard system binary emits the combined
44
+ # `path|has_gitfile|gitdir_target_exists|size` tuple these records need.
45
+
46
+ cleanup_wt_scan_bin() {
47
+ # Resolve the filesystem-scan helper honoring the CLEANUP_WT_SCAN_BIN override seam
48
+ # and echo its path; the caller invokes it.
49
+ #
50
+ # When CLEANUP_WT_SCAN_BIN is set to a non-empty value it must point to an existing
51
+ # executable; an empty or nonexistent value is treated as missing and falls back to
52
+ # the bundled cleanup_worktrees_scan_helper.sh alongside this library.
53
+ #
54
+ # Args: none. Always returns 0 and echoes exactly one path.
55
+ local override=${CLEANUP_WT_SCAN_BIN:-}
56
+ if [[ -n $override && -x $override ]]; then
57
+ printf '%s\n' "$override"
58
+ return 0
59
+ fi
60
+ local here
61
+ here=$(dirname "${BASH_SOURCE[0]}")
62
+ printf '%s\n' "$here/cleanup_worktrees_scan_helper.sh"
63
+ }
64
+
65
+ scan_stale_refs() {
66
+ # Emit STALE_REF|<refname> for every remote-tracking ref whose remote is gone.
67
+ #
68
+ # A ref under refs/remotes/<name>/ belongs to the configured remote <name>. When
69
+ # <name> is absent from `git remote`'s output the ref is a leftover: the remote was
70
+ # removed (or was never configured in this checkout) but its tracking refs were not
71
+ # pruned. The record is advisory and read-only; nothing here deletes a ref.
72
+ #
73
+ # The refname is emitted in full ref form (refs/remotes/<name>/<branch>) rather than
74
+ # the :short form, so the record names the ref exactly as `git for-each-ref` and
75
+ # `git update-ref` spell it. Output is LC_ALL=C sorted.
76
+ #
77
+ # Both git reads are captured in the PARENT shell (`out=$(...) || rc=$?`) so a
78
+ # non-zero exit is observed here rather than lost inside a process substitution. On a
79
+ # hard failure of either read this emits no STALE_REF line and returns git's non-zero
80
+ # exit code, mirroring parse_worktree_list's hard-failure contract: a git failure must
81
+ # never read as "no stale refs".
82
+ local rc=0 refs_out remotes_out ref name remote
83
+ refs_out=$(cleanup_wt_git for-each-ref --format='%(refname)' refs/remotes/) || rc=$?
84
+ if ((rc != 0)); then
85
+ printf 'cleanup-worktrees: git for-each-ref refs/remotes/ failed (rc=%s)\n' "$rc" >&2
86
+ return "$rc"
87
+ fi
88
+ remotes_out=$(cleanup_wt_git remote) || rc=$?
89
+ if ((rc != 0)); then
90
+ printf 'cleanup-worktrees: git remote failed (rc=%s)\n' "$rc" >&2
91
+ return "$rc"
92
+ fi
93
+ local -A configured=()
94
+ while IFS= read -r remote; do
95
+ [[ -z $remote ]] && continue
96
+ configured[$remote]=1
97
+ done <<<"$remotes_out"
98
+ local -a stale=()
99
+ while IFS= read -r ref; do
100
+ [[ -z $ref ]] && continue
101
+ [[ $ref == refs/remotes/* ]] || continue
102
+ name=${ref#refs/remotes/}
103
+ name=${name%%/*}
104
+ [[ -z $name ]] && continue
105
+ [[ -n ${configured[$name]:-} ]] && continue
106
+ stale+=("STALE_REF|$ref")
107
+ done <<<"$refs_out"
108
+ if ((${#stale[@]} > 0)); then
109
+ printf '%s\n' "${stale[@]}" | LC_ALL=C sort
110
+ fi
111
+ return 0
112
+ }
113
+
114
+ cleanup_wt_scan_roots() {
115
+ # Echo the worktree-tracking roots to scan, one per line.
116
+ #
117
+ # CLEANUP_WT_ORPHAN_ROOTS overrides the derivation with a colon-separated list.
118
+ # Otherwise BOTH roots derive from the main worktree path — the first
119
+ # `git worktree list --porcelain` stanza, the same derivation
120
+ # consolidation_worktree_path uses in cleanup_worktrees_actions_lib.sh. They are
121
+ # `<main-worktree-path>` joined with `.claude/worktrees` (the agent worktree folder)
122
+ # and `<main-worktree-path>-wt` (the sibling worktree folder), in that order.
123
+ #
124
+ # Deriving both from one read means a parse_worktree_list hard failure emits no root
125
+ # at all, rather than a bare relative path that would be resolved against whatever
126
+ # the process's current working directory happened to be. cleanup_wt_scan_records
127
+ # returns 0 with no record for an empty root list, so the advisory records degrade to
128
+ # silence rather than to a misleading scan. Always returns 0.
129
+ local override=${CLEANUP_WT_ORPHAN_ROOTS:-}
130
+ if [[ -n $override ]]; then
131
+ local IFS=:
132
+ local part
133
+ for part in $override; do
134
+ [[ -n $part ]] && printf '%s\n' "$part"
135
+ done
136
+ return 0
137
+ fi
138
+ local out rc=0 first_record main_wt=""
139
+ out=$(parse_worktree_list) || rc=$?
140
+ if ((rc != 0)); then
141
+ return 0
142
+ fi
143
+ first_record=${out%%$'\n'*}
144
+ main_wt=${first_record%%|*}
145
+ if [[ -n $main_wt ]]; then
146
+ printf '%s\n' "${main_wt}/.claude/worktrees"
147
+ printf '%s\n' "${main_wt}-wt"
148
+ fi
149
+ return 0
150
+ }
151
+
152
+ cleanup_wt_scan_records() {
153
+ # Echo the raw `<path>|<has_gitfile>|<gitdir_target_exists>|<size>` records for every
154
+ # candidate directory under the resolved scan roots, one per line.
155
+ #
156
+ # Every filesystem read goes through the CLEANUP_WT_SCAN_BIN seam, so the bats suites
157
+ # replay canned records instead of touching the real filesystem. The scan output is
158
+ # captured in the PARENT shell (`out=$(...) || rc=$?`) so a non-zero exit is observed
159
+ # here; on a hard failure this emits no record and returns the scan's exit code
160
+ # rather than letting a failed scan read as "nothing found".
161
+ local -a roots=()
162
+ local root
163
+ while IFS= read -r root; do
164
+ [[ -z $root ]] && continue
165
+ roots+=("$root")
166
+ done < <(cleanup_wt_scan_roots)
167
+ if ((${#roots[@]} == 0)); then
168
+ return 0
169
+ fi
170
+ local bin out rc=0
171
+ bin=$(cleanup_wt_scan_bin)
172
+ # Every script under scripts/bash/ is tracked mode 100644 and is invoked through the
173
+ # interpreter rather than by its exec bit, so the bundled fallback helper is run with
174
+ # `bash` when it is not executable. An override supplied through CLEANUP_WT_SCAN_BIN
175
+ # is always executable (cleanup_wt_scan_bin only accepts it when it is) and is run
176
+ # directly, so an override may be any executable, not only a bash script.
177
+ if [[ -x $bin ]]; then
178
+ out=$("$bin" scan-dirs "${roots[@]}") || rc=$?
179
+ else
180
+ out=$(bash "$bin" scan-dirs "${roots[@]}") || rc=$?
181
+ fi
182
+ if ((rc != 0)); then
183
+ printf 'cleanup-worktrees: filesystem scan failed (rc=%s)\n' "$rc" >&2
184
+ return "$rc"
185
+ fi
186
+ if [[ -n $out ]]; then
187
+ printf '%s\n' "$out"
188
+ fi
189
+ return 0
190
+ }
191
+
192
+ scan_orphan_dirs() {
193
+ # Emit ORPHAN_DIR|<path>|<size> for every scanned directory that is no longer a
194
+ # worktree: it carries no `.git` pointer file and no `git worktree list` entry names
195
+ # it. These are the residue of a `git worktree remove` that partially ran or failed.
196
+ # The record is advisory and read-only; nothing here deletes a directory.
197
+ #
198
+ # Registered paths are compared after normalize_wt_path normalization, so a
199
+ # backslash/case/trailing-slash difference between the porcelain listing and the
200
+ # filesystem scan cannot make a live worktree look orphaned. Output is LC_ALL=C
201
+ # sorted. A record whose size field is the literal `unknown` is emitted verbatim
202
+ # rather than dropped: size is best-effort and its absence is not a reason to hide a
203
+ # real orphan.
204
+ #
205
+ # Args: $1 = OPTIONAL pre-scanned records, in cleanup_wt_scan_records' output shape.
206
+ # When an argument is supplied it is used verbatim and no scan is performed, so a
207
+ # caller that already scanned (run_report_scans) does not scan a second time. The test
208
+ # is on the ARGUMENT COUNT, not on the value, so "records supplied and empty" is
209
+ # distinguishable from "no records supplied": an empty supplied value means the scan
210
+ # found nothing and this function emits nothing, while no argument at all means the
211
+ # function scans for itself. Direct callers may therefore keep calling it with no
212
+ # argument.
213
+ #
214
+ # Returns the scan's exit code on a hard scan failure (no record emitted), else 0.
215
+ local rc=0 recs wlout wlrc=0 record path has_gitfile size norm
216
+ if (($# > 0)); then
217
+ recs="$1"
218
+ else
219
+ recs=$(cleanup_wt_scan_records) || rc=$?
220
+ if ((rc != 0)); then
221
+ return "$rc"
222
+ fi
223
+ fi
224
+ if [[ -z $recs ]]; then
225
+ return 0
226
+ fi
227
+ local -A registered=()
228
+ wlout=$(parse_worktree_list) || wlrc=$?
229
+ if ((wlrc == 0)); then
230
+ while IFS= read -r record; do
231
+ [[ -z $record ]] && continue
232
+ path=${record%%|*}
233
+ norm=$(normalize_wt_path "$path")
234
+ [[ -n $norm ]] && registered[$norm]=1
235
+ done <<<"$wlout"
236
+ fi
237
+ local -a orphans=()
238
+ while IFS= read -r record; do
239
+ [[ -z $record ]] && continue
240
+ IFS='|' read -r path has_gitfile _ size <<<"$record"
241
+ [[ -z $path ]] && continue
242
+ [[ $has_gitfile == "0" ]] || continue
243
+ norm=$(normalize_wt_path "$path")
244
+ [[ -n ${registered[$norm]:-} ]] && continue
245
+ [[ -z $size ]] && size="unknown"
246
+ orphans+=("ORPHAN_DIR|$path|$size")
247
+ done <<<"$recs"
248
+ if ((${#orphans[@]} > 0)); then
249
+ printf '%s\n' "${orphans[@]}" | LC_ALL=C sort
250
+ fi
251
+ return 0
252
+ }
253
+
254
+ scan_registration_loss() {
255
+ # Emit WARN|registration-lost|<path> for every scanned directory that still carries a
256
+ # `.git` pointer file whose gitdir target no longer exists.
257
+ #
258
+ # This is the half-removed shape: the worktree still looks like a worktree from the
259
+ # outside, but the administrative `.git/worktrees/<name>` entry it points at is gone,
260
+ # so git commands run inside it fail in confusing ways. The record is advisory and
261
+ # read-only; nothing here deletes a directory or prunes a registration.
262
+ #
263
+ # It consumes the same scan output and the same root resolution as scan_orphan_dirs,
264
+ # and the mechanism that makes that true is the caller: run_report_scans performs one
265
+ # cleanup_wt_scan_records call and passes the SAME captured records to both functions,
266
+ # so the two records are always derived from one consistent view of the filesystem. A
267
+ # direct call with no argument scans for itself instead.
268
+ # Output is LC_ALL=C sorted. A record whose shape is unexpected (an empty path or a
269
+ # gitdir_target_exists field that is neither 0 nor 1, such as the NA a pointer-less
270
+ # directory carries) is skipped silently rather than reported or raised, mirroring
271
+ # check_main_freshness's never-blocking contract: this warning must never be the
272
+ # reason a report fails.
273
+ #
274
+ # Args: $1 = OPTIONAL pre-scanned records, in cleanup_wt_scan_records' output shape.
275
+ # The test is on the ARGUMENT COUNT, not on the value, so "records supplied and empty"
276
+ # is distinguishable from "no records supplied".
277
+ #
278
+ # Returns the scan's exit code on a hard scan failure (no record emitted), else 0.
279
+ local rc=0 recs record path has_gitfile target_exists
280
+ if (($# > 0)); then
281
+ recs="$1"
282
+ else
283
+ recs=$(cleanup_wt_scan_records) || rc=$?
284
+ if ((rc != 0)); then
285
+ return "$rc"
286
+ fi
287
+ fi
288
+ if [[ -z $recs ]]; then
289
+ return 0
290
+ fi
291
+ local -a lost=()
292
+ while IFS= read -r record; do
293
+ [[ -z $record ]] && continue
294
+ IFS='|' read -r path has_gitfile target_exists _ <<<"$record"
295
+ [[ -z $path ]] && continue
296
+ [[ $has_gitfile == "1" ]] || continue
297
+ [[ $target_exists == "0" ]] || continue
298
+ lost+=("WARN|registration-lost|$path")
299
+ done <<<"$recs"
300
+ if ((${#lost[@]} > 0)); then
301
+ printf '%s\n' "${lost[@]}" | LC_ALL=C sort
302
+ fi
303
+ return 0
304
+ }
305
+
306
+ run_report_scans() {
307
+ # The sole report-mode entry point for the three advisory scans. It exists so that
308
+ # exactly ONE filesystem scan — and therefore exactly one `du` pass — occurs per
309
+ # report: scan_orphan_dirs and scan_registration_loss each scan for themselves when
310
+ # called directly, so a caller that invoked both would scan the tree twice and could
311
+ # derive its two records from two different views of it.
312
+ #
313
+ # Emission order is the documented one: STALE_REF, then ORPHAN_DIR, then
314
+ # WARN|registration-lost. scan_stale_refs consumes no scan records, so it runs in both
315
+ # branches below.
316
+ #
317
+ # Guarded parent-shell capture: a non-zero cleanup_wt_scan_records exit is observed
318
+ # here, emits no scan-derived record, and is returned, mirroring the hard-failure
319
+ # contract the two record functions already have.
320
+ #
321
+ # Args: none. Returns the maximum non-zero return code observed, else 0.
322
+ local rc=0 srrc=0 recs scanrc=0 orc=0 lrc=0
323
+ recs=$(cleanup_wt_scan_records) || scanrc=$?
324
+ if ((scanrc != 0)); then
325
+ scan_stale_refs || srrc=$?
326
+ if ((srrc > scanrc)); then
327
+ return "$srrc"
328
+ fi
329
+ return "$scanrc"
330
+ fi
331
+ scan_stale_refs || srrc=$?
332
+ if ((srrc > rc)); then
333
+ rc=$srrc
334
+ fi
335
+ scan_orphan_dirs "$recs" || orc=$?
336
+ if ((orc > rc)); then
337
+ rc=$orc
338
+ fi
339
+ scan_registration_loss "$recs" || lrc=$?
340
+ if ((lrc > rc)); then
341
+ rc=$lrc
342
+ fi
343
+ return "$rc"
344
+ }
345
+
346
+ classify_all_branches() {
347
+ # Shared classification driver: classify every enumerated branch once through the
348
+ # unchanged per-branch ladder, emit exactly the BRANCH|/COMMIT| lines that ladder
349
+ # produced, add the advisory CHILD_OF| record where one applies, and return the
350
+ # maximum per-branch return code observed.
351
+ #
352
+ # NO LADDER RUNG IS EVER SKIPPED, and no branch's verdict is ever derived from another
353
+ # branch's verdict. Ancestry determines a branch's state in neither direction:
354
+ #
355
+ # - At rung 2, a branch merged into main by a merge commit is an ancestor of main
356
+ # and therefore of every branch cut from a main that already contains it, while
357
+ # resolving MERGED_CLEAN itself.
358
+ # - At rung 3, a branch whose net diff against main is empty resolves
359
+ # MERGED_CONTENT_NEUTRAL while still being an ancestor of an unmerged branch.
360
+ # - At rung 5, classify_residual_commit decides each residual commit by comparing
361
+ # the BRANCH TIP's blob against main's. Two branches in an ancestor relationship
362
+ # have different tips, so the same residual commit can resolve CONTENT_ON_MAIN for
363
+ # one and UNIQUE for the other. That rung is the last before the verdict and
364
+ # cannot be derived from any ancestor's result under any topology.
365
+ #
366
+ # There is no sound cut point, so there is no cut. CHILD_OF is consequently an
367
+ # informational record about a containment relationship, not a licence to skip work,
368
+ # and the outcome-preservation invariant holds by construction: the BRANCH| line this
369
+ # function emits is the byte-identical line classify_branch produced.
370
+ #
371
+ # Two-phase contract:
372
+ #
373
+ # 1. Classification. Every enumerated branch is classified by one classify_branch
374
+ # call. Its captured output is recorded verbatim and its state is read from the
375
+ # first BRANCH| line's third field. rc rises to the maximum return observed.
376
+ #
377
+ # 2. Pairwise ancestry, restricted to the NOT_MERGED set. For every ordered pair
378
+ # (X, Y) with X != Y where BOTH X and Y recorded exactly NOT_MERGED, probe
379
+ # `git merge-base --is-ancestor <X> <Y>` with the exit code captured via
380
+ # `|| mrc=$?`. Exit 0 means X is an ancestor of Y and appends CHILD_OF|X|Y to X's
381
+ # recorded lines, for the LC_ALL=C-first such Y so the record is deterministic
382
+ # when X has several NOT_MERGED ancestor-targets. Exit 1 means "not an ancestor".
383
+ # Any exit above 1 is a hard git failure: it emits no CHILD_OF record for that
384
+ # pair, leaves X's already-correct BRANCH| line exactly as classify_branch
385
+ # produced it — overwriting it would itself break the invariant above — and
386
+ # raises rc to at least 2 so the failure surfaces in the exit status instead of
387
+ # degrading silently to "not an ancestor". A hard failure of the ladder's OWN
388
+ # rung-2 probe is a separate, unaffected case that classify_branch still maps to
389
+ # BRANCH|<name>|ANCESTRY_ERROR.
390
+ #
391
+ # Restricting the probe to the NOT_MERGED set bounds its cost at k*(k-1) probes for k
392
+ # such branches rather than n*(n-1) for n branches. The base branch `main` never
393
+ # enters the probe set: compute_protected protects it by name (CLEANUP_WT_BASE_BRANCH)
394
+ # in every checkout topology, so classify_branch resolves it PROTECTED_CURRENT at rung 1.
395
+ #
396
+ # Output order is enumerate_branches' original LC_ALL=C order, regardless of the
397
+ # order in which the phases above resolved each branch, so the report is
398
+ # deterministic. An enumerate_branches hard failure aborts before any line is emitted
399
+ # and returns git's exit code.
400
+ local rc=0 crc ebout ebrc=0 cbout
401
+ ebout=$(enumerate_branches) || ebrc=$?
402
+ if ((ebrc != 0)); then
403
+ return "$ebrc"
404
+ fi
405
+ local -a order=()
406
+ local name
407
+ while read -r name _; do
408
+ [[ -z $name ]] && continue
409
+ order+=("$name")
410
+ done <<<"$ebout"
411
+ if ((${#order[@]} == 0)); then
412
+ return 0
413
+ fi
414
+ local -A branch_state=() branch_out=()
415
+ local x y mrc
416
+ # Phase 1: the unchanged full ladder, once for every branch. No rung is skipped and
417
+ # no verdict is substituted, so each recorded line is the ladder's own.
418
+ for x in "${order[@]}"; do
419
+ crc=0
420
+ cbout=$(classify_branch "$x") || crc=$?
421
+ branch_out[$x]=$cbout
422
+ branch_state[$x]=$(printf '%s\n' "$cbout" | awk -F'|' '/^BRANCH\|/{print $3; exit}')
423
+ if ((crc > rc)); then
424
+ rc=$crc
425
+ fi
426
+ done
427
+ # Phase 2: pairwise ancestry, over the NOT_MERGED set only. A branch that resolved
428
+ # anything else is neither a subject nor a target, so `main`, PROTECTED_CURRENT by the
429
+ # unconditional base-branch protection in compute_protected, is excluded by its verdict.
430
+ local -a not_merged=()
431
+ for x in "${order[@]}"; do
432
+ if [[ ${branch_state[$x]:-} == "NOT_MERGED" ]]; then
433
+ not_merged+=("$x")
434
+ fi
435
+ done
436
+ if ((${#not_merged[@]} > 1)); then
437
+ local sorted hit
438
+ local -a probe=()
439
+ # LC_ALL=C order makes the recorded ancestor-target deterministic when a branch
440
+ # has more than one NOT_MERGED ancestor-target.
441
+ sorted=$(printf '%s\n' "${not_merged[@]}" | LC_ALL=C sort)
442
+ while IFS= read -r name; do
443
+ if [[ -n $name ]]; then
444
+ probe+=("$name")
445
+ fi
446
+ done <<<"$sorted"
447
+ for x in "${probe[@]}"; do
448
+ hit=""
449
+ for y in "${probe[@]}"; do
450
+ if [[ $x == "$y" ]]; then
451
+ continue
452
+ fi
453
+ mrc=0
454
+ cleanup_wt_git merge-base --is-ancestor "$x" "$y" >/dev/null 2>&1 || mrc=$?
455
+ if ((mrc == 0)); then
456
+ hit=$y
457
+ break
458
+ fi
459
+ # A hard probe failure emits no record for this pair and never rewrites
460
+ # the branch's own verdict; it surfaces in the return code instead.
461
+ if ((mrc > 1)) && ((rc < 2)); then
462
+ rc=2
463
+ fi
464
+ done
465
+ if [[ -n $hit ]]; then
466
+ branch_out[$x]="${branch_out[$x]:-}"$'\n'"CHILD_OF|$x|$hit"
467
+ fi
468
+ done
469
+ fi
470
+ for x in "${order[@]}"; do
471
+ if [[ -n ${branch_out[$x]:-} ]]; then
472
+ printf '%s\n' "${branch_out[$x]}"
473
+ fi
474
+ done
475
+ return "$rc"
476
+ }
@@ -0,0 +1,182 @@
1
+ #!/usr/bin/env bash
2
+ # cleanup_worktrees_scan_helper.sh: the bundled real implementation behind the
3
+ # cleanup-worktrees filesystem-scan seam (CLEANUP_WT_SCAN_BIN, resolved by
4
+ # cleanup_wt_scan_bin in cleanup_worktrees_report_records_lib.sh). It performs the
5
+ # read-only filesystem observations the report-record functions need and emits them as
6
+ # one pipe-delimited tuple per candidate directory. It never writes, moves, or deletes
7
+ # anything.
8
+ #
9
+ # Usage:
10
+ # cleanup_worktrees_scan_helper.sh scan-dirs <root-dir> [<root-dir> ...]
11
+ #
12
+ # For each <root-dir> that exists as a directory, and for each immediate subdirectory
13
+ # of that root, one record is written to stdout:
14
+ #
15
+ # <path>|<has_gitfile>|<gitdir_target_exists>|<size>
16
+ #
17
+ # path the subdirectory path as constructed from the given root.
18
+ # has_gitfile 1 when <path>/.git exists as a regular file (the worktree
19
+ # pointer-file shape), else 0.
20
+ # gitdir_target_exists NA when has_gitfile is 0 (there is no pointer to resolve);
21
+ # otherwise 1 when the `gitdir: <target>` line's target exists
22
+ # (resolved relative to <path> when the target is not absolute),
23
+ # else 0. A target is absolute when it begins with `/` or with a
24
+ # drive letter followed by `/` or `\` (for example `C:/repo`).
25
+ # size best-effort `du -sh` size of <path>, or the literal `unknown`
26
+ # when du fails or prints nothing. Size is advisory only, so a
27
+ # du failure never fails the scan.
28
+ #
29
+ # A root that does not exist is skipped silently: an absent worktree-tracking folder is
30
+ # a normal state, not an error. Records are emitted in the shell's glob order per root;
31
+ # callers that need a total order sort the combined output themselves.
32
+ #
33
+ # Pointer-filename test seam: the pointer file's name is read from
34
+ # CLEANUP_WT_SCAN_GITFILE_NAME and defaults to `.git`, which is the only name a real
35
+ # worktree ever uses. The seam exists because git refuses to index any path component
36
+ # named `.git`, so a checked-in fixture tree cannot carry a real `.git` pointer file and
37
+ # the repository's test policy prohibits creating one at test time. The bats fixtures
38
+ # under tests/fixtures/cleanup_worktrees/scan_roots/ therefore name the pointer file
39
+ # `dotgit` and set this variable, exactly as the git-binary seam CLEANUP_WT_GIT_BIN and
40
+ # the SHELL_QC_<TOOL>_BIN seams substitute a checked-in stand-in for a real dependency.
41
+ # An empty value is treated as missing and falls back to `.git`.
42
+ set -euo pipefail
43
+
44
+ scan_helper_dir_size() {
45
+ # Echo the best-effort human-readable size of a directory, or `unknown`.
46
+ #
47
+ # The du/cut pipeline is captured with `|| size=""` so a du failure under
48
+ # `set -euo pipefail` degrades to `unknown` rather than aborting the scan.
49
+ #
50
+ # Args: $1 = directory path. Always returns 0.
51
+ local dir=${1:-}
52
+ local size=""
53
+ size=$(du -sh "$dir" 2>/dev/null | cut -f1) || size=""
54
+ if [[ -z $size ]]; then
55
+ size="unknown"
56
+ fi
57
+ printf '%s\n' "$size"
58
+ }
59
+
60
+ scan_helper_gitfile_name() {
61
+ # Echo the worktree pointer file's name, honoring the CLEANUP_WT_SCAN_GITFILE_NAME
62
+ # test seam. An empty or unset value falls back to the real name `.git`.
63
+ #
64
+ # Args: none. Always returns 0 and echoes exactly one name.
65
+ local override=${CLEANUP_WT_SCAN_GITFILE_NAME:-}
66
+ if [[ -n $override ]]; then
67
+ printf '%s\n' "$override"
68
+ return 0
69
+ fi
70
+ printf '%s\n' ".git"
71
+ }
72
+
73
+ scan_helper_is_absolute_path() {
74
+ # Return 0 when <path> is absolute, else 1. Pure: no filesystem access.
75
+ #
76
+ # Absolute forms are a leading `/` (POSIX paths, MSYS `/c/...` paths, and `//server`
77
+ # UNC paths) and a drive letter followed by `/` or `\` (`C:/...`, `c:/...`,
78
+ # `C:\...`), which is the form Git for Windows writes into a worktree's `.git`
79
+ # pointer file (issue #706). A drive letter with no separator (`C:rel`) is
80
+ # drive-relative and is not absolute; an empty path is not absolute.
81
+ #
82
+ # Args: $1 = path. Returns 0 (absolute) or 1 (not absolute).
83
+ local path=${1:-}
84
+ [[ $path == /* || $path == [A-Za-z]:[/\\]* ]]
85
+ }
86
+
87
+ scan_helper_target_present() {
88
+ # Return 0 when <path> exists, else non-zero. This is the helper's single
89
+ # filesystem existence check, kept in its own function as a test seam: bats tests
90
+ # redefine this function after sourcing the helper so that a drive-letter target
91
+ # can be reported present on a runner that has no such drive (issue #706).
92
+ #
93
+ # Args: $1 = path.
94
+ [[ -e ${1:-} ]]
95
+ }
96
+
97
+ scan_helper_gitdir_target_exists() {
98
+ # Echo 1 when the worktree pointer file in <dir> names an existing gitdir target,
99
+ # else 0. A missing, unreadable, or malformed pointer file yields 0 (the pointer
100
+ # does not resolve), never a crash.
101
+ #
102
+ # Args: $1 = directory path holding the pointer file, $2 = pointer file name.
103
+ # Always returns 0.
104
+ local dir=${1:-}
105
+ local gitfile=${2:-.git}
106
+ local line="" target=""
107
+ line=$(grep -m1 '^gitdir:' "$dir/$gitfile" 2>/dev/null) || line=""
108
+ target=${line#gitdir:}
109
+ # Strip a trailing CR (a pointer file written on Windows) and leading whitespace.
110
+ target=${target%$'\r'}
111
+ target=${target#"${target%%[![:space:]]*}"}
112
+ if [[ -z $target ]]; then
113
+ printf '0\n'
114
+ return 0
115
+ fi
116
+ if ! scan_helper_is_absolute_path "$target"; then
117
+ target="$dir/$target"
118
+ fi
119
+ if scan_helper_target_present "$target"; then
120
+ printf '1\n'
121
+ else
122
+ printf '0\n'
123
+ fi
124
+ }
125
+
126
+ scan_helper_scan_dirs() {
127
+ # Emit one `<path>|<has_gitfile>|<gitdir_target_exists>|<size>` record per immediate
128
+ # subdirectory of each given root. Roots that are not directories are skipped.
129
+ #
130
+ # Args: the root directories. Always returns 0.
131
+ local root entry path has_gitfile target_exists size gitfile
132
+ gitfile=$(scan_helper_gitfile_name)
133
+ for root in "$@"; do
134
+ [[ -d $root ]] || continue
135
+ for entry in "$root"/*/; do
136
+ [[ -d $entry ]] || continue
137
+ path=${entry%/}
138
+ has_gitfile=0
139
+ target_exists="NA"
140
+ if [[ -f "$path/$gitfile" ]]; then
141
+ has_gitfile=1
142
+ target_exists=$(scan_helper_gitdir_target_exists "$path" "$gitfile")
143
+ fi
144
+ size=$(scan_helper_dir_size "$path")
145
+ printf '%s|%s|%s|%s\n' "$path" "$has_gitfile" "$target_exists" "$size"
146
+ done
147
+ done
148
+ }
149
+
150
+ scan_helper_usage() {
151
+ # Print the helper usage text.
152
+ cat <<'EOF'
153
+ Usage: cleanup_worktrees_scan_helper.sh scan-dirs <root-dir> [<root-dir> ...]
154
+
155
+ Emits one read-only record per immediate subdirectory of each root:
156
+ <path>|<has_gitfile:0|1>|<gitdir_target_exists:0|1|NA>|<size-or-unknown>
157
+ EOF
158
+ }
159
+
160
+ scan_helper_main() {
161
+ # Dispatch on the subcommand. Only `scan-dirs` is supported; anything else prints
162
+ # usage to stderr and exits 2 (usage-error parity with cleanup-worktrees.sh).
163
+ local command=${1:-}
164
+ case "$command" in
165
+ scan-dirs)
166
+ shift
167
+ scan_helper_scan_dirs "$@"
168
+ ;;
169
+ *)
170
+ scan_helper_usage >&2
171
+ return 2
172
+ ;;
173
+ esac
174
+ return 0
175
+ }
176
+
177
+ # Guard so the file can be sourced without executing the scan.
178
+ if [[ ${BASH_SOURCE[0]} == "${0}" ]]; then
179
+ rc=0
180
+ scan_helper_main "$@" || rc=$?
181
+ exit "$rc"
182
+ fi
@@ -106,7 +106,7 @@ function of the DAG.
106
106
  `epic-orchestrator` drives a final PR merging `epic/<epic-slug>-integration` into `main`,
107
107
  delegating PR authoring to `Agent(pr-author)` and refreshing context via
108
108
  `mcp__drm-copilot__collect_pr_context`. `epic-orchestrator` runs the same S9 CI-green
109
- procedure (`scripts/orchestration/Invoke-CiGateParser.ps1`) directly against this PR, records
109
+ procedure (`pwsh -NoProfile -File .claude/lib/ci-gate/Invoke-CiGateParser.ps1`) directly against this PR, records
110
110
  the result under the epic checkpoint's `epic_merge_pr` object, then executes
111
111
  `gh pr merge --merge` once green, gated by `enforce-epic-merge-gate.ps1`.
112
112