muse-crew 0.14.0 → 0.14.2

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.
@@ -18,9 +18,11 @@
18
18
  # validate <task_id> — check task ID is safe for branches/paths
19
19
  # prepare <task_id> — create worktree + branch, register ownership
20
20
  # resolve-branch <task_id> — print the resolved task branch name
21
- # inspect <task_id> — diff against main for review
22
- # integrate <task_id> <commit_msg> — merge lock → merge into main → report commit
23
- # verify-merge <task_id> — confirm the task branch tip is an ancestor of main (post-step merge check)
21
+ # inspect <task_id> — diff against the integration target for review
22
+ # integrate <task_id> <commit_msg> — merge lock → merge into the integration target → report commit
23
+ # integration-target — print the integration target (branch name, or HEAD when detached)
24
+ # push-target <task_id> — refresh lock, push the integration target (skipped loudly on detached HEAD)
25
+ # verify-merge <task_id> — confirm the task branch tip is an ancestor of the integration target (post-step merge check)
24
26
  # post-deploy <task_id> — commit builder artifacts, cleanup, release lock
25
27
  # terminal-cleanup <task_id> — park/fail boundary: release lock (always), reclaim worktree+branch only when fully merged
26
28
  # cleanup <task_id> — remove worktree + branch (no lock)
@@ -35,7 +37,7 @@
35
37
  # 2 — merge lock held by another task; also BLOCKED on unset
36
38
  # CREW_HOME/CREW_REPO (prints a BLOCKED line, never proceeds silently)
37
39
  # 3 — merge conflict
38
- # 4 — dirty main (cannot merge)
40
+ # 4 — dirty integration target (cannot merge)
39
41
 
40
42
  set -euo pipefail
41
43
 
@@ -62,7 +64,7 @@ MERGE_LOCK="$LIB_DIR/merge-lock.sh"
62
64
  # on persistent disk — same durability class as $CREW_HOME/.pins/<task>).
63
65
  # Written by cmd_integrate (post-reconcile HEAD). Read by cmd_verify_merge
64
66
  # (branch-reclamation fallback) and the workflow. Consumers trust a record
65
- # only after verifying `git merge-base --is-ancestor <merge_commit> main` —
67
+ # only after verifying `git merge-base --is-ancestor <merge_commit> <target-tip>` —
66
68
  # a stale record is inert. Removed by cmd_post_deploy (consumed).
67
69
  MERGE_RECORDS_DIR="$CREW_HOME/.merge-records"
68
70
 
@@ -135,9 +137,30 @@ validate_task_id() {
135
137
  return 0
136
138
  }
137
139
 
