@danmoisan/drm-copilot-mcp 1.1.24 → 1.1.26

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 (36) hide show
  1. package/package.json +1 -1
  2. package/resources/claude-customizations/.claude/agents/parallel-orchestrator.md +7 -3
  3. package/resources/claude-customizations/.claude/agents/parallel-planner.md +6 -2
  4. package/resources/claude-customizations/.claude/hooks/enforce-model-routing-receipt.ps1 +41 -0
  5. package/resources/claude-customizations/.claude/hooks/enforce-orchestration-preimplementation-gate-cleanup.ps1 +481 -0
  6. package/resources/claude-customizations/.claude/hooks/enforce-orchestration-preimplementation-gate.ps1 +3 -1
  7. package/resources/claude-customizations/.claude/hooks/enforce-parallel-cohort-barrier-helpers.ps1 +69 -3
  8. package/resources/claude-customizations/.claude/hooks/enforce-pr-author-skill-helpers.ps1 +2 -2
  9. package/resources/claude-customizations/.claude/hooks/enforce-pr-author-skill.cleanup.ps1 +246 -0
  10. package/resources/claude-customizations/.claude/hooks/enforce-pr-author-skill.docs-only.ps1 +1 -0
  11. package/resources/claude-customizations/.claude/hooks/hook-cleanup-context.ps1 +343 -0
  12. package/resources/claude-customizations/.claude/lib/parallel-admission/Invoke-ParallelAdmission.ps1 +626 -0
  13. package/resources/claude-customizations/.claude/lib/parallel-admission/ParallelAdmission.psm1 +508 -0
  14. package/resources/claude-customizations/.claude/lib/parallel-admission/ParallelAdmissionJson.psm1 +141 -0
  15. package/resources/claude-customizations/.claude/lib/parallel-admission/ParallelAdmissionPrecedence.psm1 +508 -0
  16. package/resources/claude-customizations/.claude/rules/parallel-orchestration.md +17 -1
  17. package/resources/claude-customizations/.claude/settings.json +1 -0
  18. package/resources/claude-customizations/.claude/skills/atomic-plan-contract/SKILL.md +1 -1
  19. package/resources/claude-customizations/.claude/skills/cleanup-merged-worktrees/SKILL.md +93 -14
  20. package/resources/claude-customizations/.claude/skills/cleanup-merged-worktrees/scripts/cleanup-worktrees.sh +23 -10
  21. package/resources/claude-customizations/.claude/skills/cleanup-merged-worktrees/scripts/cleanup_worktrees_preserve_lib.sh +54 -46
  22. package/resources/claude-customizations/.claude/skills/cleanup-merged-worktrees/scripts/cleanup_worktrees_preserve_merge_lib.sh +234 -0
  23. package/resources/claude-customizations/.claude/skills/execution-environment/SKILL.md +15 -5
  24. package/resources/claude-customizations/.claude/skills/parallel-add/SKILL.md +71 -7
  25. package/resources/claude-customizations/.claude/skills/parallel-orchestrate/SKILL.md +26 -2
  26. package/resources/claude-customizations/.claude/skills/parallel-plan/SKILL.md +82 -12
  27. package/resources/claude-customizations/pack-manifests/core.json +8 -0
  28. package/resources/codex-and-agents-customizations/.agents/skills/execution-environment/SKILL.md +15 -5
  29. package/resources/codex-and-agents-customizations/.codex/config.toml +1 -1
  30. package/resources/codex-and-agents-customizations/.codex/hooks/enforce-orchestration-preimplementation-gate-cleanup.ps1 +481 -0
  31. package/resources/codex-and-agents-customizations/.codex/hooks/enforce-orchestration-preimplementation-gate.ps1 +4 -2
  32. package/resources/codex-and-agents-customizations/.codex/hooks/hook-cleanup-context.ps1 +343 -0
  33. package/resources/codex-and-agents-customizations/pack-manifests/core.json +2 -0
  34. package/resources/customizations/.github/skills/execution-environment/SKILL.md +15 -5
  35. package/resources/powershell/PoshQC/PoshQC.ScanConfig.psm1 +3 -1
  36. package/resources/powershell/PoshQC/PoshQC.Testing.psm1 +12 -10
@@ -162,7 +162,9 @@ The script emits pipe-delimited, `LC_ALL=C`-ordered records, one per line:
162
162
  2. **Editorial triage of the cherry-pick candidates.** Review each
163
163
  `COMMIT|...|UNIQUE|...` record — this is the LLM-judgment boundary. Confirm
164
164
  editorially that the unique commits are genuinely documentation/agent-memory
165
- content (for example paths under `docs/**`, `.claude/agent-memory/**`, or `**/*.md`).
165
+ content: paths under the repository-relative prefixes `docs/` and `.claude/agent-memory/`,
166
+ compared case-sensitively. A Markdown file outside those two prefixes (for example
167
+ `CLAUDE.md` or `.claude/rules/*.md`) is not consolidation content.
166
168
  The script only reports the deterministic facts (SHA, paths, author, date); deciding
167
169
  what counts as documentation is this skill's responsibility.
168
170
 
@@ -174,21 +176,42 @@ The script emits pipe-delimited, `LC_ALL=C`-ordered records, one per line:
174
176
  reuse it silently. Conflicts are aborted and surfaced as `CONFLICT` for editorial
175
177
  resolution, never auto-resolved.
176
178
 
