@danmoisan/drm-copilot-mcp 1.1.12 → 1.1.15

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 (38) hide show
  1. package/out/mcp-server.js +30 -78
  2. package/package.json +2 -2
  3. package/resources/claude-customizations/.claude/agents/parallel-planner.md +18 -1
  4. package/resources/claude-customizations/.claude/hooks/enforce-completion-consistency.ps1 +16 -8
  5. package/resources/claude-customizations/.claude/hooks/enforce-orchestration-preimplementation-gate-helpers.ps1 +27 -27
  6. package/resources/claude-customizations/.claude/lib/blast-radius/BlastRadius.psm1 +43 -6
  7. package/resources/claude-customizations/.claude/lib/blast-radius/BlastRadiusScheduling.psm1 +486 -0
  8. package/resources/claude-customizations/.claude/lib/blast-radius/BlastRadiusValidation.psm1 +5 -2
  9. package/resources/claude-customizations/.claude/lib/blast-radius/BlastRadiusWriteIntent.psm1 +446 -0
  10. package/resources/claude-customizations/.claude/lib/ci-gate/Invoke-CiGateParser.ps1 +330 -0
  11. package/resources/claude-customizations/.claude/rules/parallel-orchestration.md +150 -2
  12. package/resources/claude-customizations/.claude/rules/plan-acceptance-gates.md +5 -5
  13. package/resources/claude-customizations/.claude/rules/shell.md +8 -7
  14. package/resources/claude-customizations/.claude/skills/cleanup-merged-worktrees/SKILL.md +8 -8
  15. package/resources/claude-customizations/.claude/skills/cleanup-merged-worktrees/scripts/cleanup-worktrees.sh +234 -0
  16. package/resources/claude-customizations/.claude/skills/cleanup-merged-worktrees/scripts/cleanup_worktrees_actions_lib.sh +451 -0
  17. package/resources/claude-customizations/.claude/skills/cleanup-merged-worktrees/scripts/cleanup_worktrees_detached_lib.sh +301 -0
  18. package/resources/claude-customizations/.claude/skills/cleanup-merged-worktrees/scripts/cleanup_worktrees_dirt_lib.sh +495 -0
  19. package/resources/claude-customizations/.claude/skills/cleanup-merged-worktrees/scripts/cleanup_worktrees_enumerate_lib.sh +252 -0
  20. package/resources/claude-customizations/.claude/skills/cleanup-merged-worktrees/scripts/cleanup_worktrees_lib.sh +496 -0
  21. package/resources/claude-customizations/.claude/skills/cleanup-merged-worktrees/scripts/cleanup_worktrees_preserve_eol_lib.sh +212 -0
  22. package/resources/claude-customizations/.claude/skills/cleanup-merged-worktrees/scripts/cleanup_worktrees_preserve_lib.sh +492 -0
  23. package/resources/claude-customizations/.claude/skills/cleanup-merged-worktrees/scripts/cleanup_worktrees_report_records_lib.sh +476 -0
  24. package/resources/claude-customizations/.claude/skills/cleanup-merged-worktrees/scripts/cleanup_worktrees_scan_helper.sh +182 -0
  25. package/resources/claude-customizations/.claude/skills/epic-orchestrate/SKILL.md +1 -1
  26. package/resources/claude-customizations/.claude/skills/epic-plan/SKILL.md +11 -0
  27. package/resources/claude-customizations/.claude/skills/orchestrate/SKILL.md +1 -1
  28. package/resources/claude-customizations/.claude/skills/parallel-add/SKILL.md +32 -17
  29. package/resources/claude-customizations/.claude/skills/parallel-plan/SKILL.md +39 -10
  30. package/resources/claude-customizations/config/blast-radius.json +14 -1
  31. package/resources/claude-customizations/pack-manifests/core.json +13 -0
  32. package/resources/codex-and-agents-customizations/.codex/config.toml +1 -1
  33. package/resources/codex-and-agents-customizations/.codex/hooks/enforce-orchestration-preimplementation-gate-epic-resolution.ps1 +493 -0
  34. package/resources/codex-and-agents-customizations/.codex/hooks/enforce-orchestration-preimplementation-gate-epic-scope.ps1 +189 -0
  35. package/resources/codex-and-agents-customizations/.codex/hooks/enforce-orchestration-preimplementation-gate-helpers.ps1 +27 -27
  36. package/resources/codex-and-agents-customizations/.codex/hooks/enforce-orchestration-preimplementation-gate.ps1 +14 -27
  37. package/resources/codex-and-agents-customizations/pack-manifests/core.json +2 -0
  38. package/resources/powershell/PoshQC/settings/pester.runsettings.psd1 +16 -0