138
- require_clean_main() {
140
+ # Prints the integration target: the current branch name, or "HEAD" when
141
+ # the repo sits on a detached HEAD. Clean-room staging pins the reviewed SHA
142
+ # and commits crew-init on top -- there is no branch to check out, and
143
+ # checking one out would abandon the staged line and break the stamped
144
+ # provenance (room #21, J3: require_clean_main's `git checkout main`
145
+ # discarded the detached crew-init commit; Publish then failed closed on
146
+ # ancestry). Every merge/verify/push operation resolves the target through
147
+ # this helper. The workflow NEVER checks out a branch.
148
+ integration_target() {
139
149
  cd "$REPO"
140
- git checkout main 2>/dev/null
150
+ git rev-parse --abbrev-ref HEAD 2>/dev/null || { echo "ERROR: cannot resolve HEAD"; return 1; }
151
+ }
152
+
153
+ # (2026-09-19 REVIEW: the old integration_tip() helper is deleted — it had
154
+ # zero call sites and cmd_verify_merge reimplemented tip resolution inline
155
+ # with different semantics, a drift hazard. Tip resolution lives in exactly
156
+ # two places now: cmd_integrate/cmd_push_target resolve the target name via
157
+ # integration_target(); cmd_verify_merge resolves the live tip directly.)
158
+
159
+ require_clean_target() {
160
+ cd "$REPO"
161
+ # NOTE: no `git checkout` here, ever. The integration target is the repo's
162
+ # current checkout (branch or detached HEAD); moving it would break the
163
+ # publish-base provenance the workflow stamped at task start.
141
164
  # The crew's own worktree dir is crew state, not user changes: exclude it
142
165
  # from the cleanliness check. (2026-09-17: 0.9.0 failed closed on every
143
166
  # target repo whose .gitignore doesn't cover .worktrees/ — which is every
@@ -147,7 +170,9 @@ require_clean_main() {
147
170
  local dirty
148
171
  dirty=$(git status --porcelain 2>/dev/null | grep -vE "^.{3}$wt_esc(/|$)" | wc -l)
149
172
  if [ "$dirty" -gt 0 ]; then
150
- echo "ERROR: main has $dirty uncommitted changes"
173
+ local target
174
+ target=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "?")
175
+ echo "ERROR: integration target $target has $dirty uncommitted changes"
151
176
  git status --short
152
177
  return 1
153
178
  fi
@@ -194,8 +219,8 @@ cmd_prepare() {
194
219
  return 0
195
220
  fi
196
221
 
197
- # Preflight: main must be clean — prepare fails closed.
198
- require_clean_main || exit 4
222
+ # Preflight: the integration target must be clean — prepare fails closed.
223
+ require_clean_target || exit 4
199
224
 
200
225
  local base_commit
201
226
  base_commit=$(git rev-parse HEAD)
@@ -242,52 +267,30 @@ cmd_inspect() {
242
267
  tip=$(git rev-parse "$branch")
243
268
  echo "TIP: $tip"
244
269
  echo ""
245
- echo "=== Commits ahead of main ==="
246
- git log --oneline "main..$branch"
270
+ local target
271
+ target=$(integration_target) || return 1
272
+ echo "=== Integration target: $target ==="
247
273
  echo ""
248
- echo "=== Diff: main...$branch ==="
249
- git diff --stat "main...$branch"
274
+ echo "=== Commits ahead of $target ==="
275
+ git log --oneline "$target..$branch"
250
276
  echo ""
251
- git diff "main...$branch"
277
+ echo "=== Diff: $target...$branch ==="
278
+ git diff --stat "$target...$branch"
279
+ echo ""
280
+ git diff "$target...$branch"
252
281
  }
253
282
 
254
- cmd_integrate() {
283
+ # Acquire the merge lock with bounded backoff (serializes version assignment
284
+ # across concurrent runs). The holder is the opaque task+run identity from
285
+ # WORKFLOW_RUN_ID — never a PID (bug 2fc8f52f: short-lived agent PIDs made
286
+ # every concurrent acquire take the stale path). Another task mid
287
+ # Integrate/Publish is normal under simultaneity>1: bounded backoff (30s
288
+ # sleeps, 10min cap, overridable via MERGE_LOCK_RETRY_SECS /
289
+ # MERGE_LOCK_RETRY_CAP_SECS), then give up with LOCK_HELD so the workflow
290
+ # records the failure with a reason (dispatcher retries under its cap, parks
291
+ # after the cap). Returns 2 on LOCK_HELD / LOCK_ERROR; the caller exits.
292
+ acquire_merge_lock() {
255
293
  local task_id="$1"
256
- local commit_msg="${2:-merge: $task_id}"
257
- validate_task_id "$task_id"
258
-
259
- local branch
260
- branch=$(resolve_branch "$task_id")
261
-
262
- cd "$REPO"
263
-
264
- # Preflight: main must be clean
265
- require_clean_main || exit 4
266
-
267
- # Verify task branch exists and has commits ahead of main
268
- if ! git rev-parse --verify "$branch" >/dev/null 2>&1; then
269
- echo "ERROR: branch $branch does not exist"
270
- return 1
271
- fi
272
-
273
- local ahead
274
- ahead=$(git rev-list --count "main..$branch")
275
- if [ "$ahead" -eq 0 ]; then
276
- # Approved empty diff: the deliverable was runtime state (cron,
277
- # scheduler, dashboard config), not a repo change. Nothing to merge,
278
- # nothing to serialize — the merge lock is intentionally not taken.
279
- echo "MERGED_EMPTY: $branch has no commits ahead of main — runtime-state deliverable, nothing to merge"
280
- return 0
281
- fi
282
-
283
- # Acquire the merge lock (serializes version assignment across concurrent
284
- # runs). The holder is the opaque task+run identity from WORKFLOW_RUN_ID —
285
- # never a PID (bug 2fc8f52f: short-lived agent PIDs made every concurrent
286
- # acquire take the stale path). Another task mid Integrate/Publish is
287
- # normal under simultaneity>1: bounded backoff (30s sleeps, 10min cap,
288
- # overridable via MERGE_LOCK_RETRY_SECS / MERGE_LOCK_RETRY_CAP_SECS), then
289
- # give up with LOCK_HELD so the workflow records the failure with a reason
290
- # (dispatcher retries under its cap, parks after the cap).
291
294
  local holder="${WORKFLOW_RUN_ID:-$task_id}"
292
295
  local retry_secs="${MERGE_LOCK_RETRY_SECS:-30}"
293
296
  local retry_cap="${MERGE_LOCK_RETRY_CAP_SECS:-600}"
@@ -296,25 +299,168 @@ cmd_integrate() {
296
299
  while true; do
297
300
  if lock_result=$("$MERGE_LOCK" acquire "$task_id" "$holder"); then
298
301
  echo "$lock_result"
299
- break
302
+ return 0
300
303
  fi
301
304
  case "$lock_result" in
302
305
  HELD*)
303
306
  if [ "$waited" -ge "$retry_cap" ]; then
304
307
  echo "LOCK_HELD: $lock_result (waited ${waited}s of ${retry_cap}s backoff)"
305
- exit 2
308
+ return 2
306
309
  fi
307
310
  sleep "$retry_secs"
308
311
  waited=$((waited + retry_secs))
309
312
  ;;
310
313
  *)
311
314
  echo "LOCK_ERROR: $lock_result"
312
- exit 2
315
+ return 2
313
316
  ;;
314
317
  esac
315
318
  done
319
+ }
320
+
321
+ # Deterministic push of the integration target. Called inline by
322
+ # cmd_integrate on the main path (the lock was just acquired there — no
323
+ # refresh needed) and by cmd_push_target for the R5 manual-conflict path
324
+ # (which refreshes the lock first). Never force-pushes.
325
+ #
326
+ # The caller must hold the merge lock. Prints one of PUSHED / PUSH_SKIPPED /
327
+ # NO_REMOTE_PUSH and returns 0, or prints an ERROR line and returns 1.
328
+ do_push() {
329
+ local task_id="$1"
330
+ cd "$REPO"
331
+
332
+ local target
333
+ target=$(integration_target) || return 1
334
+
335
+ # Detached HEAD: by design the integration line is local to this checkout
336
+ # (clean-room staging pins the reviewed commit; no remote ref is claimed
337
+ # for the detached line), so there is nothing to push. This is a policy
338
+ # statement, not a git limitation — `git push origin HEAD:<ref>` would
339
+ # work; we choose not to push the detached line. Room evidence is
340
+ # preserved in room state; releases ship through the loop's Publish phase.
341
+ if [ "$target" = "HEAD" ]; then
342
+ echo "PUSH_SKIPPED: detached HEAD -- the integration line is local by design; nothing to push"
343
+ return 0
344
+ fi
345
+
346
+ # No remote configured: fail soft, loudly. (2026-09-19 REVIEW: the old
347
+ # code ran `git push origin` unconditionally, which exits 128 with no
348
+ # origin, got mislabeled PUSH_REJECTED, and failed the phase — while
349
+ # cmd_integrate on the same repo had already reported MERGED with a
350
+ # fail-soft NO_REMOTE. A branch-based repo without a remote could never
351
+ # pass Integrate.)
352
+ if ! git remote get-url origin >/dev/null 2>&1; then
353
+ echo "NO_REMOTE_PUSH: no origin remote configured -- nothing to push"
354
+ return 0
355
+ fi
356
+
357
+ if git push origin "$target" 2>&1; then
358
+ echo "PUSHED: origin/$target"
359
+ return 0
360
+ fi
361
+
362
+ # Push failed. Do not assert a cause (the old code labeled every failure
363
+ # "non-fast-forward" — auth errors got the same label). Recover
364
+ # mechanically while the lock is held: fetch, merge, retry once.
365
+ echo "PUSH_FAILED: push did not complete -- reconciling once while the lock is held"
366
+ if ! git fetch origin "$target" 2>&1; then
367
+ echo "ERROR: git fetch origin $target failed -- push not completed"
368
+ return 1
369
+ fi
370
+ if ! git merge --no-edit "origin/$target" -m "merge: push-time reconcile ($task_id)" 2>&1; then
371
+ echo "CONFLICT: origin/$target diverged with conflicting changes -- aborting"
372
+ git merge --abort 2>/dev/null || true
373
+ return 1
374
+ fi
375
+ if git push origin "$target" 2>&1; then
376
+ echo "PUSHED: origin/$target (after reconcile)"
377
+ return 0
378
+ fi
379
+ echo "ERROR: push failed again after reconcile -- not pushing"
380
+ return 1
381
+ }
382
+
383
+ cmd_integrate() {
384
+ local task_id="$1"
385
+ local commit_msg="${2:-merge: $task_id}"
386
+ validate_task_id "$task_id"
387
+
388
+ local branch
389
+ branch=$(resolve_branch "$task_id")
390
+
391
+ cd "$REPO"
392
+
393
+ # Preflight: the integration target must be clean
394
+ require_clean_target || exit 4
395
+
396
+ # Verify task branch exists and has commits ahead of the integration target
397
+ if ! git rev-parse --verify "$branch" >/dev/null 2>&1; then
398
+ echo "ERROR: branch $branch does not exist"
399
+ return 1
400
+ fi
401
+
402
+ local target
403
+ target=$(integration_target) || exit 1
404
+
405
+ # Staged-base guard (2026-09-19 REVIEW): the integration line must still
406
+ # descend from the staged publish base. Catches a stray checkout that
407
+ # redirected the line (room #21 J3: `git checkout main` abandoned the
408
+ # detached reviewed-SHA ancestry) BEFORE the merge lands on the wrong
409
+ # line — Publish's ancestry check is the backstop, not the guard.
410
+ # CREW_STAGED_BASE is ferried by the workflow from get-provenance; empty
411
+ # means no provenance is stamped and the check is skipped.
412
+ if [ -n "${CREW_STAGED_BASE:-}" ]; then
413
+ if ! git merge-base --is-ancestor "$CREW_STAGED_BASE" HEAD 2>/dev/null; then
414
+ echo "STAGED_BASE_MISMATCH: staged base $CREW_STAGED_BASE is not an ancestor of HEAD $(git rev-parse HEAD 2>/dev/null) -- the integration line was redirected; refusing to merge"
415
+ return 1
416
+ fi
417
+ echo "STAGED_BASE_OK: $CREW_STAGED_BASE is an ancestor of HEAD"
418
+ fi
419
+
420
+ local ahead
421
+ ahead=$(git rev-list --count "$target..$branch")
422
+ if [ "$ahead" -eq 0 ]; then
423
+ # Merged-but-unpushed recovery (2026-09-19 REVIEW): a prior attempt's
424
+ # merge landed but its push never completed. Take the lock and push now
425
+ # instead of reporting MERGED_EMPTY — otherwise the retry loop parks
426
+ # the task with the work stranded and no path ever pushes it.
427
+ # The check is ancestor-based, not equality-based: if a prior attempt's
428
+ # push failed, do_push's retry path may have added a reconcile merge
429
+ # on top of the recorded commit before failing again. Equality would
430
+ # miss that and fall through to MERGED_EMPTY — reporting PASS while the
431
+ # push never happened.
432
+ local record_file="$MERGE_RECORDS_DIR/$task_id"
433
+ local recorded_commit=""
434
+ if [ -f "$record_file" ]; then
435
+ recorded_commit=$(grep "^merge_commit=" "$record_file" | tail -1 | cut -d= -f2-)
436
+ fi
437
+ local live_head
438
+ live_head=$(git rev-parse HEAD)
439
+ if [ -n "$recorded_commit" ] && git merge-base --is-ancestor "$recorded_commit" "$live_head" 2>/dev/null; then
440
+ acquire_merge_lock "$task_id" || exit 2
441
+ if do_push "$task_id"; then
442
+ echo "MERGED: $recorded_commit (recovered: merge landed in a prior attempt; push completed now)"
443
+ else
444
+ echo "ERROR: push recovery failed for previously-merged $recorded_commit"
445
+ return 1
446
+ fi
447
+ echo "LOCK: held by $task_id — release after deploy with post-deploy"
448
+ return 0
449
+ fi
450
+ # Approved empty diff: the deliverable was runtime state (cron,
451
+ # scheduler, dashboard config) or an already-merged sha the workflow
452
+ # verified — not a repo change. Nothing to merge, nothing to serialize —
453
+ # the merge lock is intentionally not taken.
454
+ echo "MERGED_EMPTY: $branch has no commits ahead of $target — runtime-state deliverable or verified already-merged sha, nothing to merge"
455
+ return 0
456
+ fi
457
+
458
+ acquire_merge_lock "$task_id" || exit 2
316
459
 
317
- # Merge
460
+ # Merge into the integration target: the repo's current checkout. No
461
+ # `git checkout` anywhere in this flow — on a detached HEAD (clean-room
462
+ # staging) the merge advances the detached HEAD itself, preserving the
463
+ # publish-base ancestry Publish verifies.
318
464
  if ! git merge --no-ff "$branch" -m "$commit_msg" 2>&1; then
319
465
  echo "CONFLICT: merge failed — aborting"
320
466
  git merge --abort 2>/dev/null || true
@@ -322,24 +468,28 @@ cmd_integrate() {
322
468
  fi
323
469
 
324
470
  # Reconcile with the remote while the merge lock is still held. Another
325
- # writer (a second crew instance, direct pushes) can land commits on
326
- # origin/main outside this lock; pushing a stale main then fails
327
- # non-fast-forward and the Integrate phase fails closed. Fetch and merge
328
- # origin/main now so the agent's subsequent push is a fast-forward.
471
+ # writer (a second crew instance, direct pushes) can land commits on the
472
+ # remote branch outside this lock; pushing a stale branch then fails and
473
+ # the Integrate phase fails closed. Fetch and merge the remote branch now
474
+ # so the subsequent push is a fast-forward.
329
475
  # Fail-soft when there is no remote; fail closed on a real conflict.
330
- if git rev-parse --verify "origin/main" >/dev/null 2>&1; then
331
- if git fetch origin main 2>&1; then
332
- if ! git merge --no-edit "origin/main" -m "merge: reconcile with origin/main before push ($task_id)" 2>&1; then
333
- echo "CONFLICT: origin/main diverged with conflicting changes — aborting"
476
+ # A detached HEAD is local by design (no remote ref is claimed for the
477
+ # detached line), so there is nothing to reconcile.
478
+ if [ "$target" = "HEAD" ]; then
479
+ echo "NO_REMOTE_RECONCILE: detached HEAD -- the integration line is local by design; skipping origin reconcile"
480
+ elif git rev-parse --verify "origin/$target" >/dev/null 2>&1; then
481
+ if git fetch origin "$target" 2>&1; then
482
+ if ! git merge --no-edit "origin/$target" -m "merge: reconcile with origin/$target before push ($task_id)" 2>&1; then
483
+ echo "CONFLICT: origin/$target diverged with conflicting changes — aborting"
334
484
  git merge --abort 2>/dev/null || true
335
485
  exit 3
336
486
  fi
337
- echo "RECONCILED: merged origin/main into main"
487
+ echo "RECONCILED: merged origin/$target into $target"
338
488
  else
339
489
  echo "WARNING: git fetch origin failed — pushing without remote reconcile; the push step may report non-fast-forward"
340
490
  fi
341
491
  else
342
- echo "NO_REMOTE: no origin/main — skipping remote reconcile"
492
+ echo "NO_REMOTE: no origin/$target — skipping remote reconcile"
343
493
  fi
344
494
 
345
495
  local merged_commit
@@ -348,13 +498,23 @@ cmd_integrate() {
348
498
 
349
499
  # Record the merge commit (merge-lease fix, 2026-09-12): if the branch is
350
500
  # later reclaimed (e.g. under an expired lease), verify-merge can still
351
- # confirm the merge landed from this record. Append-only, on persistent disk.
501
+ # confirm the merge landed from this record. Append-only, on persistent
502
+ # disk. One atomic printf (not three appends — a torn write against a
503
+ # concurrent reader is benign today but not worth the risk), and cut
504
+ # -d= -f2- on read so values containing `=` survive.
352
505
  mkdir -p "$MERGE_RECORDS_DIR"
353
- {
354
- echo "merge_commit=$merged_commit"
355
- echo "merged_at=$(date -u +%Y-%m-%dT%H:%M:%SZ)"
356
- } >> "$MERGE_RECORDS_DIR/$task_id"
357
- echo "RECORDED: merge commit $merged_commit in $MERGE_RECORDS_DIR/$task_id"
506
+ printf 'merge_commit=%s\nintegration_target=%s\nmerged_at=%s\n' \
507
+ "$merged_commit" "$target" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
508
+ >> "$MERGE_RECORDS_DIR/$task_id"
509
+ echo "RECORDED: merge commit $merged_commit (target $target) in $MERGE_RECORDS_DIR/$task_id"
510
+
511
+ # Push inline while the lock is held — no agent round-trip (the old STEP 2
512
+ # agent run added only a lock refresh). R5's manual-conflict path uses the
513
+ # push-target command instead.
514
+ if ! do_push "$task_id"; then
515
+ echo "ERROR: push failed after merge $merged_commit -- merge is recorded; retry will recover the push"
516
+ return 1
517
+ fi
358
518
 
359
519
  echo "LOCK: held by $task_id — release after deploy with post-deploy"
360
520
  }
@@ -372,17 +532,38 @@ cmd_verify_merge() {
372
532
 
373
533
  cd "$REPO"
374
534
 
535
+ # The integration target is always the repo's current checkout,
536
+ # re-resolved NOW — never the merge record's word for it. (2026-09-19
537
+ # REVIEW: the old code read the target name from the record and, for
538
+ # target=HEAD, used the record's merge_commit as the tip — so both sides
539
+ # of the ancestry check came from the same record and is-ancestor(X, X)
540
+ # certified merges no longer on the line. The tip below is always live.)
541
+ local target
542
+ target=$(integration_target) || return 1
543
+ local target_tip
544
+ if [ "$target" = "HEAD" ]; then
545
+ target_tip=$(git rev-parse HEAD 2>/dev/null || echo "")
546
+ else
547
+ target_tip=$(git rev-parse --verify "$target" 2>/dev/null || echo "")
548
+ fi
549
+ if [ -z "$target_tip" ]; then
550
+ echo "ERROR: cannot resolve tip of integration target $target"
551
+ return 1
552
+ fi
553
+
554
+ local record_file="$MERGE_RECORDS_DIR/$task_id"
555
+
375
556
  # If the branch is gone, fall back to the merge record (merge-lease fix,
376
557
  # 2026-09-12): the branch may have been reclaimed under an expired lease
377
558
  # after the merge landed. The record is trusted only if the recorded
378
- # commit is actually an ancestor of main.
559
+ # commit is actually an ancestor of the integration target's LIVE tip —
560
+ # never the record's own word for the tip.
379
561
  if ! git rev-parse --verify "$branch" >/dev/null 2>&1; then
380
- local record_file="$MERGE_RECORDS_DIR/$task_id"
381
562
  if [ -f "$record_file" ]; then
382
563
  local recorded_commit
383
- recorded_commit=$(grep "^merge_commit=" "$record_file" | tail -1 | cut -d= -f2)
384
- if [ -n "$recorded_commit" ] && git merge-base --is-ancestor "$recorded_commit" main 2>/dev/null; then
385
- echo "VERIFIED: branch $branch gone but recorded merge commit $recorded_commit is an ancestor of main — merge landed"
564
+ recorded_commit=$(grep "^merge_commit=" "$record_file" | tail -1 | cut -d= -f2-)
565
+ if [ -n "$recorded_commit" ] && git merge-base --is-ancestor "$recorded_commit" "$target_tip" 2>/dev/null; then
566
+ echo "VERIFIED: branch $branch gone but recorded merge commit $recorded_commit is an ancestor of $target ($target_tip) — merge landed"
386
567
  return 0
387
568
  fi
388
569
  fi
@@ -393,17 +574,18 @@ cmd_verify_merge() {
393
574
  local tip
394
575
  tip=$(git rev-parse "$branch")
395
576
 
396
- # The merge landed iff the task branch tip is an ancestor of main. A
397
- # genuine empty-diff Integrate (MERGED_EMPTY: no commits ahead of main)
398
- # verifies vacuously — the tip is then main itself or behind it.
399
- if git merge-base --is-ancestor "$branch" main 2>/dev/null; then
400
- echo "VERIFIED: $branch tip $tip is an ancestor of main — merge landed"
577
+ # The merge landed iff the task branch tip is an ancestor of the
578
+ # integration target's tip. A genuine empty-diff Integrate (MERGED_EMPTY:
579
+ # no commits ahead of the target) verifies vacuously — the tip is then
580
+ # the target tip itself or behind it.
581
+ if git merge-base --is-ancestor "$branch" "$target_tip" 2>/dev/null; then
582
+ echo "VERIFIED: $branch tip $tip is an ancestor of $target ($target_tip) — merge landed"
401
583
  return 0
402
584
  fi
403
585
 
404
586
  local ahead
405
- ahead=$(git rev-list --count "main..$branch")
406
- echo "NOT_MERGED: $branch tip $tip is not an ancestor of main — $ahead commit(s) still ahead of main"
587
+ ahead=$(git rev-list --count "$target..$branch" 2>/dev/null || echo "?")
588
+ echo "NOT_MERGED: $branch tip $tip is not an ancestor of $target — $ahead commit(s) still ahead of $target"
407
589
  return 1
408
590
  }
409
591
 
@@ -412,13 +594,17 @@ cmd_post_deploy() {
412
594
  validate_task_id "$task_id"
413
595
 
414
596
  cd "$REPO"
415
- git checkout main 2>/dev/null
597
+ # NOTE: no `git checkout` here, ever. Builder leftovers are committed onto
598
+ # the integration target (the current checkout) — checking out main would
599
+ # abandon a detached-HEAD room line exactly the way room #21's Integrate
600
+ # did, breaking provenance for the next journey in this repo.
416
601
 
417
602
  # Commit any changes the artifact builder left behind.
418
603
  # The crew's own .worktrees/ dir (merge lock, registry) is crew-owned
419
604
  # state, never user content — unstage it after the add so a stray lock
420
- # file can never be committed into main (J1 canary: .merge-lock landed
421
- # in main via add -A). Never use an all-negative pathspec here: on git
605
+ # file can never be committed into the integration target (J1 canary:
606
+ # .merge-lock landed in main via add -A). Never use an all-negative
607
+ # pathspec here: on git
422
608
  # 2.43.0 `git add -A -- ':!.worktrees/'` still exits 1 against the
423
609
  # crew's own gitignored .worktrees/ (room #15, J3), and set -e then
424
610
  # kills post-deploy before "DEPLOYED:". A plain add never errors on
@@ -466,7 +652,7 @@ cmd_post_deploy() {
466
652
  # merge lock is a shared resource, so its release is unconditional and
467
653
  # forgiving (only the holder can release; anyone else is a no-op). The
468
654
  # worktree and branch are reclaimed ONLY when the task branch is fully
469
- # merged into main — then they are redundant and safe to drop. Anything
655
+ # merged into the integration target — then they are redundant and safe to drop. Anything
470
656
  # else is preserved for the human by design: parked work awaiting
471
657
  # judgment must survive its run. Never force-removes a dirty worktree:
472
658
  # uncommitted work is reported loudly, never silently destroyed.
@@ -489,9 +675,15 @@ cmd_terminal_cleanup() {
489
675
  echo "CLEANED: false"
490
676
  return 0
491
677
  fi
492
- if ! git merge-base --is-ancestor "$branch" main 2>/dev/null; then
678
+ local target
679
+ if ! target=$(integration_target); then
680
+ echo "PRESERVED: cannot resolve the integration target — leaving branch for human"
681
+ echo "CLEANED: false"
682
+ return 0
683
+ fi
684
+ if ! git merge-base --is-ancestor "$branch" "$target" 2>/dev/null; then
493
685
  local ahead
494
- ahead=$(git rev-list --count "main..$branch" 2>/dev/null || echo "?")
686
+ ahead=$(git rev-list --count "$target..$branch" 2>/dev/null || echo "?")
495
687
  echo "PRESERVED: $branch holds $ahead unmerged commit(s) — left for human"
496
688
  echo "CLEANED: false"
497
689
  return 0
@@ -591,12 +783,14 @@ cmd_status() {
591
783
 
592
784
  # Branch existence and merge status
593
785
  if git rev-parse --verify "$branch" >/dev/null 2>&1; then
786
+ local target
787
+ target=$(integration_target 2>/dev/null || echo "unresolvable")
594
788
  local ahead behind
595
- ahead=$(git rev-list --count "main..$branch" 2>/dev/null || echo "?")
596
- behind=$(git rev-list --count "$branch..main" 2>/dev/null || echo "?")
597
- echo "Branch: $branch (${ahead} ahead, ${behind} behind main)"
789
+ ahead=$(git rev-list --count "$target..$branch" 2>/dev/null || echo "?")
790
+ behind=$(git rev-list --count "$branch..$target" 2>/dev/null || echo "?")
791
+ echo "Branch: $branch (${ahead} ahead, ${behind} behind $target)"
598
792
  local merged
599
- merged=$(git branch --merged main 2>/dev/null | sed 's/^[* +]*//' | grep -Fx "$branch" || true)
793
+ merged=$(git branch --merged "$target" 2>/dev/null | sed 's/^[* +]*//' | grep -Fx "$branch" || true)
600
794
  [ -n "$merged" ] && echo "Merged: yes" || echo "Merged: no"
601
795
  else
602
796
  echo "Branch: not found"
@@ -633,6 +827,54 @@ cmd_merge_record() {
633
827
  fi
634
828
  }
635
829
 
830
+ # Agent-side one-liner: prints the integration target (branch name, or
831
+ # "HEAD" when detached). Workflow prompts resolve "main" language through
832
+ # this instead of assuming a branch.
833
+ cmd_integration_target() {
834
+ integration_target
835
+ }
836
+
837
+ # Deterministic push of the integration target. Thin wrapper over do_push
838
+ # for the R5 manual-conflict path (re-running `integrate` there misfires on
839
+ # ahead=0 -> MERGED_EMPTY, so the manually-merged line still needs a
840
+ # deterministic push). Refreshes the merge lock first: a slow push must not
841
+ # let the lease expire under us. Do not push without the lock.
842
+ #
843
+ # Distinguishes "nothing merged" from "lock lost" (2026-09-19 REVIEW): after
844
+ # a MERGED_EMPTY integrate there is no merge record and no lock — an agent
845
+ # that runs push-target anyway gets a loud skip, not a false "lock lost".
846
+ cmd_push_target() {
847
+ local task_id="$1"
848
+ validate_task_id "$task_id"
849
+
850
+ cd "$REPO"
851
+
852
+ local record_file="$MERGE_RECORDS_DIR/$task_id"
853
+ if [ ! -f "$record_file" ]; then
854
+ # No merge record: either MERGED_EMPTY (nothing to push) or the R5
855
+ # manual path (the lock is still held from the conflicted integrate).
856
+ # The lock tells them apart.
857
+ if cmd_refresh_lock "$task_id" >/dev/null 2>&1; then
858
+ echo "LOCK: refreshed for push (manual-merge path)"
859
+ do_push "$task_id"
860
+ return $?
861
+ fi
862
+ echo "PUSH_SKIPPED: no merge record for $task_id and no lock held -- nothing was merged, nothing to push"
863
+ return 0
864
+ fi
865
+
866
+ # Refresh the merge lock first: a slow push must not let the lease expire
867
+ # under us. Do not push without the lock.
868
+ if ! cmd_refresh_lock "$task_id" >/dev/null 2>&1; then
869
+ echo "ERROR: merge lock lost before push -- not pushing without the lock"
870
+ return 1
871
+ fi
872
+ echo "LOCK: refreshed for push"
873
+
874
+ do_push "$task_id"
875
+ return $?
876
+ }
877
+
636
878
  # --- dispatch ---
637
879
 
638
880
  cmd="${1:-}"
@@ -645,6 +887,8 @@ case "$cmd" in
645
887
  inspect) cmd_inspect "${1:?task_id required}" ;;
646
888
  integrate) cmd_integrate "${1:?task_id required}" "${2:-}" ;;
647
889
  verify-merge) cmd_verify_merge "${1:?task_id required}" ;;
890
+ integration-target) cmd_integration_target ;;
891
+ push-target) cmd_push_target "${1:?task_id required}" ;;
648
892
  post-deploy) cmd_post_deploy "${1:?task_id required}" ;;
649
893
  terminal-cleanup) cmd_terminal_cleanup "${1:?task_id required}" ;;
650
894
  cleanup) cmd_cleanup "${1:?task_id required}" ;;
@@ -654,7 +898,7 @@ case "$cmd" in
654
898
  merge-record) cmd_merge_record "${1:?task_id required}" ;;
655
899
  *)
656
900
  echo "Usage: worktree-lifecycle.sh <command> [args]"
657
- echo "Commands: validate, prepare, resolve-branch, inspect, integrate, verify-merge, post-deploy, terminal-cleanup, cleanup, status, refresh-lock, lock-status, merge-record"
901
+ echo "Commands: validate, prepare, resolve-branch, inspect, integrate, integration-target, push-target, verify-merge, post-deploy, terminal-cleanup, cleanup, status, refresh-lock, lock-status, merge-record"
658
902
  exit 1
659
903
  ;;
660
904
  esac
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "muse-crew",
3
- "version": "0.14.0",
3
+ "version": "0.14.2",
4
4
  "description": "Opinionated orchestration for Muse — workflows, identities, and tooling for autonomous software development.",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,