179
+ **Cleanup context.** A cleanup run writes no orchestration checkpoint, so the enforcement
180
+ hooks recognize its operations by the cleanup context instead: the sanctioned-removal
181
+ manifest at the session root (`artifacts/orchestration/cleanup-worktrees-manifest.json` in
182
+ the main checkout where this skill runs, written whole with Write; see Sanctioned Removal
183
+ Manifest) and the consolidation worktree, identified by its branch
184
+ `documentationandmemories`. Any staging in that worktree beyond what the script performs
185
+ uses `git -C <consolidation root> add -- <path>` with paths under `docs/` or
186
+ `.claude/agent-memory/` only, and the commit uses
187
+ `git -C <consolidation root> commit -F <message file>` with no pathspec. The preimplementation
188
+ gate admits that commit only when every staged path is under those two prefixes.
189
+
177
190
  4. **Push and hand off PR creation to `Agent(pr-author)`.** Push the consolidation
178
- branch (`git push`). Refresh the PR-context bundle with
179
- `mcp__drm-copilot__collect_pr_context` using base branch `main` (producing
180
- `artifacts/pr_context.summary.txt` and `artifacts/pr_context.appendix.txt`).
181
- Validate the orchestrator-state checkpoint
182
- (`artifacts/orchestration/orchestrator-state.json`) with `--require-pr-creation-ready`
183
- and record the `pr_author_preflight` result; delegation is prohibited when that
184
- validation fails. Then delegate PR creation to `Agent(pr-author)` per
185
- `.claude/skills/pr-author/SKILL.md`, which owns the body-file and receipt contract;
186
- defer to that skill for it rather than restating any value from it here. For `<N>`,
191
+ branch (`git -C <consolidation root> push -u origin documentationandmemories`).
192
+ The push precedes `gh pr create`: the PR-author gate requires
193
+ `refs/remotes/origin/documentationandmemories` to resolve to the same commit as the
194
+ consolidation worktree `HEAD`, so push again after any later commit in that worktree. Refresh the
195
+ PR-context bundle with `mcp__drm-copilot__collect_pr_context` using base branch `main` and
196
+ `workspace_root` set to the consolidation worktree, so that
197
+ `artifacts/pr_context.summary.txt` and `artifacts/pr_context.appendix.txt` exist beneath the
198
+ consolidation worktree. A consolidation pull request has no orchestrator-state checkpoint and
199
+ is not validated against one; the hooks perform the cleanup readiness check instead.
200
+ `enforce-model-routing-receipt.ps1` admits the `Agent(pr-author)` delegation, and
201
+ `enforce-pr-author-skill.ps1` admits `gh pr create`, only while a fresh, valid manifest exists
202
+ at the session root; the PR-author gate also requires an explicit
203
+ `--head documentationandmemories --base main` and a branch diff limited to `docs/` and
204
+ `.claude/agent-memory/`, and its receipt checks still apply. Then delegate PR creation to
205
+ `Agent(pr-author)` per `.claude/skills/pr-author/SKILL.md`, which owns the body-file and
206
+ receipt contract; defer to that skill for it rather than restating any value from it here.
207
+ The delegation is non-isolated and passes the absolute body path beneath the consolidation
208
+ worktree, as that skill's coordinator-session topology requires. For `<N>`,
187
209
  use the GitHub issue number the run executes under when the run has one. When the run
188
210
  has none, `<N>` is an arbitrary run-scoped identifier chosen by the pr-author agent.
189
- The delegation prompt MUST carry a `branch: <name>` label naming the consolidation branch,
190
- and the canonical issue number line when the run has an issue number.
191
- `enforce-model-routing-receipt.ps1` identifies the item from those two lines and denies a
211
+ The delegation prompt MUST carry exactly one `branch: documentationandmemories` label, on
212
+ its own line, and no `--head` or `--branch` text, plus the canonical issue number line when
213
+ the run has an issue number.
214
+ `enforce-model-routing-receipt.ps1` identifies the item from those lines and denies a
192
215
  gated delegation it cannot identify. A cleanup run with no issue number is therefore
193
216
  identified by the branch label alone, which is why the label is required and the issue line
194
217
  is not.
@@ -397,6 +420,15 @@ specific files or commit SHAs, before step 9 acts on any finding.
397
420
  `git worktree remove <path>` command only when a record in it covers that exact path. A removal
398
421
  issued with no covering record is denied with each gate's existing, unchanged reason code.
399
422
 
423
+ The manifest lives at the session root: the main checkout where this skill runs. Both removal
424
+ gates, the PR-author gate, and the model-routing gate read the relative path
425
+ `artifacts/orchestration/cleanup-worktrees-manifest.json` against the hook process location,
426
+ which is the session root, so a manifest written in any other worktree is never read. Write the
427
+ whole document with Write; the preimplementation gate admits that Write without a checkpoint
428
+ only at the session-root spelling and only when the content carries the expected `tool` and
429
+ `schema_version`. An Edit of the manifest is not supported without a checkpoint, and neither is a
430
+ Codex `apply_patch` Update, Delete, or Move of it; rewrite the document with Write instead.
431
+
400
432
  The document is UTF-8 JSON with `snake_case` keys. It carries six top-level fields. Its `removals`
401
433
  and `preserved_files` entries are sibling arrays and are never nested: removal records are keyed by
402
434
  worktree path, preserve records by the pair of worktree path and file path, and the gates must
@@ -445,7 +477,8 @@ These records serve the consolidation consumer and are never read by either gate
445
477
  consumer reads that worktree's `MEMORY.md`.
446
478
  - `source_path` — string, required, repo-relative within that worktree. The file to preserve.
447
479
  - `change_class` — string, required, either `untracked` or `modified`, recording whether staging
448
- adds a new file or carries a working-tree modification.
480
+ adds a new file or carries a working-tree modification. How each class is staged is described
481
+ under `### Preserve staging outcomes`.
449
482
  - `disposition` — string, required, exactly `PRESERVE`.
