muse-crew 0.14.1 → 0.14.3

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,48 @@ 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
+ # Resolve the remote branch a detached HEAD pushes to: the remote's default
154
+ # branch (origin/HEAD). Prints the bare branch name (no origin/ prefix).
155
+ # Attached checkouts never need this (their target IS the branch name).
156
+ # Fails when the destination is genuinely unknowable -- the caller fails
157
+ # closed (never a silent skip, never a refusal of a knowable push).
158
+ # (2026-09-19 detached-HEAD audit REDO: the old code treated detached+remote
159
+ # as PUSH_SKIPPED->PASS "by policy", hiding merged work that never reached
160
+ # the remote. The contract is explicit HEAD:<destination> semantics.)
161
+ resolve_push_destination() {
162
+ cd "$REPO"
163
+ local sym
164
+ sym=$(git symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null) || {
165
+ echo "ERROR: detached HEAD with an origin remote, but origin/HEAD is not set -- the push destination is genuinely unknowable; set it (git remote set-head origin -a) and retry -- not pushing"
166
+ return 1
167
+ }
168
+ printf '%s\n' "${sym#origin/}"
169
+ }
170
+
171
+ # (2026-09-19 REVIEW: the old integration_tip() helper is deleted — it had
172
+ # zero call sites and cmd_verify_merge reimplemented tip resolution inline
173
+ # with different semantics, a drift hazard. Tip resolution lives in exactly
174
+ # two places now: cmd_integrate/cmd_push_target resolve the target name via
175
+ # integration_target(); cmd_verify_merge resolves the live tip directly.)
176
+
177
+ require_clean_target() {
178
+ cd "$REPO"
179
+ # NOTE: no `git checkout` here, ever. The integration target is the repo's
180
+ # current checkout (branch or detached HEAD); moving it would break the
181
+ # publish-base provenance the workflow stamped at task start.
141
182
  # The crew's own worktree dir is crew state, not user changes: exclude it
142
183
  # from the cleanliness check. (2026-09-17: 0.9.0 failed closed on every
143
184
  # target repo whose .gitignore doesn't cover .worktrees/ — which is every
@@ -147,7 +188,9 @@ require_clean_main() {
147
188
  local dirty
148
189
  dirty=$(git status --porcelain 2>/dev/null | grep -vE "^.{3}$wt_esc(/|$)" | wc -l)
149
190
  if [ "$dirty" -gt 0 ]; then
150
- echo "ERROR: main has $dirty uncommitted changes"
191
+ local target
192
+ target=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "?")
193
+ echo "ERROR: integration target $target has $dirty uncommitted changes"
151
194
  git status --short
152
195
  return 1
153
196
  fi
@@ -194,8 +237,8 @@ cmd_prepare() {
194
237
  return 0
195
238
  fi
196
239
 
197
- # Preflight: main must be clean — prepare fails closed.
198
- require_clean_main || exit 4
240
+ # Preflight: the integration target must be clean — prepare fails closed.
241
+ require_clean_target || exit 4
199
242
 
200
243
  local base_commit
201
244
  base_commit=$(git rev-parse HEAD)
@@ -242,52 +285,30 @@ cmd_inspect() {
242
285
  tip=$(git rev-parse "$branch")
243
286
  echo "TIP: $tip"
244
287
  echo ""
245
- echo "=== Commits ahead of main ==="
246
- git log --oneline "main..$branch"
288
+ local target
289
+ target=$(integration_target) || return 1
290
+ echo "=== Integration target: $target ==="
247
291
  echo ""
248
- echo "=== Diff: main...$branch ==="
249
- git diff --stat "main...$branch"
292
+ echo "=== Commits ahead of $target ==="
293
+ git log --oneline "$target..$branch"
250
294
  echo ""
251
- git diff "main...$branch"
295
+ echo "=== Diff: $target...$branch ==="
296
+ git diff --stat "$target...$branch"
297
+ echo ""
298
+ git diff "$target...$branch"
252
299
  }
253
300
 
254
- cmd_integrate() {
301
+ # Acquire the merge lock with bounded backoff (serializes version assignment
302
+ # across concurrent runs). The holder is the opaque task+run identity from
303
+ # WORKFLOW_RUN_ID — never a PID (bug 2fc8f52f: short-lived agent PIDs made
304
+ # every concurrent acquire take the stale path). Another task mid
305
+ # Integrate/Publish is normal under simultaneity>1: bounded backoff (30s
306
+ # sleeps, 10min cap, overridable via MERGE_LOCK_RETRY_SECS /
307
+ # MERGE_LOCK_RETRY_CAP_SECS), then give up with LOCK_HELD so the workflow
308
+ # records the failure with a reason (dispatcher retries under its cap, parks
309
+ # after the cap). Returns 2 on LOCK_HELD / LOCK_ERROR; the caller exits.
310
+ acquire_merge_lock() {
255
311
  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
312
  local holder="${WORKFLOW_RUN_ID:-$task_id}"
292
313
  local retry_secs="${MERGE_LOCK_RETRY_SECS:-30}"
293
314
  local retry_cap="${MERGE_LOCK_RETRY_CAP_SECS:-600}"
@@ -296,25 +317,176 @@ cmd_integrate() {
296
317
  while true; do
297
318
  if lock_result=$("$MERGE_LOCK" acquire "$task_id" "$holder"); then
298
319
  echo "$lock_result"
299
- break
320
+ return 0
300
321
  fi
301
322
  case "$lock_result" in
302
323
  HELD*)
303
324
  if [ "$waited" -ge "$retry_cap" ]; then
304
325
  echo "LOCK_HELD: $lock_result (waited ${waited}s of ${retry_cap}s backoff)"
305
- exit 2
326
+ return 2
306
327
  fi
307
328
  sleep "$retry_secs"
308
329
  waited=$((waited + retry_secs))
309
330
  ;;
310
331
  *)
311
332
  echo "LOCK_ERROR: $lock_result"
312
- exit 2
333
+ return 2
313
334
  ;;
314
335
  esac
315
336
  done
337
+ }
338
+
339
+ # Deterministic push of the integration target. Called inline by
340
+ # cmd_integrate on the main path (the lock was just acquired there — no
341
+ # refresh needed) and by cmd_push_target for the R5 manual-conflict path
342
+ # (which refreshes the lock first). Never force-pushes.
343
+ #
344
+ # The caller must hold the merge lock. Prints one of PUSHED /
345
+ # NO_REMOTE_PUSH and returns 0, or prints PUSH_FAILED / CONFLICT / ERROR
346
+ # and returns 1 as labeled. (PUSH_SKIPPED belongs to cmd_push_target's
347
+ # nothing-merged path only -- F-R3.) The push
348
+ # refspec is always explicit: a branch pushes as <branch>, a detached HEAD
349
+ # pushes as HEAD:<destination> where the destination is the remote's
350
+ # default branch resolved by resolve_push_destination() -- never a silent
351
+ # skip (2026-09-19 detached-HEAD audit REDO: PUSH_SKIPPED->PASS on
352
+ # detached+remote hid merged work that never reached the remote).
353
+ do_push() {
354
+ local task_id="$1"
355
+ cd "$REPO"
356
+
357
+ local target
358
+ target=$(integration_target) || return 1
359
+
360
+ # No remote configured: fail soft, loudly. (2026-09-19 REVIEW: the old
361
+ # code ran `git push origin` unconditionally, which exits 128 with no
362
+ # origin, got mislabeled PUSH_REJECTED, and failed the phase — while
363
+ # cmd_integrate on the same repo had already reported MERGED with a
364
+ # fail-soft NO_REMOTE. A branch-based repo without a remote could never
365
+ # pass Integrate.)
366
+ if ! git remote get-url origin >/dev/null 2>&1; then
367
+ echo "NO_REMOTE_PUSH: no origin remote configured -- nothing to push"
368
+ return 0
369
+ fi
370
+
371
+ # Attached: branch name; detached: remote default branch via resolve_push_destination() (see header).
372
+ local dest refspec
373
+ if [ "$target" = "HEAD" ]; then
374
+ # Surface the resolver's reason: the caller cannot remediate a swallowed
375
+ # failure, and the retry-promise below must not lie about it (F-R1).
376
+ dest=$(resolve_push_destination) || { echo "$dest"; return 1; }
377
+ refspec="HEAD:$dest"
378
+ else
379
+ dest="$target"
380
+ refspec="$target"
381
+ fi
382
+
383
+ if git push origin "$refspec" 2>&1; then
384
+ echo "PUSHED: origin/$dest (refspec $refspec)"
385
+ return 0
386
+ fi
387
+
388
+ # Push failed. Do not assert a cause (the old code labeled every failure
389
+ # "non-fast-forward" — auth errors got the same label). Recover
390
+ # mechanically while the lock is held: fetch, merge, retry once.
391
+ echo "PUSH_FAILED: push did not complete -- reconciling once while the lock is held"
392
+ if ! git fetch origin "$dest" 2>&1; then
393
+ echo "ERROR: git fetch origin $dest failed -- push not completed"
394
+ return 1
395
+ fi
396
+ if ! git merge --no-edit "origin/$dest" -m "merge: push-time reconcile ($task_id)" 2>&1; then
397
+ echo "CONFLICT: origin/$dest diverged with conflicting changes -- aborting"
398
+ git merge --abort 2>/dev/null || true
399
+ return 1
400
+ fi
401
+ if git push origin "$refspec" 2>&1; then
402
+ echo "PUSHED: origin/$dest (refspec $refspec, after reconcile)"
403
+ return 0
404
+ fi
405
+ echo "ERROR: push failed again after reconcile -- not pushing"
406
+ return 1
407
+ }
408
+
409
+ cmd_integrate() {
410
+ local task_id="$1"
411
+ local commit_msg="${2:-merge: $task_id}"
412
+ validate_task_id "$task_id"
413
+
414
+ local branch
415
+ branch=$(resolve_branch "$task_id")
416
+
417
+ cd "$REPO"
418
+
419
+ # Preflight: the integration target must be clean
420
+ require_clean_target || exit 4
421
+
422
+ # Verify task branch exists and has commits ahead of the integration target
423
+ if ! git rev-parse --verify "$branch" >/dev/null 2>&1; then
424
+ echo "ERROR: branch $branch does not exist"
425
+ return 1
426
+ fi
427
+
428
+ local target
429
+ target=$(integration_target) || exit 1
430
+
431
+ # Staged-base guard (2026-09-19 REVIEW): the integration line must still
432
+ # descend from the staged publish base. Catches a stray checkout that
433
+ # redirected the line (room #21 J3: `git checkout main` abandoned the
434
+ # detached reviewed-SHA ancestry) BEFORE the merge lands on the wrong
435
+ # line — Publish's ancestry check is the backstop, not the guard.
436
+ # CREW_STAGED_BASE is ferried by the workflow from get-provenance; empty
437
+ # means no provenance is stamped and the check is skipped.
438
+ if [ -n "${CREW_STAGED_BASE:-}" ]; then
439
+ if ! git merge-base --is-ancestor "$CREW_STAGED_BASE" HEAD 2>/dev/null; then
440
+ 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"
441
+ return 1
442
+ fi
443
+ echo "STAGED_BASE_OK: $CREW_STAGED_BASE is an ancestor of HEAD"
444
+ fi
445
+
446
+ local ahead
447
+ ahead=$(git rev-list --count "$target..$branch")
448
+ if [ "$ahead" -eq 0 ]; then
449
+ # Merged-but-unpushed recovery (2026-09-19 REVIEW): a prior attempt's
450
+ # merge landed but its push never completed. Take the lock and push now
451
+ # instead of reporting MERGED_EMPTY — otherwise the retry loop parks
452
+ # the task with the work stranded and no path ever pushes it.
453
+ # The check is ancestor-based, not equality-based: if a prior attempt's
454
+ # push failed, do_push's retry path may have added a reconcile merge
455
+ # on top of the recorded commit before failing again. Equality would
456
+ # miss that and fall through to MERGED_EMPTY — reporting PASS while the
457
+ # push never happened.
458
+ local record_file="$MERGE_RECORDS_DIR/$task_id"
459
+ local recorded_commit=""
460
+ if [ -f "$record_file" ]; then
461
+ recorded_commit=$(grep "^merge_commit=" "$record_file" | tail -1 | cut -d= -f2-)
462
+ fi
463
+ local live_head
464
+ live_head=$(git rev-parse HEAD)
465
+ if [ -n "$recorded_commit" ] && git merge-base --is-ancestor "$recorded_commit" "$live_head" 2>/dev/null; then
466
+ acquire_merge_lock "$task_id" || exit 2
467
+ if do_push "$task_id"; then
468
+ echo "MERGED: $recorded_commit (recovered: merge landed in a prior attempt; push completed now)"
469
+ else
470
+ echo "ERROR: push recovery failed for previously-merged $recorded_commit"
471
+ return 1
472
+ fi
473
+ echo "LOCK: held by $task_id — release after deploy with post-deploy"
474
+ return 0
475
+ fi
476
+ # Approved empty diff: the deliverable was runtime state (cron,
477
+ # scheduler, dashboard config) or an already-merged sha the workflow
478
+ # verified — not a repo change. Nothing to merge, nothing to serialize —
479
+ # the merge lock is intentionally not taken.
480
+ echo "MERGED_EMPTY: $branch has no commits ahead of $target — runtime-state deliverable or verified already-merged sha, nothing to merge"
481
+ return 0
482
+ fi
483
+
484
+ acquire_merge_lock "$task_id" || exit 2
316
485
 
317
- # Merge
486
+ # Merge into the integration target: the repo's current checkout. No
487
+ # `git checkout` anywhere in this flow — on a detached HEAD (clean-room
488
+ # staging) the merge advances the detached HEAD itself, preserving the
489
+ # publish-base ancestry Publish verifies.
318
490
  if ! git merge --no-ff "$branch" -m "$commit_msg" 2>&1; then
319
491
  echo "CONFLICT: merge failed — aborting"
320
492
  git merge --abort 2>/dev/null || true
@@ -322,24 +494,30 @@ cmd_integrate() {
322
494
  fi
323
495
 
324
496
  # 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.
497
+ # writer (a second crew instance, direct pushes) can land commits on the
498
+ # remote branch outside this lock; pushing a stale branch then fails and
499
+ # the Integrate phase fails closed. Fetch and merge the remote branch now
500
+ # so the subsequent push is a fast-forward.
329
501
  # 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"
502
+ # Detached: there is no static branch target to reconcile against inline;
503
+ # do_push resolves the destination explicitly and reconciles on push
504
+ # failure, so the inline reconcile is skipped here (F-R2 -- the old
505
+ # "local by design" rationale is the rejected 0.14.2 policy).
506
+ if [ "$target" = "HEAD" ]; then
507
+ echo "NO_REMOTE_RECONCILE: detached HEAD -- destination resolved explicitly by do_push, skipping inline reconcile"
508
+ elif git rev-parse --verify "origin/$target" >/dev/null 2>&1; then
509
+ if git fetch origin "$target" 2>&1; then
510
+ if ! git merge --no-edit "origin/$target" -m "merge: reconcile with origin/$target before push ($task_id)" 2>&1; then
511
+ echo "CONFLICT: origin/$target diverged with conflicting changes — aborting"
334
512
  git merge --abort 2>/dev/null || true
335
513
  exit 3
336
514
  fi
337
- echo "RECONCILED: merged origin/main into main"
515
+ echo "RECONCILED: merged origin/$target into $target"
338
516
  else
339
517
  echo "WARNING: git fetch origin failed — pushing without remote reconcile; the push step may report non-fast-forward"
340
518
  fi
341
519
  else
342
- echo "NO_REMOTE: no origin/main — skipping remote reconcile"
520
+ echo "NO_REMOTE: no origin/$target — skipping remote reconcile"
343
521
  fi
344
522
 
345
523
  local merged_commit
@@ -348,13 +526,23 @@ cmd_integrate() {
348
526
 
349
527
  # Record the merge commit (merge-lease fix, 2026-09-12): if the branch is
350
528
  # 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.
529
+ # confirm the merge landed from this record. Append-only, on persistent
530
+ # disk. One atomic printf (not three appends — a torn write against a
531
+ # concurrent reader is benign today but not worth the risk), and cut
532
+ # -d= -f2- on read so values containing `=` survive.
352
533
  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"
534
+ printf 'merge_commit=%s\nintegration_target=%s\nmerged_at=%s\n' \
535
+ "$merged_commit" "$target" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
536
+ >> "$MERGE_RECORDS_DIR/$task_id"
537
+ echo "RECORDED: merge commit $merged_commit (target $target) in $MERGE_RECORDS_DIR/$task_id"
538
+
539
+ # Push inline while the lock is held — no agent round-trip (the old STEP 2
540
+ # agent run added only a lock refresh). R5's manual-conflict path uses the
541
+ # push-target command instead.
542
+ if ! do_push "$task_id"; then
543
+ echo "ERROR: push failed after merge $merged_commit -- merge is recorded; a re-run of push-target retries transient failures, but an unknowable destination needs 'git remote set-head origin -a' (see the ERROR line above -- F-R1)"
544
+ return 1
545
+ fi
358
546
 
359
547
  echo "LOCK: held by $task_id — release after deploy with post-deploy"
360
548
  }
@@ -372,17 +560,38 @@ cmd_verify_merge() {
372
560
 
373
561
  cd "$REPO"
374
562
 
563
+ # The integration target is always the repo's current checkout,
564
+ # re-resolved NOW — never the merge record's word for it. (2026-09-19
565
+ # REVIEW: the old code read the target name from the record and, for
566
+ # target=HEAD, used the record's merge_commit as the tip — so both sides
567
+ # of the ancestry check came from the same record and is-ancestor(X, X)
568
+ # certified merges no longer on the line. The tip below is always live.)
569
+ local target
570
+ target=$(integration_target) || return 1
571
+ local target_tip
572
+ if [ "$target" = "HEAD" ]; then
573
+ target_tip=$(git rev-parse HEAD 2>/dev/null || echo "")
574
+ else
575
+ target_tip=$(git rev-parse --verify "$target" 2>/dev/null || echo "")
576
+ fi
577
+ if [ -z "$target_tip" ]; then
578
+ echo "ERROR: cannot resolve tip of integration target $target"
579
+ return 1
580
+ fi
581
+
582
+ local record_file="$MERGE_RECORDS_DIR/$task_id"
583
+
375
584
  # If the branch is gone, fall back to the merge record (merge-lease fix,
376
585
  # 2026-09-12): the branch may have been reclaimed under an expired lease
377
586
  # after the merge landed. The record is trusted only if the recorded
378
- # commit is actually an ancestor of main.
587
+ # commit is actually an ancestor of the integration target's LIVE tip —
588
+ # never the record's own word for the tip.
379
589
  if ! git rev-parse --verify "$branch" >/dev/null 2>&1; then
380
- local record_file="$MERGE_RECORDS_DIR/$task_id"
381
590
  if [ -f "$record_file" ]; then
382
591
  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"
592
+ recorded_commit=$(grep "^merge_commit=" "$record_file" | tail -1 | cut -d= -f2-)
593
+ if [ -n "$recorded_commit" ] && git merge-base --is-ancestor "$recorded_commit" "$target_tip" 2>/dev/null; then
594
+ echo "VERIFIED: branch $branch gone but recorded merge commit $recorded_commit is an ancestor of $target ($target_tip) — merge landed"
386
595
  return 0
387
596
  fi
388
597
  fi
@@ -393,17 +602,18 @@ cmd_verify_merge() {
393
602
  local tip
394
603
  tip=$(git rev-parse "$branch")
395
604
 
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"
605
+ # The merge landed iff the task branch tip is an ancestor of the
606
+ # integration target's tip. A genuine empty-diff Integrate (MERGED_EMPTY:
607
+ # no commits ahead of the target) verifies vacuously — the tip is then
608
+ # the target tip itself or behind it.
609
+ if git merge-base --is-ancestor "$branch" "$target_tip" 2>/dev/null; then
610
+ echo "VERIFIED: $branch tip $tip is an ancestor of $target ($target_tip) — merge landed"
401
611
  return 0
402
612
  fi
403
613
 
404
614
  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"
615
+ ahead=$(git rev-list --count "$target..$branch" 2>/dev/null || echo "?")
616
+ echo "NOT_MERGED: $branch tip $tip is not an ancestor of $target — $ahead commit(s) still ahead of $target"
407
617
  return 1
408
618
  }
409
619
 
@@ -412,13 +622,17 @@ cmd_post_deploy() {
412
622
  validate_task_id "$task_id"
413
623
 
414
624
  cd "$REPO"
415
- git checkout main 2>/dev/null
625
+ # NOTE: no `git checkout` here, ever. Builder leftovers are committed onto
626
+ # the integration target (the current checkout) — checking out main would
627
+ # abandon a detached-HEAD room line exactly the way room #21's Integrate
628
+ # did, breaking provenance for the next journey in this repo.
416
629
 
417
630
  # Commit any changes the artifact builder left behind.
418
631
  # The crew's own .worktrees/ dir (merge lock, registry) is crew-owned
419
632
  # 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
633
+ # file can never be committed into the integration target (J1 canary:
634
+ # .merge-lock landed in main via add -A). Never use an all-negative
635
+ # pathspec here: on git
422
636
  # 2.43.0 `git add -A -- ':!.worktrees/'` still exits 1 against the
423
637
  # crew's own gitignored .worktrees/ (room #15, J3), and set -e then
424
638
  # kills post-deploy before "DEPLOYED:". A plain add never errors on
@@ -466,7 +680,7 @@ cmd_post_deploy() {
466
680
  # merge lock is a shared resource, so its release is unconditional and
467
681
  # forgiving (only the holder can release; anyone else is a no-op). The
468
682
  # 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
683
+ # merged into the integration target — then they are redundant and safe to drop. Anything
470
684
  # else is preserved for the human by design: parked work awaiting
471
685
  # judgment must survive its run. Never force-removes a dirty worktree:
472
686
  # uncommitted work is reported loudly, never silently destroyed.
@@ -489,9 +703,15 @@ cmd_terminal_cleanup() {
489
703
  echo "CLEANED: false"
490
704
  return 0
491
705
  fi
492
- if ! git merge-base --is-ancestor "$branch" main 2>/dev/null; then
706
+ local target
707
+ if ! target=$(integration_target); then
708
+ echo "PRESERVED: cannot resolve the integration target — leaving branch for human"
709
+ echo "CLEANED: false"
710
+ return 0
711
+ fi
712
+ if ! git merge-base --is-ancestor "$branch" "$target" 2>/dev/null; then
493
713
  local ahead
494
- ahead=$(git rev-list --count "main..$branch" 2>/dev/null || echo "?")
714
+ ahead=$(git rev-list --count "$target..$branch" 2>/dev/null || echo "?")
495
715
  echo "PRESERVED: $branch holds $ahead unmerged commit(s) — left for human"
496
716
  echo "CLEANED: false"
497
717
  return 0
@@ -591,12 +811,14 @@ cmd_status() {
591
811
 
592
812
  # Branch existence and merge status
593
813
  if git rev-parse --verify "$branch" >/dev/null 2>&1; then
814
+ local target
815
+ target=$(integration_target 2>/dev/null || echo "unresolvable")
594
816
  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)"
817
+ ahead=$(git rev-list --count "$target..$branch" 2>/dev/null || echo "?")
818
+ behind=$(git rev-list --count "$branch..$target" 2>/dev/null || echo "?")
819
+ echo "Branch: $branch (${ahead} ahead, ${behind} behind $target)"
598
820
  local merged
599
- merged=$(git branch --merged main 2>/dev/null | sed 's/^[* +]*//' | grep -Fx "$branch" || true)
821
+ merged=$(git branch --merged "$target" 2>/dev/null | sed 's/^[* +]*//' | grep -Fx "$branch" || true)
600
822
  [ -n "$merged" ] && echo "Merged: yes" || echo "Merged: no"
601
823
  else
602
824
  echo "Branch: not found"
@@ -633,6 +855,67 @@ cmd_merge_record() {
633
855
  fi
634
856
  }
635
857
 
858
+ # Agent-side one-liner: prints the integration target (branch name, or
859
+ # "HEAD" when detached). Workflow prompts resolve "main" language through
860
+ # this instead of assuming a branch.
861
+ cmd_integration_target() {
862
+ integration_target
863
+ }
864
+
865
+ # Agent-side one-liner: prints the remote branch a push updates. Attached:
866
+ # the branch name. Detached: the resolved destination from
867
+ # resolve_push_destination() (fails when genuinely unknowable).
868
+ cmd_push_destination() {
869
+ local target
870
+ target=$(integration_target) || exit 1
871
+ if [ "$target" = "HEAD" ]; then
872
+ resolve_push_destination
873
+ else
874
+ printf '%s\n' "$target"
875
+ fi
876
+ }
877
+
878
+ # Deterministic push of the integration target. Thin wrapper over do_push
879
+ # for the R5 manual-conflict path (re-running `integrate` there misfires on
880
+ # ahead=0 -> MERGED_EMPTY, so the manually-merged line still needs a
881
+ # deterministic push). Refreshes the merge lock first: a slow push must not
882
+ # let the lease expire under us. Do not push without the lock.
883
+ #
884
+ # Distinguishes "nothing merged" from "lock lost" (2026-09-19 REVIEW): after
885
+ # a MERGED_EMPTY integrate there is no merge record and no lock — an agent
886
+ # that runs push-target anyway gets a loud skip, not a false "lock lost".
887
+ cmd_push_target() {
888
+ local task_id="$1"
889
+ validate_task_id "$task_id"
890
+
891
+ cd "$REPO"
892
+
893
+ local record_file="$MERGE_RECORDS_DIR/$task_id"
894
+ if [ ! -f "$record_file" ]; then
895
+ # No merge record: either MERGED_EMPTY (nothing to push) or the R5
896
+ # manual path (the lock is still held from the conflicted integrate).
897
+ # The lock tells them apart.
898
+ if cmd_refresh_lock "$task_id" >/dev/null 2>&1; then
899
+ echo "LOCK: refreshed for push (manual-merge path)"
900
+ do_push "$task_id"
901
+ return $?
902
+ fi
903
+ echo "PUSH_SKIPPED: no merge record for $task_id and no lock held -- nothing was merged, nothing to push"
904
+ return 0
905
+ fi
906
+
907
+ # Refresh the merge lock first: a slow push must not let the lease expire
908
+ # under us. Do not push without the lock.
909
+ if ! cmd_refresh_lock "$task_id" >/dev/null 2>&1; then
910
+ echo "ERROR: merge lock lost before push -- not pushing without the lock"
911
+ return 1
912
+ fi
913
+ echo "LOCK: refreshed for push"
914
+
915
+ do_push "$task_id"
916
+ return $?
917
+ }
918
+
636
919
  # --- dispatch ---
637
920
 
638
921
  cmd="${1:-}"
@@ -645,6 +928,9 @@ case "$cmd" in
645
928
  inspect) cmd_inspect "${1:?task_id required}" ;;
646
929
  integrate) cmd_integrate "${1:?task_id required}" "${2:-}" ;;
647
930
  verify-merge) cmd_verify_merge "${1:?task_id required}" ;;
931
+ integration-target) cmd_integration_target ;;
932
+ push-destination) cmd_push_destination ;;
933
+ push-target) cmd_push_target "${1:?task_id required}" ;;
648
934
  post-deploy) cmd_post_deploy "${1:?task_id required}" ;;
649
935
  terminal-cleanup) cmd_terminal_cleanup "${1:?task_id required}" ;;
650
936
  cleanup) cmd_cleanup "${1:?task_id required}" ;;
@@ -654,7 +940,7 @@ case "$cmd" in
654
940
  merge-record) cmd_merge_record "${1:?task_id required}" ;;
655
941
  *)
656
942
  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"
943
+ 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
944
  exit 1
659
945
  ;;
660
946
  esac
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "muse-crew",
3
- "version": "0.14.1",
3
+ "version": "0.14.3",
4
4
  "description": "Opinionated orchestration for Muse — workflows, identities, and tooling for autonomous software development.",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,