@@ -0,0 +1,495 @@
1
+ #!/usr/bin/env bash
2
+ # cleanup_worktrees_dirt_lib.sh: sourceable dirt-classification function group for the
3
+ # cleanup-worktrees tool. Provides the bounded staged-tree probe
4
+ # (dirt_staged_tree_commit), the per-entry precedence ladder (classify_dirt_entry), the
5
+ # per-worktree driver that emits the DIRTFILE|/DIRTSUM| records
6
+ # (classify_worktree_dirt), and the opt-in clearing sequence (clear_disposable_dirt).
7
+ #
8
+ # Sourcing contract: this library defines functions and constants only; it never runs
9
+ # work at source time, so the wrapper and the bats suites can source it without side
10
+ # effects. It depends on cleanup_wt_git from cleanup_worktrees_enumerate_lib.sh, which
11
+ # MUST be sourced before this file. All git commands go through cleanup_wt_git so tests
12
+ # can stub the git binary via CLEANUP_WT_GIT_BIN.
13
+ #
14
+ # READ-ONLY GUARANTEE. Classification performs no index write and no object-database
15
+ # write. It reaches its staged-tree answer by comparing the existing index against
16
+ # candidate commits with `diff-index --cached --quiet`, never by materialising a tree,
17
+ # and it never redirects the index to an alternate file through the environment. Report
18
+ # mode is therefore non-mutating, which is the property
19
+ # tests/shell/test_cleanup_worktrees_dirt_clear.bats asserts by searching the stub's
20
+ # argv and environment log. Do not add a probe that writes.
21
+ #
22
+ # ONE-SIDED BOUND ASYMMETRY. Every bound and every match in this file fails in the safe
23
+ # direction. A missed match degrades an entry to UNIQUE, which makes the worktree
24
+ # HAS_UNIQUE and REFUSES the clear; the worktree is then left in place for manual
25
+ # triage, which is the pre-existing behavior. A spurious match would do the opposite and
26
+ # clear content that exists nowhere else. Every non-zero exit that carries no defined
27
+ # negative answer is therefore mapped to UNIQUE rather than advanced past.
28
+ #
29
+ # NO --ignored ON THE STATUS READ. The status read must not be given --ignored. Ignored
30
+ # files are never classified, so they are never cleared, and `clean -fd` without -x
31
+ # leaves them in place. Adding --ignored would bring build output and local tooling
32
+ # state into the classified set and, for any entry that then matched a disposable rung,
33
+ # into the cleared set. Do not add it.
34
+ #
35
+ # Git exit-code capture rule, following the sibling libraries: every git-backed read
36
+ # whose output or exit code is authoritative is captured in the PARENT shell
37
+ # (`out=$(cmd) || rc=$?`), never inside a pipeline or a process substitution, so a
38
+ # non-zero exit is observed rather than lost.
39
+ #
40
+ # TWO-WAY CLASSIFICATION OF NON-ZERO EXITS. A probe whose non-zero exit is its DEFINED
41
+ # NEGATIVE ANSWER advances the ladder: `rev-parse main:<path>` exiting non-zero means
42
+ # the path is absent from main, and `diff --quiet main -- <path>` exiting 1 means the
43
+ # contents differ. Both are rung-4 misses. A probe whose non-zero exit carries NO
44
+ # verdict is a hard read failure and maps the entry to UNIQUE: `status --porcelain`,
45
+ # `hash-object`, `log --find-object`, and `diff-index --cached --quiet` exiting above 1.
46
+ # Collapsing the two into one blanket rule would make CONTENT_IN_HISTORY unreachable.
47
+ #
48
+ # EMPTY PATHSPEC IS NOT A MATCH. `diff --quiet main -- <path>` exits 0 for two distinct
49
+ # reasons: the compared content is identical, and the pathspec selected no paths at all.
50
+ # Only the first supports a disposable verdict, so rung 4's tracked positive answer is
51
+ # conditional. The reachable case is an AD entry, added to the index and then removed
52
+ # from the working tree, whose content exists only as a staged blob; reading its exit 0
53
+ # as a match would clear content held nowhere else. The answer is therefore gated on
54
+ # `rev-parse --verify --quiet main:<path>`, which asks whether main has content at that
55
+ # path to be equal to. A non-zero answer emits nothing and advances the ladder to the
56
+ # hash-object guard. This is the vacuous confinement rung 3 already guards against.
57
+ #
58
+ # INDEX AND WORKING TREE ARE TWO LOCATIONS. M, A, R, C, T, U are content-bearing: each
59
+ # says that side holds a blob (T is a typechange, U holds content at index stages 2 and
60
+ # 3); a space, ?, ! or D does not. Two content-bearing columns mean TWO distinct blobs,
61
+ # and rung 4's tracked half and rung 5 read only the working-tree one, so both fail
62
+ # closed there. Rung 3 is not gated: dirt_is_build_artifact reads the cached diff too.
63
+ #
64
+ # Record contract (pipe-delimited, one record per line; the file path is LAST so a path
65
+ # containing the delimiter is still recoverable):
66
+ # DIRTFILE|<worktree-path>|<verdict>|<detail>|<status-code>|<file-path>
67
+ # DIRTSUM|<worktree-path>|ALL_DISPOSABLE|HAS_UNIQUE|<detail>
68
+ # <verdict> is one of DISPOSABLE_BUILD_ARTIFACT, DISPOSABLE_SESSION_ARTIFACT,
69
+ # CONTENT_ON_MAIN, CONTENT_IN_HISTORY, STAGED_TREE_IS_COMMIT, UNIQUE. <detail> carries a
70
+ # commit sha for STAGED_TREE_IS_COMMIT and CONTENT_IN_HISTORY and is empty otherwise.
71
+ # ACTION|dirt-clear|<worktree-path>|OK|REFUSED-UNIQUE|FAILED
72
+
73
+ # Opt-in gate for the destructive clearing path. Defaulted through parameter expansion
74
+ # so the bats suites can drive these functions without the CLI wrapper.
75
+ CLEANUP_WT_CLEAR_DISPOSABLE=${CLEANUP_WT_CLEAR_DISPOSABLE:-0}
76
+
77
+ # Upper bound on how far back the staged-tree probe walks from HEAD. One worktree in the
78
+ # 2026-09-06 run carried 86 staged changes that were exactly an earlier commit.
79
+ CLEANUP_WT_STAGED_TREE_DEPTH=200
80
+
81
+ # Upper bound on the history window scanned for a blob. Exceeding the available history
82
+ # is not an error: the probe falls back to the full `main` ref.
83
+ CLEANUP_WT_HISTORY_SCAN_DEPTH=1000
84
+
85
+ # The authorization list for an irreversible action, hard-coded with no environment
86
+ # override, following the CLEANUP_WT_CONSOLIDATION_BRANCH precedent in
87
+ # cleanup_worktrees_actions_lib.sh. A runtime override would let any path be declared
88
+ # disposable and would defeat the UNIQUE gate this feature's safety rests on. Matching
89
+ # is EXACT and never by prefix: artifacts/** at large must not be auto-cleared.
90
+ CLEANUP_WT_SESSION_ARTIFACT_PATHS=(
91
+ "artifacts/pr_context.summary.txt"
92
+ "artifacts/pr_context.appendix.txt"
93
+ "artifacts/orchestration/orchestrator-state.json"
94
+ )
95
+
96
+ dirt_staged_tree_commit() {
97
+ # Bounded staged-tree probe: is the current index exactly the tree of some recent
98
+ # commit on this worktree's history?
99
+ #
100
+ # Walks CLEANUP_WT_STAGED_TREE_DEPTH commits back from HEAD and compares the index
101
+ # against each with `diff-index --cached --quiet`. The bound is passed as depth plus
102
+ # one because the FIRST line rev-list returns is HEAD itself, which is dropped: HEAD's
103
+ # tree is what an unmodified index already equals, so probing it would match for every
104
+ # worktree and label every staged entry disposable.
105
+ #
106
+ # Args: $1 = worktree path.
107
+ # Echoes the matching commit sha and returns 0 on a match; returns 1 with no output
108
+ # when no candidate matched; returns 2 on a hard read failure (a rev-list failure, or
109
+ # a diff-index exit above 1, neither of which carries a verdict).
110
+ local wt="$1" out rc=0 first=1 sha drc
111
+ out=$(cleanup_wt_git --no-optional-locks -C "$wt" rev-list \
112
+ --max-count=$((CLEANUP_WT_STAGED_TREE_DEPTH + 1)) HEAD) || rc=$?
113
+ if ((rc != 0)); then # guard:staged-probe-revlist-hard-fail
114
+ return 2
115
+ fi
116
+ while IFS= read -r sha || [[ -n $sha ]]; do
117
+ [[ -z $sha ]] && continue
118
+ if ((first == 1)); then # guard:staged-probe-skip-head
119
+ first=0
120
+ continue
121
+ fi
122
+ drc=0
123
+ cleanup_wt_git --no-optional-locks -C "$wt" diff-index --cached --quiet \
124
+ "$sha" -- >/dev/null || drc=$?
125
+ if ((drc == 0)); then # guard:staged-probe-tree-match
126
+ printf '%s\n' "$sha"
127
+ return 0
128
+ fi
129
+ if ((drc > 1)); then # guard:staged-probe-diffindex-hard-fail
130
+ return 2
131
+ fi
132
+ done <<<"$out"
133
+ return 1
134
+ }
135
+
136
+ dirt_is_session_artifact() {
137
+ # Rung 2. Pure string comparison against the hard-coded array; no git call, so no
138
+ # invocation ever names a session-artifact path.
139
+ #
140
+ # Args: $1 = repo-relative path. Returns 0 on an exact match, 1 otherwise.
141
+ local rel="$1" p
142
+ for p in "${CLEANUP_WT_SESSION_ARTIFACT_PATHS[@]}"; do
143
+ [[ $rel == "$p" ]] && return 0
144
+ done
145
+ return 1
146
+ }
147
+
148
+ dirt_diff_is_hintpath_confined() {
149
+ # Read one diff and decide whether every changed content line in it is an analyzer
150
+ # HintPath rewrite.
151
+ #
152
+ # The `---` and `+++` file headers are excluded before the test: both begin with a
153
+ # changed-line marker and neither is content, so counting them would make every diff
154
+ # unconfined. Hunk headers and the `diff --git`/`index` lines are not changed lines
155
+ # and are ignored.
156
+ #
157
+ # The header skip is ANCHORED to the four forms the diff emits, not written as a
158
+ # bare `+++ `/`--- ` prefix test. A space does not end a diff line's content, so an
159
+ # ADDED line whose own text begins `+++ ` matches a bare prefix test and is dropped
160
+ # before it is counted and before it is tested for HintPath. A project file carrying
161
+ # one such hand edit then reads as confined and resolves DISPOSABLE_BUILD_ARTIFACT,
162
+ # which is the fail-open direction and makes the entry clearable.
163
+ #
164
+ # The `a/` and `b/` prefixes are assumed because this library issues
165
+ # `diff --no-color -U0` without `--no-prefix`, so the default prefixes are always
166
+ # present; the two `/dev/null` forms cover an added or a deleted file. A header form
167
+ # the pattern does not match is counted as a changed content line, which fails the
168
+ # HintPath test and resolves the entry UNIQUE — the safe direction.
169
+ #
170
+ # Args: $1 = worktree path, $2 = repo-relative path, $3 = "cached" for the staged
171
+ # diff or the empty string for the worktree diff.
172
+ # Echoes the number of changed content lines seen and returns 0 when every one of
173
+ # them is a HintPath line; returns 1 when any is not; returns 2 on a hard read
174
+ # failure.
175
+ local wt="$1" rel="$2" mode="${3:-}" out rc=0 line changed=0
176
+ if [[ $mode == "cached" ]]; then
177
+ out=$(cleanup_wt_git --no-optional-locks -C "$wt" diff --no-color -U0 \
178
+ --cached -- "$rel") || rc=$?
179
+ else
180
+ out=$(cleanup_wt_git --no-optional-locks -C "$wt" diff --no-color -U0 \
181
+ -- "$rel") || rc=$?
182
+ fi
183
+ if ((rc != 0)); then # guard:hintpath-diff-read-hard-fail
184
+ return 2
185
+ fi
186
+ while IFS= read -r line || [[ -n $line ]]; do
187
+ case "$line" in
188
+ "--- a/"* | "+++ b/"* | "--- /dev/null" | "+++ /dev/null") continue ;; # guard:diff-header-skip
189
+ "+"* | "-"*) ;;
190
+ *) continue ;;
191
+ esac
192
+ changed=$((changed + 1))
193
+ if [[ $line != *"HintPath"* ]]; then
194
+ printf '%s\n' "$changed"
195
+ return 1
196
+ fi
197
+ done <<<"$out"
198
+ printf '%s\n' "$changed"
199
+ return 0
200
+ }
201
+
202
+ dirt_is_build_artifact() {
203
+ # Rung 3. A tracked, modified project file whose every changed content line, in BOTH
204
+ # the worktree diff and the cached diff, is an analyzer HintPath rewrite.
205
+ #
206
+ # The rule is deliberately NOT path-pattern-only. In a legacy non-SDK C# project every
207
+ # added source file is an explicit <Compile Include=...> edit to the project file, and
208
+ # a path-only rule would label that hand edit disposable and destroy it. The content
209
+ # confinement is the definition of the class, not extra scope.
210
+ #
211
+ # A diff pair with zero changed content lines is NOT a build artifact. Vacuous
212
+ # confinement would let a read that returned nothing resolve to a disposable verdict,
213
+ # which is the fail-open direction.
214
+ #
215
+ # Args: $1 = worktree path, $2 = repo-relative path.
216
+ # Returns 0 for a build artifact, 1 for not one, 2 on a hard read failure.
217
+ local wt="$1" rel="$2" wout crc=0 wrc=0 cout total=0
218
+ case "$rel" in
219
+ *.csproj | packages.config | */packages.config | app.config | */app.config) ;;
220
+ *) return 1 ;;
221
+ esac
222
+ wout=$(dirt_diff_is_hintpath_confined "$wt" "$rel" "") || wrc=$?
223
+ ((wrc == 2)) && return 2 # guard:build-artifact-worktree-diff-hard-fail
224
+ ((wrc == 1)) && return 1 # guard:build-artifact-worktree-diff-unconfined
225
+ cout=$(dirt_diff_is_hintpath_confined "$wt" "$rel" "cached") || crc=$?
226
+ ((crc == 2)) && return 2 # guard:build-artifact-cached-diff-hard-fail
227
+ ((crc == 1)) && return 1 # guard:build-artifact-cached-diff-unconfined
228
+ total=$((wout + cout))
229
+ ((total == 0)) && return 1 # guard:build-artifact-vacuous-confinement
230
+ return 0
231
+ }
232
+
233
+ classify_dirt_entry() {
234
+ # The six-rung, first-match-wins precedence ladder for one status entry.
235
+ #
236
+ # Rung order: staged-tree, session artifact, build artifact, content-on-main,
237
+ # content-in-history, unique. Rung 1 is answered from the once-per-worktree probe
238
+ # result passed in by the caller rather than re-probed here, so the probe is issued at
239
+ # most once per worktree however many entries it has.
240
+ #
241
+ # Args: $1 = worktree path, $2 = two-character status code, $3 = repo-relative path,
242
+ # $4 = the staged-tree probe result: a commit sha on a match, the empty string on no
243
+ # match, or the literal ERROR on a hard probe failure.
244
+ # Echoes exactly one `<verdict>|<detail>` line and always returns 0.
245
+ local wt="$1" xy="$2" rel="$3" staged="$4"
246
+ local x="${xy:0:1}" y="${xy:1:1}" untracked=0 blob="" hrc=0 mrc=0 mainblob="" brc=0
247
+ local drc=0 erc=0 vrc=0 lrc=0 bothloc=0 range found
248
+
249
+ # A C-quoted payload is returned UNIQUE immediately with no unquoting attempt. One
250
+ # branch, deterministic, and failing in the safe direction; attempting to unquote
251
+ # would add an escape-decoding surface whose only use is to widen what can be
252
+ # cleared.
253
+ if [[ $rel == \"* ]]; then
254
+ printf 'UNIQUE|\n'
255
+ return 0
256
+ fi
257
+
258
+ # Rung 1. Staged entries only: the X column is the index status, and a space,
259
+ # question mark, or exclamation mark there means the entry is not staged.
260
+ #
261
+ # THE Y COLUMN GATE IS LOAD-BEARING. The X column answers a question about the
262
+ # INDEX, and the probe that answers rung 1 is `diff-index --cached --quiet`, which
263
+ # compares the index against a candidate commit and says nothing whatever about a
264
+ # non-space Y column. An MM entry has a staged change that matches an ancestor tree
265
+ # AND an unstaged working-tree modification on top of it; that unstaged delta exists
266
+ # in no commit and not in the index, so nothing recovers it once
267
+ # --clear-disposable runs reset --hard. Rung 1 therefore fires only when the Y
268
+ # column is a single space.
269
+ #
270
+ # An entry with a non-space Y column falls through to the rungs that compare
271
+ # WORKING-TREE content, which is the comparison its unstaged delta actually needs.
272
+ # See INDEX AND WORKING TREE ARE TWO LOCATIONS in the header: when the Y column is
273
+ # itself content-bearing that comparison alone is not enough and rungs 4 and 5 fail
274
+ # closed. An MM entry on a HintPath-confined project file can still reach
275
+ # DISPOSABLE_BUILD_ARTIFACT, because dirt_is_build_artifact reads both the worktree
276
+ # diff and the cached diff; an MM entry on any other path reaches rung 6 and is UNIQUE.
277
+ if [[ $x != " " && $x != "?" && $x != "!" && $y == " " ]]; then # guard:rung1-y-column-gate
278
+ if [[ $staged == "ERROR" ]]; then
279
+ printf 'UNIQUE|\n'
280
+ return 0
281
+ fi
282
+ if [[ -n $staged ]]; then
283
+ printf 'STAGED_TREE_IS_COMMIT|%s\n' "$staged"
284
+ return 0
285
+ fi
286
+ fi
287
+
288
+ # Rung 2.
289
+ if dirt_is_session_artifact "$rel"; then
290
+ printf 'DISPOSABLE_SESSION_ARTIFACT|\n'
291
+ return 0
292
+ fi
293
+
294
+ [[ $xy == "??" ]] && untracked=1
295
+ # See INDEX AND WORKING TREE ARE TWO LOCATIONS in the header: two content-bearing
296
+ # columns mean the index blob and the working-tree blob hold different content.
297
+ [[ $x == [MARCTU] && $y == [MARCTU] ]] && bothloc=1 # guard:index-and-worktree-both-hold-content
298
+
299
+ # Rung 3. Tracked, modified entries only; an untracked file has no diff to confine.
300
+ if ((untracked == 0)); then # guard:rung3-tracked-only-gate
301
+ dirt_is_build_artifact "$wt" "$rel" || brc=$?
302
+ if ((brc == 0)); then # guard:rung3-build-artifact-match
303
+ printf 'DISPOSABLE_BUILD_ARTIFACT|\n'
304
+ return 0
305
+ fi
306
+ if ((brc > 1)); then # guard:rung3-build-artifact-hard-fail
307
+ printf 'UNIQUE|\n'
308
+ return 0
309
+ fi
310
+ fi
311
+
312
+ # Rung 4, tracked half. Exit 1 is this probe's defined negative answer (the contents
313
+ # differ) and advances the ladder; an exit above 1 carries no verdict.
314
+ if ((untracked == 0)); then # guard:rung4-tracked-gate
315
+ cleanup_wt_git --no-optional-locks -C "$wt" diff --quiet main -- "$rel" \
316
+ >/dev/null || drc=$?
317
+ if ((drc == 0)); then # guard:rung4-tracked-content-equal
318
+ # See EMPTY PATHSPEC IS NOT A MATCH in the header. Only stdout is
319
+ # discarded: the stub logs its argv to stderr, and redirecting that
320
+ # stream too would hide this read from the argv assertions. --quiet
321
+ # already suppresses git's own diagnostic on the negative answer.
322
+ cleanup_wt_git --no-optional-locks -C "$wt" rev-parse --verify --quiet \
323
+ "main:$rel" >/dev/null || erc=$?
324
+ if ((erc == 0)); then # guard:rung4-tracked-path-in-main
325
+ if ((bothloc == 0)); then # guard:rung4-index-blob-unaccounted
326
+ printf 'CONTENT_ON_MAIN|\n'
327
+ return 0
328
+ fi
329
+ fi
330
+ fi
331
+ if ((drc > 1)); then # guard:rung4-tracked-hard-fail
332
+ printf 'UNIQUE|\n'
333
+ return 0
334
+ fi
335
+ fi
336
+
337
+ # The working-tree blob id, needed by rung 4's untracked half and by rung 5. A
338
+ # hash-object failure carries no verdict, so it fails closed.
339
+ blob=$(cleanup_wt_git --no-optional-locks -C "$wt" hash-object -- "$rel") || hrc=$?
340
+ blob=${blob%%$'\n'*}
341
+ if ((hrc != 0)) || [[ -z $blob ]]; then # guard:hash-object-hard-fail
342
+ printf 'UNIQUE|\n'
343
+ return 0
344
+ fi
345
+
346
+ # Rung 4, untracked half. A rev-parse failure means the path is absent from main,
347
+ # which is this probe's defined negative answer, so the ladder continues.
348
+ if ((untracked == 1)); then # guard:rung4-untracked-gate
349
+ mainblob=$(cleanup_wt_git --no-optional-locks -C "$wt" rev-parse "main:$rel") || mrc=$?
350
+ mainblob=${mainblob%%$'\n'*}
351
+ if ((mrc == 0)) && [[ -n $mainblob && $mainblob == "$blob" ]]; then # guard:rung4-untracked-main-present
352
+ printf 'CONTENT_ON_MAIN|\n'
353
+ return 0
354
+ fi
355
+ fi
356
+
357
+ # Rung 5. The bounded range endpoint is probed first; a repository with less history
358
+ # than the bound cannot resolve it, and that is not an error, so the scan falls back
359
+ # to the full ref rather than reporting a read failure.
360
+ range="main"
361
+ cleanup_wt_git --no-optional-locks -C "$wt" rev-parse --verify --quiet \
362
+ "main~$CLEANUP_WT_HISTORY_SCAN_DEPTH" >/dev/null 2>&1 || vrc=$?
363
+ ((vrc == 0)) && range="main~$CLEANUP_WT_HISTORY_SCAN_DEPTH..main" # guard:history-scan-bounded-range
364
+ found=$(cleanup_wt_git --no-optional-locks -C "$wt" log "$range" \
365
+ --find-object="$blob" --format=%H --max-count=1) || lrc=$?
366
+ if ((lrc != 0)); then # guard:find-object-hard-fail
367
+ printf 'UNIQUE|\n'
368
+ return 0
369
+ fi
370
+ found=${found%%$'\n'*}
371
+ if [[ -n $found ]]; then # guard:history-hit-nonempty
372
+ if ((bothloc == 0)); then # guard:rung5-index-blob-unaccounted
373
+ printf 'CONTENT_IN_HISTORY|%s\n' "$found"
374
+ return 0
375
+ fi
376
+ fi
377
+
378
+ # Rung 6.
379
+ printf 'UNIQUE|\n'
380
+ return 0
381
+ }
382
+
383
+ classify_worktree_dirt() {
384
+ # Per-worktree driver: one guarded status read, one DIRTFILE record per entry in
385
+ # porcelain order, then exactly one DIRTSUM aggregate.
386
+ #
387
+ # A worktree with zero status entries emits NEITHER record: there is nothing to
388
+ # classify and an aggregate over an empty set would assert a property of no data.
389
+ # A hard status read emits no record at all and returns git's non-zero exit code, so
390
+ # a read failure can never be mistaken for a clean worktree.
391
+ #
392
+ # The aggregate is ALL_DISPOSABLE if and only if the entry count is at least one and
393
+ # the UNIQUE count is zero, and HAS_UNIQUE otherwise. Its detail field carries the
394
+ # first non-empty per-entry detail, which is the staged-tree commit sha when the
395
+ # worktree's dirt is a staged tree.
396
+ #
397
+ # Args: $1 = worktree path. Returns 0 normally; git's exit code on a hard status
398
+ # read failure.
399
+ local wt="$1" out srrc=0 line xy rel res verdict detail
400
+ local staged="" any_staged=0 prc=0 x
401
+ local entry_count=0 unique_count=0 agg_detail=""
402
+ out=$(cleanup_wt_git --no-optional-locks -C "$wt" status --porcelain) || srrc=$?
403
+ if ((srrc != 0)); then # guard:status-read-hard-fail
404
+ return "$srrc"
405
+ fi
406
+ [[ -z $out ]] && return 0
407
+ # The staged-tree probe is issued only when at least one entry is actually staged, so
408
+ # a worktree carrying only unstaged or untracked dirt costs no rev-list walk.
409
+ while IFS= read -r line || [[ -n $line ]]; do
410
+ [[ -z $line ]] && continue
411
+ x="${line:0:1}"
412
+ if [[ $x != " " && $x != "?" && $x != "!" ]]; then
413
+ any_staged=1
414
+ break
415
+ fi
416
+ done <<<"$out"
417
+ if ((any_staged == 1)); then # guard:staged-probe-issue-gate
418
+ staged=$(dirt_staged_tree_commit "$wt") || prc=$?
419
+ ((prc == 1)) && staged="" # guard:staged-probe-no-match
420
+ ((prc > 1)) && staged="ERROR" # guard:staged-probe-error-map
421
+ fi
422
+ while IFS= read -r line || [[ -n $line ]]; do
423
+ [[ -z $line ]] && continue
424
+ xy="${line:0:2}"
425
+ rel="${line:3}"
426
+ # A rename or copy entry's payload is `OLD -> NEW`; the destination is the path
427
+ # that exists in the working tree and is therefore the one classified. Porcelain
428
+ # status uses that payload form ONLY for the two codes R and C, and a space does
429
+ # not trigger C-quoting, so an ordinary path may contain the literal ` -> `.
430
+ # Splitting unconditionally truncated such a path, issued every probe against a
431
+ # file that does not exist, and emitted a record naming the wrong file. The X
432
+ # column is therefore the gate. The variable read here is `xy`, not the enclosing
433
+ # function's `x`: `x` holds the last value the any_staged pre-scan loop assigned,
434
+ # not this entry's X column.
435
+ if [[ ${xy:0:1} == R || ${xy:0:1} == C ]]; then # guard:rename-payload-split-gate
436
+ rel="${rel#* -> }"
437
+ fi
438
+ res=$(classify_dirt_entry "$wt" "$xy" "$rel" "$staged")
439
+ verdict="${res%%|*}"
440
+ detail="${res#*|}"
441
+ entry_count=$((entry_count + 1))
442
+ [[ $verdict == "UNIQUE" ]] && unique_count=$((unique_count + 1)) # guard:unique-verdict-tally
443
+ [[ -z $agg_detail && -n $detail ]] && agg_detail="$detail"
444
+ printf 'DIRTFILE|%s|%s|%s|%s|%s\n' "$wt" "$verdict" "$detail" "$xy" "$rel"
445
+ done <<<"$out"
446
+ ((entry_count == 0)) && return 0 # guard:aggregate-empty-entry-set
447
+ if ((unique_count == 0)); then # guard:aggregate-all-disposable-gate
448
+ printf 'DIRTSUM|%s|ALL_DISPOSABLE|%s\n' "$wt" "$agg_detail"
449
+ else
450
+ printf 'DIRTSUM|%s|HAS_UNIQUE|\n' "$wt"
451
+ fi
452
+ return 0
453
+ }
454
+
455
+ clear_disposable_dirt() {
456
+ # The opt-in clearing sequence: classify, require the aggregate to be exactly
457
+ # ALL_DISPOSABLE, reset --hard, clean -fd, then emit the result record.
458
+ #
459
+ # `git worktree remove` is never invoked from here. The caller retries the existing
460
+ # unforced removal through remove_worktree_safe, so this path introduces no new
461
+ # removal call site and is not force-removal: it clears the working tree first and
462
+ # then retries the same unforced removal.
463
+ #
464
+ # `git clean` is never given -x, -X, or -ff. Without -x the ignored files that -fd
465
+ # leaves alone stay on disk, which matches the classifier's refusal to classify them.
466
+ #
467
+ # Anything other than an exact ALL_DISPOSABLE aggregate refuses: a HAS_UNIQUE
468
+ # aggregate, a hard status read (which emits no aggregate at all), and a worktree with
469
+ # no entries all take the same fail-closed branch, because none of them establishes
470
+ # that every entry is disposable.
471
+ #
472
+ # Args: $1 = worktree path. Returns 0 on a completed clear, 1 on a refusal or a
473
+ # failure.
474
+ local wt="$1" cout crc=0 agg="" rrc=0 clrc=0
475
+ cout=$(classify_worktree_dirt "$wt") || crc=$?
476
+ if ((crc == 0)); then # guard:clear-classify-exit-gate
477
+ agg=$(printf '%s\n' "$cout" | awk -F'|' '/^DIRTSUM\|/{print $3; exit}')
478
+ fi
479
+ if [[ $agg != "ALL_DISPOSABLE" ]]; then # guard:clear-requires-all-disposable
480
+ printf 'ACTION|dirt-clear|%s|REFUSED-UNIQUE\n' "$wt"
481
+ return 1
482
+ fi
483
+ cleanup_wt_git -C "$wt" reset --hard >/dev/null || rrc=$?
484
+ if ((rrc != 0)); then # guard:clear-reset-hard-fail
485
+ printf 'ACTION|dirt-clear|%s|FAILED\n' "$wt"
486
+ return 1
487
+ fi
488
+ cleanup_wt_git -C "$wt" clean -fd >/dev/null || clrc=$?
489
+ if ((clrc != 0)); then # guard:clear-clean-hard-fail
490
+ printf 'ACTION|dirt-clear|%s|FAILED\n' "$wt"
491
+ return 1
492
+ fi
493
+ printf 'ACTION|dirt-clear|%s|OK\n' "$wt"
494
+ return 0
495
+ }