450
483
  - `verdict` — string, required, a member of the step-5 verdict vocabulary listed above.
451
484
  - `target_path` — string, required, repo-relative on the consolidation branch. Required because the
@@ -499,6 +532,52 @@ These records serve the consolidation consumer and are never read by either gate
499
532
  }
500
533
  ```
501
534
 
535
+ ### Preserve staging outcomes
536
+
537
+ `cleanup-worktrees.sh --preserve` reports each record's outcome as
538
+ `ACTION|preserve-stage|<target_path>|<result>`. The result vocabulary is:
539
+
540
+ - `OK` — the record was written and staged.
541
+ - `FAILED` — a write or staging operation failed; the remaining records continue.
542
+ - `SKIPPED-INVALID` — a record field failed validation.
543
+ - `MISSING-SOURCE` — the source file does not exist in the named worktree.
544
+ - `HOST-TOKEN-BLOCKED` — the incoming bytes carry a host-identifying token; the whole pass stops
545
+ with exit 3 and nothing is staged.
546
+ - `IGNORED-TARGET` — the target path is ignored on the consolidation branch and is not forced.
547
+ - `MISSING-WORKTREE` — the consolidation worktree does not exist.
548
+ - `REFUSED-WOULD-REGRESS` — staging the record would lose a line that the consolidation branch
549
+ already carries, or the record repeats an earlier record's `target_path`.
550
+
551
+ Staging depends on whether `<consolidation-worktree>/<target_path>` is an existing regular file:
552
+
553
+ - Absent target, either `change_class`: the source is copied byte for byte.
554
+ - Existing target, `untracked` record: the source is copied only when every line of the target is
555
+ present in the source in the same order; otherwise the record is refused.
556
+ - Existing target, `modified` record with a merge base (the source path equals the target path, and
557
+ the file exists at the merge base of the worktree `HEAD` and the consolidation `HEAD`): the
558
+ worktree change must only add lines to the base (G1); a `git merge-file --union --object-id`
559
+ merge of the target, the base, and the source then runs; the merged result must keep every target
560
+ line (G2) and must add no line beyond the worktree's own additions (G3). The merged blob is
561
+ staged.
562
+ - Existing target, `modified` record without a merge base: treated as an existing `untracked`
563
+ target.
564
+ - A record whose `target_path` repeats an earlier record's in the same pass is refused; the first
565
+ record keeps the target.
566
+
567
+ A refused record still emits its `PRESERVE|` record, writes a `cleanup-worktrees: refused` line to
568
+ stderr naming the failed guard, appends no `MEMORY.md` index line, and makes the pass exit 1.
569
+
570
+ Residuals:
571
+
572
+ - A resurrected line textually identical to a line the worktree added cannot be distinguished by G3.
573
+ - The merge path requires Git 2.43 or later for `merge-file --object-id`; an older Git fails the
574
+ merge and the record is refused.
575
+ - The order in which `--union` places two insertions at the same position is not specified by Git;
576
+ G2 and G3 bound the result whatever the order.
577
+ - The planning phase writes unreferenced blob objects (`hash-object -w`, `merge-file --object-id`)
578
+ to the object store; it writes no index, ref, or working-tree file.
579
+ - A refused record is not consolidated; the operator consolidates it manually.
580
+
502
581
  ### Accepted residual
503
582
 
504
583
  Both gates read the command text of the `Bash` tool call they are given. A removal routed
@@ -30,19 +30,24 @@ source "$SCRIPT_DIR/cleanup_worktrees_actions_lib.sh"
30
30
  # shellcheck source=.claude/skills/cleanup-merged-worktrees/scripts/cleanup_worktrees_detached_lib.sh
31
31
  # shellcheck disable=SC1091
32
32
  source "$SCRIPT_DIR/cleanup_worktrees_detached_lib.sh"
33
- # Neither of the three libraries below requires being sourced last, and neither claims to
34
- # be. Each library's source-time work is limited to function definitions plus constant and
33
+ # None of the four libraries below requires being sourced last, and none claims to be.
34
+ # Each library's source-time work is limited to function definitions plus constant and
35
35
  # environment-default variable assignments; none of them calls a function while being
36
36
  # sourced, and no function name is defined by more than one library, so no later source can
37
37
  # shadow an earlier definition. Bash resolves a function name when the call runs, not when
38
38
  # the caller is defined, so the cross-library calls resolve whatever the order: the
39
39
  # disposable-dirt classifier is called BY run_report and BY delete_candidate in the
40
40
  # libraries above, and the preserve library calls cleanup_wt_git from the enumeration
41
- # library and consolidation_worktree_path from the actions library. The order here is
42
- # therefore grouping for readability only.
41
+ # library, consolidation_worktree_path from the actions library, and the staging decision
42
+ # from the merge library. The order here is therefore grouping for readability only.
43
43
  # shellcheck source=.claude/skills/cleanup-merged-worktrees/scripts/cleanup_worktrees_dirt_lib.sh
44
44
  # shellcheck disable=SC1091
45
45
  source "$SCRIPT_DIR/cleanup_worktrees_dirt_lib.sh"
46
+ # The merge library holds the preserve staging decision. It is sourced before the
47
+ # line-ending library so that library stays immediately before the preserve library.
48
+ # shellcheck source=.claude/skills/cleanup-merged-worktrees/scripts/cleanup_worktrees_preserve_merge_lib.sh
49
+ # shellcheck disable=SC1091
50
+ source "$SCRIPT_DIR/cleanup_worktrees_preserve_merge_lib.sh"
46
51
  # The line-ending and index group is placed immediately before the preserve library, whose
47
52
  # read-only planning phase calls the functions it defines.
48
53
  # shellcheck source=.claude/skills/cleanup-merged-worktrees/scripts/cleanup_worktrees_preserve_eol_lib.sh
@@ -69,12 +74,20 @@ Commands:
69
74
  preserve | --preserve
70
75
  Stage the manifest's preserved_files[] findings onto the
71
76
  consolidation branch. Takes no operand; the manifest path comes
72
- from CLEANUP_WT_MANIFEST_PATH. Copies each named source file
73
- verbatim, carries its MEMORY.md index line across using the
74
- destination file's own re-derived line-ending convention, and
75
- refuses the whole pass when the incoming bytes carry a
76
- host-identifying token. Exit 0 clean, 1 something was skipped or
77
- an index was created, 3 host-token hard stop, 127 a required tool
77
+ from CLEANUP_WT_MANIFEST_PATH. Staging depends on the target and
78
+ change_class: a target absent from the consolidation branch is
79
+ copied byte for byte; an existing target is copied only when
80
+ none of its lines would be lost (untracked, or modified with no
81
+ merge base), or receives a three-way union merge of the
82
+ worktree's added lines (modified with a merge base). A record
83
+ that would lose a line, fails a merge guard, or repeats an
84
+ earlier target_path is refused with
85
+ ACTION|preserve-stage|<target>|REFUSED-WOULD-REGRESS. The
86
+ MEMORY.md index line is carried across using the destination
87
+ file's own re-derived line-ending convention, and the whole pass
88
+ is refused when the incoming bytes carry a host-identifying
89
+ token. Exit 0 clean, 1 something was skipped or refused or an
90
+ index was created, 3 host-token hard stop, 127 a required tool
78
91
  could not be resolved.
79
92
  --help | -h | help Print this help and exit 0.
80
93
 
@@ -12,12 +12,13 @@
12
12
  # cleanup_worktrees_actions_lib.sh, because it calls cleanup_wt_git (defined in the
13
13
  # enumeration library) and consolidation_worktree_path (defined in the actions library).
14
14
  #
15
- # Two-phase design. preserve_plan performs NO writes: it reads and validates the manifest
16
- # and every record, verifies each source file exists, runs the host-token pre-pass,
17
- # decides each index outcome, and emits the PRESERVE| and ACTION| records plus an internal
18
- # plan stream. preserve_commit_plan is the only function that writes and is entered only
19
- # when the pre-pass reported no match, which is what keeps the writing phase thin enough
20
- # to leave in the coverage denominator rather than exclude from measurement.
15
+ # Two-phase design. preserve_plan writes no index, ref, or working-tree file: it validates
16
+ # the manifest and every record, verifies each source exists, runs the host-token
17
+ # pre-pass, takes each staging and index decision, and emits the PRESERVE| and ACTION|
18
+ # records plus an internal plan stream. Its one write is the exception stated in
19
+ # cleanup_worktrees_preserve_merge_lib.sh: unreferenced blob objects created by
20
+ # `hash-object -w` and `merge-file --object-id`. preserve_commit_plan is the only writer of
21
+ # files and is entered only when the pre-pass reported no match.
21
22
  #
22
23
  # Seams. All git commands go through cleanup_wt_git (CLEANUP_WT_GIT_BIN); jq resolves
23
24
  # through preserve_resolve_jq (CLEANUP_WT_JQ_BIN); the manifest path comes from
@@ -174,9 +175,8 @@ preserve_validate_record() {
174
175
  # ACTION|preserve-stage|<target-path>|SKIPPED-INVALID; the first violation in the chain
175
176
  # below wins, because one bad field skips the record whatever the others say.
176
177
  #
177
- # change_class is validated for vocabulary but does NOT change the staging action: an
178
- # untracked and a modified record are both copied to target_path and staged, stated
179
- # here so no later change invents a divergent path for one of them.
178
+ # change_class is validated here for vocabulary only; its effect on staging belongs to
179
+ # preserve_merge_decide (absent target copied, existing target guarded or merged).
180
180
  #
181
181
  # host_token_scan is refused when it is absent, is not a JSON object, is missing either
182
182
  # member, or carries a result outside clean/tokens_present: a record whose scan metadata
@@ -236,16 +236,18 @@ preserve_validate_record() {
236
236
  }
237
237
 
238
238
  preserve_plan_one() {
239
- # Plan one manifest record. Performs no write of any kind.
239
+ # Plan one manifest record. Writes no index, ref, or working-tree file; the staging
240
+ # decision may write unreferenced blob objects (`hash-object -w`, `merge-file
241
+ # --object-id`), the exception cleanup_worktrees_preserve_merge_lib.sh states.
240
242
  #
241
- # The PRESERVE| report record is emitted for every record that survives validation,
242
- # before any per-record outcome is known, because that record identifies the finding at
243
- # its origin exactly as DIRTY|<worktree-path>|<status-line> does. The destination
244
- # outcome travels separately on the ACTION| result channel.
243
+ # The PRESERVE| record is emitted for every record that survives validation, before any
244
+ # per-record outcome is known, as DIRTY|<worktree-path>|<status-line> is; the outcome
245
+ # travels separately on the ACTION| result channel. The source file is read by plain
246
+ # file I/O at <worktree_path>/<source_path>, never through git (see the header).
245
247
  #
246
- # The source file is located by plain file I/O at <worktree_path>/<source_path> and is
247
- # never read through git: the repository-root .claude/agent-memory tree is gitignored,
248
- # so a git-mediated read would be refused or would need a force flag.
248
+ # The plan entry carries ten columns: target_path, source file, destination file, index
249
+ # action, index path, index line, terminator token, leading-terminator flag, staging
250
+ # mode (`copy` or `merge`), and the merged blob id (empty for `copy`).
249
251
  #
250
252
  # Args: $1 = the tab-separated record. Returns 0 when the record was planned and 1 when
251
253
  # it was skipped, refused, or reported.
@@ -294,6 +296,11 @@ preserve_plan_one() {
294
296
  printf 'cleanup-worktrees: destination is ignored, refusing to force: %s\n' "$tgt" >&2
295
297
  return 1
296
298
  fi
299
+ # The staging decision (merge library): a repeated target_path is refused, and a target
300
+ # that already exists is guarded or merged rather than overwritten. A refusal returns
301
+ # before the index decision, so a refused record produces no index record either.
302
+ preserve_merge_check_duplicate "$tgt" || return 1
303
+ preserve_merge_decide "${PRESERVE_FIELDS[2]}" "$wt" "$src" "$tgt" "$PRESERVE_CWT" || return 1
297
304
  # The index decision, including the line-ending re-derivation it rests on, lives in
298
305
  # the line-ending library. A memory_index_line of JSON null means the file is not a
299
306
  # memory entry: it is copied and staged, and no index is read, created, or appended to.
@@ -307,16 +314,19 @@ preserve_plan_one() {
307
314
  ipre=$PRESERVE_INDEX_PRE
308
315
  fi
309
316
  local entry
310
- printf -v entry '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s' \
311
- "$tgt" "$srcfile" "$PRESERVE_CWT/$tgt" "$iaction" "$ipath" "$iline" "$iterm" "$ipre"
317
+ printf -v entry '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s' \
318
+ "$tgt" "$srcfile" "$PRESERVE_CWT/$tgt" "$iaction" "$ipath" "$iline" "$iterm" "$ipre" \
319
+ "$PRESERVE_STAGE_MODE" "$PRESERVE_STAGE_OID"
312
320
  PRESERVE_PLAN_STREAM+=("$entry")
313
321
  return 0
314
322
  }
315
323
 
316
324
  preserve_plan() {
317
- # Phase 1 of the two-phase driver: read, validate, decide, and report. It performs no
318
- # write of any kind, so a failure anywhere in it leaves the consolidation worktree
319
- # byte-unchanged and there is nothing to undo.
325
+ # Phase 1 of the two-phase driver: read, validate, decide, and report. It writes no
326
+ # index, ref, or working-tree file, so a failure anywhere in it leaves the consolidation
327
+ # worktree byte-unchanged and there is nothing to undo. The one exception is the staging
328
+ # decision's unreferenced blob objects (`hash-object -w`, `merge-file --object-id`),
329
+ # which nothing references unless phase 2 stages the merged result.
320
330
  #
321
331
  # Records are processed in LC_ALL=C order of the pair (worktree_path, source_path)
322
332
  # rather than in manifest array order, because the report stream's ordering is
@@ -324,10 +334,8 @@ preserve_plan() {
324
334
  # a contract. The sort is field-aware rather than whole-line, so a worktree_path that
325
335
  # is a prefix of another cannot reorder the pair.
326
336
  #
327
- # The decisions are accumulated on two globals rather than returned, because a record's
328
- # outcome has to survive the loop: PRESERVE_PLAN_STREAM carries one tab-separated entry
329
- # per record that reached the writing phase, and PRESERVE_EXIT accumulates the
330
- # per-record skip signal that becomes the pass's exit code.
337
+ # Outcomes survive the loop on globals: PRESERVE_PLAN_STREAM (one entry per planned
338
+ # record), PRESERVE_EXIT (the per-record skip signal), and PRESERVE_SEEN_TARGETS.
331
339
  #
332
340
  # Returns 0 when the pass may proceed to the writing phase, 1 when the manifest itself
333
341
  # was rejected, 3 on the host-token hard stop, and 127 when a required tool could not
@@ -337,6 +345,9 @@ preserve_plan() {
337
345
  PRESERVE_EXIT=0
338
346
  PRESERVE_BLOCKED=0
339
347
  PRESERVE_CWT=""
348
+ # Read by preserve_merge_check_duplicate in the merge library (SC2034 cannot see it).
349
+ # shellcheck disable=SC2034
350
+ PRESERVE_SEEN_TARGETS=""
340
351
  local manifest records sorted line rc=0
341
352
  manifest=${CLEANUP_WT_MANIFEST_PATH:-artifacts/orchestration/cleanup-worktrees-manifest.json}
342
353
  PRESERVE_CWT=$(consolidation_worktree_path) || return $?
@@ -353,31 +364,26 @@ preserve_plan() {
353
364
  [[ -n $line ]] || continue
354
365
  preserve_plan_one "$line" || PRESERVE_EXIT=1
355
366
  done <<<"$sorted"
356
- # The hard stop is evaluated only after every record has been examined, so the pass
357
- # reports every match rather than only the first, and the writing phase is not entered
358
- # for any record. Nothing has been written at this point, so there is nothing to undo.
367
+ # The hard stop is evaluated after every record is examined, so every match is reported
368
+ # and the writing phase is entered for no record; no file has been written to undo.
359
369
  ((PRESERVE_BLOCKED == 0)) || return 3
360
370
  return 0
361
371
  }
362
372
 
363
373
  preserve_commit_plan() {
364
- # Phase 2: the only function in this library that writes anything.
374
+ # Phase 2: the only function in this library that writes files. It executes each plan
375
+ # entry's decisions with exactly four kinds of operation: mkdir -p of the destination
376
+ # directory, the content write, the index append or creation, and the staging call. It
377
+ # takes no staging decision of its own, which keeps it thin enough to stay measured.
365
378
  #
366
- # It consumes the plan stream phase 1 built and performs exactly four kinds of
367
- # operation: mkdir -p of the destination directory, a verbatim byte copy of the source,
368
- # the destination index append or creation, and the staging call. Every decision was
369
- # already taken in phase 1, so this function contains no policy and is thin enough to
370
- # leave in the coverage denominator rather than exclude from measurement.
379
+ # The content write follows column 9. `merge` writes the merged blob phase 1 recorded
380
+ # (`cat-file blob <oid>`); `copy` or empty copies the source byte for byte. Neither
381
+ # applies a line-ending transformation to the preserved content; the line-ending
382
+ # obligation is scoped to the index append, and staging re-normalizes tracked text.
371
383
  #
372
- # The copy is verbatim. No line-ending transformation is applied to the preserved
373
- # file's own content: transforming it would alter content the editorial pass judged
374
- # worth keeping, and the staging call re-normalizes tracked text anyway. The
375
- # line-ending obligation is scoped to the index append, where the defect occurred.
376
- #
377
- # A non-zero result from any of the three operations emits
378
- # ACTION|preserve-stage|<target-path>|FAILED and contributes to exit 1; it never aborts
379
- # the remaining records, because each record's write is independent of the others.
380
- local entry tgt srcfile dstfile iaction ipath iline iterm ipre fail
384
+ # A failed operation emits ACTION|preserve-stage|<target-path>|FAILED and contributes to
385
+ # exit 1; it never aborts the remaining records, whose writes are independent.
386
+ local entry tgt srcfile dstfile iaction ipath iline iterm ipre mode oid fail
381
387
  ((${#PRESERVE_PLAN_STREAM[@]} > 0)) || return 0
382
388
  for entry in "${PRESERVE_PLAN_STREAM[@]}"; do
383
389
  preserve_split_tsv "$entry"
@@ -389,10 +395,12 @@ preserve_commit_plan() {
389
395
  iline=${PRESERVE_FIELDS[5]}
390
396
  iterm=${PRESERVE_FIELDS[6]}
391
397
  ipre=${PRESERVE_FIELDS[7]}
392
- fail=""
398
+ mode=${PRESERVE_FIELDS[8]-} oid=${PRESERVE_FIELDS[9]-} fail=""
393
399
  if ! mkdir -p -- "${dstfile%/*}" 2>/dev/null; then
394
400
  fail="cannot create the destination directory"
395
- elif ! cat -- "$srcfile" >"$dstfile" 2>/dev/null; then
401
+ elif [[ $mode == "merge" ]] && ! cleanup_wt_git -C "$PRESERVE_CWT" cat-file blob "$oid" >"$dstfile"; then
402
+ fail="reading the merged blob failed"
403
+ elif [[ $mode != "merge" ]] && ! cat -- "$srcfile" >"$dstfile" 2>/dev/null; then
396
404
  fail="the verbatim byte copy failed"
397
405
  elif [[ $iaction != "none" ]] &&
398
406
  ! preserve_render_index_append "$ipath" "$iline" "$iterm" "$ipre"; then
@@ -0,0 +1,234 @@
1
+ #!/usr/bin/env bash
2
+ # cleanup_worktrees_preserve_merge_lib.sh: sourceable staging-decision function group for
3
+ # the preserve pass of the cleanup-worktrees tool. For each preserve record whose target
4
+ # already exists as a regular file on the consolidation branch, it decides whether the
5
+ # source may be copied, whether a three-way union merge may be staged instead, or whether
6
+ # the record must be refused with ACTION|preserve-stage|<target-path>|REFUSED-WOULD-REGRESS.
7
+ #
8
+ # Sourcing contract: this library defines functions only and runs no work at source time.
9
+ # It MUST be sourced before cleanup_worktrees_preserve_lib.sh, whose preserve_plan_one calls
10
+ # preserve_merge_check_duplicate and preserve_merge_decide. Its own git calls go through
11
+ # cleanup_wt_git, defined in cleanup_worktrees_enumerate_lib.sh.
12
+ #
13
+ # Phase-1 write exception. The preserve planning phase writes no index, ref, or working-tree
14
+ # file. The one write it performs is the creation of unreferenced blob objects in the object
15
+ # store: `git hash-object -w` hashes the two merge inputs and `git merge-file --object-id`
16
+ # records the merged result. Nothing refers to those blobs unless the writing phase stages
17
+ # the merged result, and an unreferenced blob is removed by the normal object pruning.
18
+ #
19
+ # Line-comparison rule. Every guard compares lines. Content is split with the
20
+ # `while IFS= read -r line || [[ -n $line ]]` idiom, so an unterminated final line counts as
21
+ # a line, and one trailing carriage return is stripped from each line before comparison, so
22
+ # a CRLF working-tree file and its LF-normalized blob compare equal.
23
+ #
24
+ # No git call in this library passes -f or --force, and no process substitution is passed
25
+ # to git as a file argument: native git on Windows cannot open an MSYS /dev/fd path.
26
+
27
+ preserve_merge_split_lines() {
28
+ # Split one content string into the global array PRESERVE_MERGE_LINES under the
29
+ # line-comparison rule stated in the header. An empty string yields no line at all.
30
+ #
31
+ # Args: $1 = the content.
32
+ PRESERVE_MERGE_LINES=()
33
+ # The carriage return is held in a variable: ANSI-C quoting nested inside a
34
+ # double-quoted expansion is not interpreted consistently across bash versions.
35
+ local line cr=$'\r'
36
+ while IFS= read -r line || [[ -n $line ]]; do
37
+ PRESERVE_MERGE_LINES+=("${line%"$cr"}")
38
+ done < <(printf '%s' "${1-}")
39
+ }
40
+
41
+ preserve_merge_is_subsequence() {
42
+ # Return 0 when every line of the needle appears in the haystack in the same order, not
43
+ # necessarily adjacent, and 1 otherwise. An empty needle is a subsequence of anything.
44
+ #
45
+ # Args: $1 = needle content, $2 = haystack content.
46
+ local -a needle=() hay=()
47
+ preserve_merge_split_lines "${1-}"
48
+ needle=("${PRESERVE_MERGE_LINES[@]}")
49
+ preserve_merge_split_lines "${2-}"
50
+ hay=("${PRESERVE_MERGE_LINES[@]}")
51
+ local n=${#needle[@]} i=0 line
52
+ if ((n == 0)); then
53
+ return 0
54
+ fi
55
+ for line in "${hay[@]}"; do
56
+ if [[ $line == "${needle[$i]}" ]]; then
57
+ i=$((i + 1))
58
+ if ((i == n)); then
59
+ return 0
60
+ fi
61
+ fi
62
+ done
63
+ return 1
64
+ }
65
+
66
+ preserve_merge_extra_within() {
67
+ # Guard G3. Return 0 when multiset(merged) minus multiset(main) is contained in
68
+ # multiset(worktree) minus multiset(base), and 1 otherwise. A merged line that main does
69
+ # not carry is admitted only as one of the worktree's own additions, so a base line that
70
+ # main deleted and the merge re-emitted is refused. Counts are kept per line, so a line
71
+ # the worktree added once may appear at most once beyond main's own copies.
72
+ #
73
+ # Keys carry a fixed prefix because bash refuses an empty associative-array subscript,
74
+ # and a blank line is an ordinary line.
75
+ #
76
+ # Args: $1 = merged content, $2 = main content, $3 = worktree content, $4 = base content.
77
+ local -A extra=() added=()
78
+ local line key
79
+ preserve_merge_split_lines "${1-}"
80
+ for line in "${PRESERVE_MERGE_LINES[@]}"; do
81
+ key="k$line"
82
+ extra["$key"]=$((${extra["$key"]:-0} + 1))
83
+ done
84
+ preserve_merge_split_lines "${2-}"
85
+ for line in "${PRESERVE_MERGE_LINES[@]}"; do
86
+ key="k$line"
87
+ extra["$key"]=$((${extra["$key"]:-0} - 1))
88
+ done
89
+ preserve_merge_split_lines "${3-}"
90
+ for line in "${PRESERVE_MERGE_LINES[@]}"; do
91
+ key="k$line"
92
+ added["$key"]=$((${added["$key"]:-0} + 1))
93
+ done
94
+ preserve_merge_split_lines "${4-}"
95
+ for line in "${PRESERVE_MERGE_LINES[@]}"; do
96
+ key="k$line"
97
+ added["$key"]=$((${added["$key"]:-0} - 1))
98
+ done
99
+ for key in "${!extra[@]}"; do
100
+ if ((${extra["$key"]} > ${added["$key"]:-0} && ${extra["$key"]} > 0)); then
101
+ return 1
102
+ fi
103
+ done
104
+ return 0
105
+ }
106
+
107
+ preserve_merge_refuse() {
108
+ # Refuse one record: emit ACTION|preserve-stage|<target-path>|REFUSED-WOULD-REGRESS on
109
+ # stdout, a `cleanup-worktrees: refused` diagnostic naming the reason on stderr, set
110
+ # PRESERVE_EXIT to 1, and return 1. A refusal is not a hard stop: the remaining records
111
+ # are still planned and the pass exits 1.
112
+ #
113
+ # Args: $1 = target_path, $2 = the reason, which begins with the failed guard's token.
114
+ printf 'ACTION|preserve-stage|%s|REFUSED-WOULD-REGRESS\n' "${1-}"
115
+ printf 'cleanup-worktrees: refused %s: %s\n' "${1-}" "${2-}" >&2
116
+ # Read by preserve_plan and run_preserve across a source boundary shellcheck does not
117
+ # follow, so SC2034 reports it unused here; the suppression is line-scoped.
118
+ # shellcheck disable=SC2034
119
+ PRESERVE_EXIT=1
120
+ return 1
121
+ }
122
+
123
+ preserve_merge_check_duplicate() {
124
+ # Refuse a record whose target_path an earlier record of the same pass already named. A
125
+ # target counts as seen as soon as a record reaches this check, whatever that record's
126
+ # later outcome, so the first record keeps the target and every later one is refused.
127
+ # The seen set is the newline-delimited global PRESERVE_SEEN_TARGETS, which preserve_plan
128
+ # resets at the start of every pass.
129
+ #
130
+ # Args: $1 = target_path. Returns 0 when the target is new, 1 when it was refused.
131
+ local tgt=${1-}
132
+ local seen=${PRESERVE_SEEN_TARGETS:-$'\n'}
133
+ if [[ $seen == *$'\n'"$tgt"$'\n'* ]]; then
134
+ preserve_merge_refuse "$tgt" "duplicate-target: an earlier record in this pass names the same target_path"
135
+ return 1
136
+ fi
137
+ PRESERVE_SEEN_TARGETS="$seen$tgt"$'\n'
138
+ return 0
139
+ }
140
+
141
+ preserve_merge_resolve_base() {
142
+ # Echo the blob id of the source file at the merge base of the worktree HEAD and the
143
+ # consolidation HEAD, or return 1 when no base is available. The base is unavailable
144
+ # when source_path differs from target_path (no git call is made in that case), when
145
+ # the worktree HEAD does not resolve, when the two HEADs have no merge base, or when the
146
+ # file is absent at the merge base. An empty answer from git counts as unavailable.
147
+ #
148
+ # Args: $1 = worktree path, $2 = source_path, $3 = target_path, $4 = consolidation path.
149
+ local wt=${1-} src=${2-} tgt=${3-} cwt=${4-} head="" mb="" oid=""
150
+ if [[ $src != "$tgt" ]]; then
151
+ return 1
152
+ fi
153
+ head=$(cleanup_wt_git -C "$wt" rev-parse --verify --quiet HEAD) || return 1
154
+ [[ -n $head ]] || return 1
155
+ mb=$(cleanup_wt_git -C "$cwt" merge-base "$head" HEAD) || return 1
156
+ [[ -n $mb ]] || return 1
157
+ oid=$(cleanup_wt_git -C "$cwt" rev-parse --verify --quiet "$mb:$src") || return 1
158
+ [[ -n $oid ]] || return 1
159
+ printf '%s\n' "$oid"
160
+ }
161
+
162
+ preserve_merge_guard_b() {
163
+ # Guard (b), the fallback when no merge base is available: admit a whole-file copy only
164
+ # when main's version is a line-subsequence of the source, so no main line is lost.
165
+ #
166
+ # Args: $1 = target_path, $2 = main content, $3 = source content.
167
+ if preserve_merge_is_subsequence "${2-}" "${3-}"; then
168
+ PRESERVE_STAGE_MODE="copy"
169
+ return 0
170
+ fi
171
+ preserve_merge_refuse "${1-}" "guard-b: the target holds lines the source lacks"
172
+ return 1
173
+ }
174
+
175
+ # PRESERVE_STAGE_MODE and PRESERVE_STAGE_OID are read by preserve_plan_one across a source
176
+ # boundary shellcheck does not follow, so SC2034 reports them unused; the suppression is
177
+ # scoped to this one function.
178
+ # shellcheck disable=SC2034
179
+ preserve_merge_decide() {
180
+ # Decide how one record is staged. Sets PRESERVE_STAGE_MODE to `copy` or `merge` and
181
+ # PRESERVE_STAGE_OID to the merged blob id (empty for `copy`), or refuses the record.
182
+ #
183
+ # - target not a regular file: `copy`, with no git call at all;
184
+ # - `untracked` record, or `modified` record with no merge base: guard (b);
185
+ # - `modified` record with a merge base: G1 (the worktree change is insertion-only),
186
+ # then a `git merge-file --union --object-id` merge, then G2 (every main line
187
+ # survives) and G3 (no line beyond the worktree's own additions); `merge` on success.
188
+ #
189
+ # The source and target are read with plain file I/O. The base and merged contents are
190
+ # read from the object store with `cat-file blob`.
191
+ #
192
+ # Args: $1 = change_class, $2 = worktree path, $3 = source_path, $4 = target_path,
193
+ # $5 = consolidation path. Returns 0 when the record may be staged, 1 when it was refused.
194
+ local cls=${1-} wt=${2-} src=${3-} tgt=${4-} cwt=${5-}
195
+ PRESERVE_STAGE_MODE="copy"
196
+ PRESERVE_STAGE_OID=""
197
+ if [[ ! -f "$cwt/$tgt" ]]; then
198
+ return 0
199
+ fi
200
+ local main_c wt_c base_oid base_c main_oid wt_oid merged_oid merged_c mrc=0
201
+ main_c=$(<"$cwt/$tgt")
202
+ wt_c=$(<"$wt/$src")
203
+ if [[ $cls == "untracked" ]] || ! base_oid=$(preserve_merge_resolve_base "$wt" "$src" "$tgt" "$cwt"); then
204
+ preserve_merge_guard_b "$tgt" "$main_c" "$wt_c"
205
+ return
206
+ fi
207
+ base_c=$(cleanup_wt_git -C "$cwt" cat-file blob "$base_oid") ||
208
+ { preserve_merge_refuse "$tgt" "G1: the base blob could not be read" || return 1; }
209
+ if ! preserve_merge_is_subsequence "$base_c" "$wt_c"; then
210
+ preserve_merge_refuse "$tgt" "G1: the worktree removed or changed a base line"
211
+ return 1
212
+ fi
213
+ main_oid=$(cleanup_wt_git -C "$cwt" hash-object -w --path="$tgt" -- "$tgt") || main_oid=""
214
+ wt_oid=$(cleanup_wt_git -C "$wt" hash-object -w --path="$tgt" -- "$src") || wt_oid=""
215
+ merged_oid=$(cleanup_wt_git -C "$cwt" merge-file --union --object-id "$main_oid" "$base_oid" "$wt_oid") || mrc=$?
216
+ if ((mrc != 0)) || [[ -z $main_oid || -z $wt_oid || -z $merged_oid ]]; then
217
+ preserve_merge_refuse "$tgt" "merge-file rc=$mrc: no merged blob was produced"
218
+ return 1
219
+ fi
220
+ merged_c=$(cleanup_wt_git -C "$cwt" cat-file blob "$merged_oid") ||
221
+ { preserve_merge_refuse "$tgt" "G2: the merged blob could not be read" || return 1; }
222
+ if ! preserve_merge_is_subsequence "$main_c" "$merged_c"; then
223
+ preserve_merge_refuse "$tgt" "G2: the merged result lost a main line"
224
+ return 1
225
+ fi
226
+ if ! preserve_merge_extra_within "$merged_c" "$main_c" "$wt_c" "$base_c"; then
227
+ preserve_merge_refuse "$tgt" "G3: the merged result carries a line the worktree did not add"
228
+ return 1
229
+ fi
230
+ PRESERVE_STAGE_MODE="merge"
231
+ PRESERVE_STAGE_OID=$merged_oid
232
+ printf 'cleanup-worktrees: merged %s\n' "$tgt" >&2
233
+ return 0
234
+ }