muse-crew 0.14.2 → 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.
@@ -82,7 +82,42 @@ if [ "$PREFLIGHT_CHECK_CODE" -ne 0 ] \
82
82
  fi
83
83
  # PREFLIGHT-END
84
84
 
85
- # 3. Dry run: markers only, no lock, no deploy, no mutation.
85
+ # PUSH-TARGET-ANCHOR: resolve the push destination before ANY mutation.
86
+ # The destination is the integration target resolved by the lifecycle, not a
87
+ # hardcoded `main` (2026-09-19 REVIEW: the old `git push origin main` was a
88
+ # stale clone of the same hardcoding the integrate fix removed). A detached
89
+ # HEAD pushes as HEAD:<destination>, with the destination resolved explicitly
90
+ # by the lifecycle's push-destination command (2026-09-19 detached-HEAD audit
91
+ # REDO: the old code refused every detached checkout; detached publication
92
+ # is legitimate and must work). Fail here, before the version-bump commit
93
+ # and the registry publish, only when the destination is genuinely
94
+ # unknowable -- never after a side effect you cannot roll back. (The old
95
+ # position of this check -- step 13, after the npm publish -- left a shipped
96
+ # version with a failed phase and a retry loop.)
97
+ # No-origin is checked for BOTH shapes (2026-09-19 critic finding F-A1):
98
+ # step 13 pushes unconditionally, so an attached checkout with no origin
99
+ # would otherwise fail PUBLISH_FAILED=push after the version-bump commit
100
+ # and the registry publish. A version-bump commit with nowhere to record it
101
+ # would ship an npm version with no git provenance, so no-remote fails
102
+ # closed here rather than skipping like the integrate path (F-F6).
103
+ [ -n "${LIFECYCLE:-}" ] || fail "env" "LIFECYCLE is not set -- cannot resolve the push destination before mutation (F-R4)"
104
+ PUSH_TARGET="$(CREW_HOME="${CREW_HOME:-}" CREW_REPO="$REPO_PATH" bash "$LIFECYCLE" integration-target 2>&1)" \
105
+ || fail "integration-target" "$PUSH_TARGET"
106
+ if ! git -C "$REPO_PATH" remote get-url origin >/dev/null 2>&1; then
107
+ fail "env" "no origin remote configured: the version-bump commit could not be pushed anywhere -- no version-bump commit was created and nothing was published"
108
+ fi
109
+ PUSH_REFSPEC="$PUSH_TARGET"
110
+ if [ "$PUSH_TARGET" = "HEAD" ]; then
111
+ PUSH_DEST="$(CREW_HOME="${CREW_HOME:-}" CREW_REPO="$REPO_PATH" bash "$LIFECYCLE" push-destination 2>&1)" \
112
+ || fail "env" "$PUSH_DEST"
113
+ PUSH_REFSPEC="HEAD:$PUSH_DEST"
114
+ fi
115
+ # PUSH-TARGET-END
116
+
117
+ # 3. Dry run: markers only, no lock, no deploy, no mutation. The push
118
+ # preflight above already ran, so even a dry run fails closed on a
119
+ # genuinely unknowable push destination (see case 7 in
120
+ # lib/test-publish-preflight.sh -- F-R9).
86
121
  if [ "${DRY_RUN:-0}" = "1" ]; then
87
122
  echo "PUBLISH_DRY_RUN=1"
88
123
  exit 0
@@ -281,15 +316,9 @@ LOCK_OUT2="$(CREW_HOME="${CREW_HOME:-}" CREW_REPO="$REPO_PATH" bash "${LIFECYCLE
281
316
  || fail "lock-refresh" "$LOCK_OUT2"
282
317
 
283
318
  # 13. Push the version-bump commit (idempotent: no-op if already pushed).
284
- # The destination is the integration target resolved by the lifecycle, not a
285
- # hardcoded `main` (2026-09-19 REVIEW: the old `git push origin main` was a
286
- # stale clone of the same hardcoding the integrate fix removed).
287
- PUSH_TARGET="$(CREW_HOME="${CREW_HOME:-}" CREW_REPO="$REPO_PATH" bash "${LIFECYCLE:?LIFECYCLE is required}" integration-target 2>&1)" \
288
- || fail "integration-target" "$PUSH_TARGET"
289
- if [ "$PUSH_TARGET" = "HEAD" ]; then
290
- fail "push" "detached HEAD: publish-npm cannot push a version-bump commit from a detached checkout"
291
- fi
292
- PUSH_OUT="$(git -C "$REPO_PATH" push origin "$PUSH_TARGET" 2>&1)" \
319
+ # PUSH_REFSPEC was resolved in the preflight above -- a branch name, or
320
+ # HEAD:<destination> on a detached checkout (never a bare HEAD).
321
+ PUSH_OUT="$(git -C "$REPO_PATH" push origin "$PUSH_REFSPEC" 2>&1)" \
293
322
  || fail "push" "$PUSH_OUT"
294
323
  echo "PUBLISH_PUSHED=1"
295
324
 
@@ -180,8 +180,11 @@ if (phase === "prepare") {
180
180
  }
181
181
 
182
182
  const classifierPath = join(crewHome, "lib", "classify-publish-absence.js");
183
- // Find the trigger entry for the manifest baseline.
184
- const trigger = [...entries].reverse().find((e) =>
183
+ // Find the trigger entry for the manifest baseline: the OLDEST matching
184
+ // "submitted" (the trigger issuance), matching verify-publish.js's anchor.
185
+ // agent_id is null on the issuance entry (no receipt observed at trigger
186
+ // time) — the baseline comes from manifest_before, never agent_id.
187
+ const trigger = entries.find((e) =>
185
188
  e.outcome === "submitted" && e.commit === commit && (attempt == null || e.attempt === attempt));
186
189
  const manifestBefore = trigger && trigger.manifest_before ? JSON.stringify(trigger.manifest_before) : null;
187
190
 
@@ -22,7 +22,8 @@
22
22
  # 6. NOT move the main branch (it still points at A)
23
23
  # 7. write integration_target=HEAD in the merge record
24
24
  # 8. verify-merge → VERIFIED
25
- # 9. push-target → PUSH_SKIPPED (detached HEAD has no remote counterpart)
25
+ # 9. push-target → NO_REMOTE_PUSH (fixture A has no origin; a detached
26
+ # line with no remote has nowhere to push -- loud, not silent)
26
27
  #
27
28
  # Fixture B (branch — the old behavior is preserved):
28
29
  # repo on main with a local bare origin. Integrate merges, reconciles,
@@ -49,6 +50,14 @@
49
50
  # crash): re-running integrate takes the lock and pushes instead of
50
51
  # parking on MERGED_EMPTY.
51
52
  #
53
+ # Fixture H (detached HEAD with an origin — 2026-09-19 detached-HEAD audit
54
+ # REDO): H1 proves push-destination fails closed when origin/HEAD is unset
55
+ # (genuinely unknowable destination); H2-H5 prove the detached line pushes
56
+ # explicitly as HEAD:main — inline push reports
57
+ # `PUSHED: origin/main (refspec HEAD:main)`, the remote ref advances to the
58
+ # merge commit, local main stays at the reviewed base, HEAD stays detached,
59
+ # verify-merge VERIFIEDs, and push-target re-pushes idempotently.
60
+ #
52
61
  # Hermetic: scratch dirs under /tmp only, removed at start (and end).
53
62
  set -u
54
63
 
@@ -126,9 +135,9 @@ echo "$rec" | grep -q '^integration_target=HEAD$' || fail "merge record missing
126
135
  out=$(bash "$LIFECYCLE" verify-merge "$tid") || fail "verify-merge exited non-zero: $out"
127
136
  echo "$out" | grep -q '^VERIFIED:' || fail "verify-merge: expected VERIFIED, got: $out"
128
137
 
129
- # 9. push-target skips loudly on a detached HEAD
138
+ # 9. push-target on a detached HEAD with no remote: loud NO_REMOTE_PUSH
130
139
  out=$(bash "$LIFECYCLE" push-target "$tid") || fail "push-target exited non-zero: $out"
131
- echo "$out" | grep -q '^PUSH_SKIPPED: detached HEAD' || fail "push-target: expected PUSH_SKIPPED, got: $out"
140
+ echo "$out" | grep -q '^NO_REMOTE_PUSH: no origin remote configured' || fail "push-target: expected NO_REMOTE_PUSH, got: $out"
132
141
 
133
142
  echo "fixture A (detached HEAD): all checks passed"
134
143
 
@@ -336,5 +345,72 @@ echo "$out" | grep -qi 'lock lost' && fail "G: misleading lock-lost error: $out"
336
345
 
337
346
  echo "fixture G (push-target after MERGED_EMPTY): all checks passed"
338
347
 
348
+ # ---------------- Fixture H: detached HEAD with an origin ----------------
349
+ # (2026-09-19 detached-HEAD audit REDO: a detached line with a remote is
350
+ # pushed explicitly as HEAD:<destination> -- the remote's default branch --
351
+ # never silently skipped, never refused. The local branch the line
352
+ # descends from is NOT moved; only the remote ref advances.)
353
+ H_DIR="$T/fixture-h"
354
+ H_REMOTE="$T/remote-h.git"
355
+ git init -q --bare "$H_REMOTE"
356
+ mkdir -p "$H_DIR"
357
+ cd "$H_DIR"
358
+ git init -q -b main .
359
+ git config user.email test@test.t
360
+ git config user.name test
361
+ echo reviewed > f.txt
362
+ git add -A
363
+ git commit -qm "reviewed base"
364
+ H_BASE=$(git rev-parse HEAD)
365
+ git remote add origin "$H_REMOTE"
366
+ git push -q origin main
367
+ git fetch -q origin
368
+ # Detach at the reviewed base, crew-init stamp on top -- the detached line
369
+ # the REDO must push explicitly (set origin/HEAD only after H1).
370
+ git checkout -q --detach HEAD
371
+ echo crew-init >> f.txt
372
+ git commit -qam "crew-init: stamp provenance"
373
+ H_LINE=$(git rev-parse HEAD)
374
+ git symbolic-ref -q HEAD >/dev/null && fail "fixture H: HEAD should be detached"
375
+ export CREW_REPO="$H_DIR"
376
+ export CREW_HOME="$H_DIR/home"
377
+ export CREW_LIB="$REPO_DIR/lib"
378
+ # H1: origin/HEAD not set -- the destination is genuinely unknowable --
379
+ # push-destination fails closed (never a silent skip, never a bare-HEAD push)
380
+ out=$(bash "$LIFECYCLE" push-destination 2>&1) && fail "H1: push-destination should fail without origin/HEAD: $out"
381
+ echo "$out" | grep -q 'genuinely unknowable' || fail "H1: expected unknowable error, got: $out"
382
+ echo "fixture H1 (unknowable destination fails closed): passed"
383
+ # Now set the remote default branch explicitly (hermetic: no `remote set-head -a` network round-trip)
384
+ git symbolic-ref refs/remotes/origin/HEAD refs/remotes/origin/main
385
+ tid="detachedorigin1"
386
+
387
+ # H2: push-destination resolves the remote default branch
388
+ out=$(bash "$LIFECYCLE" push-destination) || fail "H2 push-destination exited non-zero: $out"
389
+ [ "$out" = "main" ] || fail "H2 push-destination: expected main, got: $out"
390
+
391
+ # H3: prepare + task work, then integrate pushes inline as HEAD:main
392
+ out=$(bash "$LIFECYCLE" prepare "$tid") || fail "H3 prepare exited non-zero: $out"
393
+ echo task-work >> "$H_DIR/.worktrees/$tid/f.txt"
394
+ (cd "$H_DIR/.worktrees/$tid" && git commit -qam "task work")
395
+ out=$(bash "$LIFECYCLE" integrate "$tid" "merge: $tid" 2>&1) || fail "H3 integrate exited non-zero: $out"
396
+ echo "$out" | grep -q '^MERGED:' || fail "H3 integrate: expected MERGED, got: $out"
397
+ echo "$out" | grep -q '^PUSHED: origin/main (refspec HEAD:main)' || fail "H3 integrate: expected explicit detached PUSHED, got: $out"
398
+
399
+ # H4: HEAD still detached; the detached line survived; the remote advanced
400
+ git symbolic-ref -q HEAD >/dev/null && fail "H4: HEAD should still be detached"
401
+ H_MERGE=$(git rev-parse HEAD)
402
+ git merge-base --is-ancestor "$H_BASE" HEAD || fail "H4: reviewed base abandoned"
403
+ git merge-base --is-ancestor "$H_LINE" HEAD || fail "H4: detached line abandoned"
404
+ [ "$(git rev-parse main)" = "$H_BASE" ] || fail "H4: local main moved (must stay at the reviewed base)"
405
+ [ "$(git --git-dir="$H_REMOTE" rev-parse refs/heads/main)" = "$H_MERGE" ] || fail "H4: origin/main not updated to the merge commit"
406
+
407
+ # H5: verify-merge VERIFIEDs; push-target re-pushes idempotently as HEAD:main
408
+ out=$(bash "$LIFECYCLE" verify-merge "$tid") || fail "H5 verify-merge exited non-zero: $out"
409
+ echo "$out" | grep -q '^VERIFIED:' || fail "H5: expected VERIFIED, got: $out"
410
+ out=$(bash "$LIFECYCLE" push-target "$tid") || fail "H5 push-target exited non-zero: $out"
411
+ echo "$out" | grep -q '^PUSHED: origin/main (refspec HEAD:main)' || fail "H5 push-target: expected idempotent PUSHED, got: $out"
412
+
413
+ echo "fixture H (detached HEAD + origin): all checks passed"
414
+
339
415
  rm -rf "$T"
340
416
  echo "PASS: detached-HEAD integrate fixtures"
@@ -21,6 +21,17 @@
21
21
  # * old helper that does not know --check (usage to stderr, exit 1,
22
22
  # no CREDENTIAL markers) with DRY_RUN=1 -> no skip (fall-through
23
23
  # preserved: unknown is not absent)
24
+ # - detached fixtures (detached-HEAD audit REDO, 2026-09-19): a detached
25
+ # checkout with a local origin. Case 6 (origin/HEAD set, DRY_RUN=1) ->
26
+ # preflight passes through to PUBLISH_DRY_RUN=1 (no refusal — detached
27
+ # publication is legitimate). Case 7 (origin/HEAD deleted, DRY_RUN=1) ->
28
+ # PUBLISH_FAILED=env before any mutation, repo untouched (the genuinely
29
+ # unknowable destination fails in the preflight, ahead of the DRY_RUN
30
+ # block).
31
+ # - Case 8: attached checkout with no origin (DRY_RUN=1) -> PUBLISH_FAILED=env
32
+ # pre-mutation, repo untouched (2026-09-19 critic F-A1: the anchor used to
33
+ # check no-origin only on the detached branch, so attached+no-origin
34
+ # failed post-mutation after the version-bump commit and registry publish).
24
35
  # - --check probe unit test against the real helper: on the author
25
36
  # machine this expects exit 0 and exactly CREDENTIAL_OK=custom.npm.
26
37
  # NOTE: this case depends on the `custom.npm` credential existing in
@@ -52,6 +63,13 @@ git -C "$SCRATCH" config user.email "test@crew"
52
63
  git -C "$SCRATCH" config user.name "crew-test"
53
64
  echo x > "$SCRATCH/f.txt"
54
65
  git -C "$SCRATCH" add -A && git -C "$SCRATCH" commit -qm init
66
+ # The scratch repo needs an origin for the DRY_RUN pass-through cases
67
+ # (2026-09-19 critic F-A1: the push-destination anchor fails closed
68
+ # pre-mutation when origin is absent, attached or detached).
69
+ git init -q --bare "$TMPBASE/origin.git"
70
+ git -C "$SCRATCH" remote add origin "$TMPBASE/origin.git"
71
+ git -C "$SCRATCH" push -q origin main
72
+ git -C "$SCRATCH" remote set-head origin -a
55
73
 
56
74
  # --- Fake helpers ---
57
75
  cat > "$TMPBASE/helper-ok.py" <<'EOF'
@@ -78,6 +96,32 @@ print("Usage: helper-old.py <tarball-path>", file=sys.stderr)
78
96
  sys.exit(1)
79
97
  EOF
80
98
 
99
+ # --- Detached fixtures (detached-HEAD audit REDO, 2026-09-19): a detached
100
+ # checkout with a local origin. Case 6 runs with origin/HEAD set; Case 7
101
+ # deletes origin/HEAD first, exercising push-destination's genuinely-
102
+ # unknowable branch. ---
103
+ ORIGIN="$TMPBASE/origin-det.git"
104
+ git init -q --bare "$ORIGIN"
105
+ SCRATCH_DET="$TMPBASE/repo-detached"
106
+ git init -q -b main "$SCRATCH_DET"
107
+ git -C "$SCRATCH_DET" config user.email "test@crew"
108
+ git -C "$SCRATCH_DET" config user.name "crew-test"
109
+ echo x > "$SCRATCH_DET/f.txt"
110
+ git -C "$SCRATCH_DET" add -A && git -C "$SCRATCH_DET" commit -qm init
111
+ git -C "$SCRATCH_DET" remote add origin "$ORIGIN"
112
+ git -C "$SCRATCH_DET" push -q origin main
113
+ git -C "$SCRATCH_DET" remote set-head origin -a
114
+ git -C "$SCRATCH_DET" checkout -q --detach HEAD
115
+
116
+ # --- Attached no-origin fixture (F-A1): an attached checkout with no origin
117
+ # fails closed in the anchor, attached or detached. ---
118
+ SCRATCH_NOORIGIN="$TMPBASE/repo-no-origin"
119
+ git init -q -b main "$SCRATCH_NOORIGIN"
120
+ git -C "$SCRATCH_NOORIGIN" config user.email "test@crew"
121
+ git -C "$SCRATCH_NOORIGIN" config user.name "crew-test"
122
+ echo x > "$SCRATCH_NOORIGIN/f.txt"
123
+ git -C "$SCRATCH_NOORIGIN" add -A && git -C "$SCRATCH_NOORIGIN" commit -qm init
124
+
81
125
  ACTIONABLE="npm publish not configured on this machine — see docs/guide.md#npm-releases; code changes are merged, nothing was versioned or published"
82
126
 
83
127
  # --- Structural: extract the shipped preflight block by anchor ---
@@ -123,13 +167,23 @@ fi
123
167
 
124
168
  # Runner: full publish-npm.sh against the scratch repo. The preflight exits
125
169
  # before any mutation, so the repo needs no package.json.
170
+ # LIFECYCLE + CREW_HOME are exported because the preflight now resolves the
171
+ # push destination up front (2026-09-19: the push-destination resolution moved
172
+ # from step 13 to the preflight — fail before mutation, never after the
173
+ # registry publish; 2026-09-19 detached-HEAD audit REDO: a detached checkout
174
+ # resolves to HEAD:<destination>, refused only when genuinely unknowable).
175
+ # The scratch repo sits on branch `main`, so integration-target
176
+ # resolves cleanly and the npm-credential assertions are unaffected.
126
177
  run_full() { # run_full <helper_path> [extra_env...]
127
178
  local helper="$1"; shift
128
179
  local run="$TMPBASE/run-$RANDOM"
129
180
  mkdir -p "$run"
181
+ local crew_home="$TMPBASE/crew-home"
182
+ mkdir -p "$crew_home"
130
183
  (
131
184
  export TASK_ID="preflight-test" REPO_PATH="$SCRATCH" PKG="muse-crew" TARGET_VERSION="9.9.9"
132
185
  export NPM_PUBLISH_PY="$helper"
186
+ export LIFECYCLE="$SCRIPT_DIR/worktree-lifecycle.sh" CREW_HOME="$crew_home" CREW_REPO="$SCRATCH"
133
187
  # A bare `export` (empty "$@") would dump the environment to stdout and
134
188
  # corrupt the captured run path — only export when extras are given.
135
189
  if [ $# -gt 0 ]; then export "$@"; fi
@@ -140,11 +194,11 @@ run_full() { # run_full <helper_path> [extra_env...]
140
194
  echo "$run"
141
195
  }
142
196
 
143
- repo_state() { # prints "HEAD=<sha> STATUS=<porcelain-lines> TGZ=<count>"
144
- local head status tgz
145
- head="$(git -C "$SCRATCH" rev-parse HEAD)"
146
- status="$(git -C "$SCRATCH" status --porcelain | wc -l)"
147
- tgz="$(find "$SCRATCH" -maxdepth 1 -name '*.tgz' | wc -l)"
197
+ repo_state() { # repo_state [repo]: "HEAD=<sha> STATUS=<porcelain-lines> TGZ=<count>"
198
+ local head status tgz repo="${1:-$SCRATCH}"
199
+ head="$(git -C "$repo" rev-parse HEAD)"
200
+ status="$(git -C "$repo" status --porcelain | wc -l)"
201
+ tgz="$(find "$repo" -maxdepth 1 -name '*.tgz' | wc -l)"
148
202
  echo "HEAD=$head STATUS=$status TGZ=$tgz"
149
203
  }
150
204
 
@@ -203,6 +257,41 @@ else
203
257
  no "real helper --check: code=$CHECK_CODE out='$CHECK_OUT' (expected CREDENTIAL_OK=custom.npm — this case is credential-dependent; see header)"
204
258
  fi
205
259
 
260
+ # --- Case 6: detached + resolvable destination + DRY_RUN=1 -> pass-through, no refusal ---
261
+ RUN="$(run_full "$TMPBASE/helper-ok.py" DRY_RUN=1 REPO_PATH="$SCRATCH_DET")"
262
+ CODE="$(cat "$RUN/code")"; OUT="$(cat "$RUN/out")"
263
+ if [ "$CODE" -eq 0 ] && [[ "$OUT" == *"PUBLISH_DRY_RUN=1"* ]] \
264
+ && [[ "$OUT" != *"PUBLISH_SKIPPED"* ]] && [[ "$OUT" != *"PUBLISH_FAILED"* ]]; then
265
+ ok "detached + resolvable destination: preflight passes through to PUBLISH_DRY_RUN=1 (no refusal)"
266
+ else
267
+ no "detached resolvable: code=$CODE out='$OUT'"
268
+ fi
269
+
270
+ # --- Case 7: detached + origin/HEAD unset -> PUBLISH_FAILED=env before any mutation ---
271
+ git -C "$SCRATCH_DET" remote set-head origin -d
272
+ BEFORE="$(repo_state "$SCRATCH_DET")"
273
+ RUN="$(run_full "$TMPBASE/helper-ok.py" DRY_RUN=1 REPO_PATH="$SCRATCH_DET")"
274
+ CODE="$(cat "$RUN/code")"; OUT="$(cat "$RUN/out")"; AFTER="$(repo_state "$SCRATCH_DET")"
275
+ if [ "$CODE" -ne 0 ] && [[ "$OUT" == *"PUBLISH_FAILED=env"* ]] \
276
+ && [[ "$OUT" != *"PUBLISH_SKIPPED"* ]] && [[ "$OUT" != *"PUBLISH_DRY_RUN=1"* ]] \
277
+ && [ "$BEFORE" = "$AFTER" ]; then
278
+ ok "detached + unknowable destination: fails closed pre-mutation, repo untouched"
279
+ else
280
+ no "detached unknowable: code=$CODE state=$BEFORE/$AFTER out='$OUT'"
281
+ fi
282
+
283
+ # --- Case 8: attached + no origin -> PUBLISH_FAILED=env before any mutation ---
284
+ BEFORE="$(repo_state "$SCRATCH_NOORIGIN")"
285
+ RUN="$(run_full "$TMPBASE/helper-ok.py" DRY_RUN=1 REPO_PATH="$SCRATCH_NOORIGIN")"
286
+ CODE="$(cat "$RUN/code")"; OUT="$(cat "$RUN/out")"; AFTER="$(repo_state "$SCRATCH_NOORIGIN")"
287
+ if [ "$CODE" -ne 0 ] && [[ "$OUT" == *"PUBLISH_FAILED=env"* ]] \
288
+ && [[ "$OUT" != *"PUBLISH_SKIPPED"* ]] && [[ "$OUT" != *"PUBLISH_DRY_RUN=1"* ]] \
289
+ && [ "$BEFORE" = "$AFTER" ]; then
290
+ ok "attached + no origin: fails closed pre-mutation, repo untouched"
291
+ else
292
+ no "attached no-origin: code=$CODE state=$BEFORE/$AFTER out='$OUT'"
293
+ fi
294
+
206
295
  bash -n "$PUBLISH" && ok "publish-npm.sh syntax OK" || no "publish-npm.sh syntax"
207
296
 
208
297
  echo ""
@@ -298,8 +298,9 @@ if (head !== commit) {
298
298
  // 2026-09-18, blocker 4: bind by exact (task_id, commit, attempt) when the
299
299
  // attempt is known — the original and retry attempts share a commit, and
300
300
  // positional selection is luck. Within one attempt, select the OLDEST
301
- // submitted entry (the trigger issuance; per blocker 2, audit observations
302
- // are now "build-observed", not "submitted").
301
+ // submitted entry (the trigger issuance; a receipt-bearing second "submitted"
302
+ // may follow it — the oldest binds the trigger instant, the latest carries
303
+ // the observed agent_id).
303
304
  let triggerTs = null;
304
305
  let manifestBefore = null;
305
306
  try {
@@ -313,8 +314,6 @@ try {
313
314
  (attempt == null || e.attempt === attempt)
314
315
  );
315
316
  if (candidates.length > 0) {
316
- // Oldest first: the trigger issuance is the first submitted entry for
317
- // the attempt (observations are "build-observed" per blocker 2).
318
317
  candidates.sort((a, b) => (a.ts || "").localeCompare(b.ts || ""));
319
318
  const e = candidates[0];
320
319
  triggerTs = e.ts || null;
@@ -150,6 +150,24 @@ integration_target() {
150
150
  git rev-parse --abbrev-ref HEAD 2>/dev/null || { echo "ERROR: cannot resolve HEAD"; return 1; }
151
151
  }
152
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
+
153
171
  # (2026-09-19 REVIEW: the old integration_tip() helper is deleted — it had
154
172
  # zero call sites and cmd_verify_merge reimplemented tip resolution inline
155
173
  # with different semantics, a drift hazard. Tip resolution lives in exactly
@@ -323,8 +341,15 @@ acquire_merge_lock() {
323
341
  # refresh needed) and by cmd_push_target for the R5 manual-conflict path
324
342
  # (which refreshes the lock first). Never force-pushes.
325
343
  #
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.
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).
328
353
  do_push() {
329
354
  local task_id="$1"
330
355
  cd "$REPO"
@@ -332,17 +357,6 @@ do_push() {
332
357
  local target
333
358
  target=$(integration_target) || return 1
334
359
 
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
360
  # No remote configured: fail soft, loudly. (2026-09-19 REVIEW: the old
347
361
  # code ran `git push origin` unconditionally, which exits 128 with no
348
362
  # origin, got mislabeled PUSH_REJECTED, and failed the phase — while
@@ -354,8 +368,20 @@ do_push() {
354
368
  return 0
355
369
  fi
356
370
 
357
- if git push origin "$target" 2>&1; then
358
- echo "PUSHED: origin/$target"
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)"
359
385
  return 0
360
386
  fi
361
387
 
@@ -363,17 +389,17 @@ do_push() {
363
389
  # "non-fast-forward" — auth errors got the same label). Recover
364
390
  # mechanically while the lock is held: fetch, merge, retry once.
365
391
  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"
392
+ if ! git fetch origin "$dest" 2>&1; then
393
+ echo "ERROR: git fetch origin $dest failed -- push not completed"
368
394
  return 1
369
395
  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"
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"
372
398
  git merge --abort 2>/dev/null || true
373
399
  return 1
374
400
  fi
375
- if git push origin "$target" 2>&1; then
376
- echo "PUSHED: origin/$target (after reconcile)"
401
+ if git push origin "$refspec" 2>&1; then
402
+ echo "PUSHED: origin/$dest (refspec $refspec, after reconcile)"
377
403
  return 0
378
404
  fi
379
405
  echo "ERROR: push failed again after reconcile -- not pushing"
@@ -473,10 +499,12 @@ cmd_integrate() {
473
499
  # the Integrate phase fails closed. Fetch and merge the remote branch now
474
500
  # so the subsequent push is a fast-forward.
475
501
  # Fail-soft when there is no remote; fail closed on a real conflict.
476
- # A detached HEAD is local by design (no remote ref is claimed for the
477
- # detached line), so there is nothing to reconcile.
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).
478
506
  if [ "$target" = "HEAD" ]; then
479
- echo "NO_REMOTE_RECONCILE: detached HEAD -- the integration line is local by design; skipping origin reconcile"
507
+ echo "NO_REMOTE_RECONCILE: detached HEAD -- destination resolved explicitly by do_push, skipping inline reconcile"
480
508
  elif git rev-parse --verify "origin/$target" >/dev/null 2>&1; then
481
509
  if git fetch origin "$target" 2>&1; then
482
510
  if ! git merge --no-edit "origin/$target" -m "merge: reconcile with origin/$target before push ($task_id)" 2>&1; then
@@ -512,7 +540,7 @@ cmd_integrate() {
512
540
  # agent run added only a lock refresh). R5's manual-conflict path uses the
513
541
  # push-target command instead.
514
542
  if ! do_push "$task_id"; then
515
- echo "ERROR: push failed after merge $merged_commit -- merge is recorded; retry will recover the push"
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)"
516
544
  return 1
517
545
  fi
518
546
 
@@ -834,6 +862,19 @@ cmd_integration_target() {
834
862
  integration_target
835
863
  }
836
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
+
837
878
  # Deterministic push of the integration target. Thin wrapper over do_push
838
879
  # for the R5 manual-conflict path (re-running `integrate` there misfires on
839
880
  # ahead=0 -> MERGED_EMPTY, so the manually-merged line still needs a
@@ -888,6 +929,7 @@ case "$cmd" in
888
929
  integrate) cmd_integrate "${1:?task_id required}" "${2:-}" ;;
889
930
  verify-merge) cmd_verify_merge "${1:?task_id required}" ;;
890
931
  integration-target) cmd_integration_target ;;
932
+ push-destination) cmd_push_destination ;;
891
933
  push-target) cmd_push_target "${1:?task_id required}" ;;
892
934
  post-deploy) cmd_post_deploy "${1:?task_id required}" ;;
893
935
  terminal-cleanup) cmd_terminal_cleanup "${1:?task_id required}" ;;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "muse-crew",
3
- "version": "0.14.2",
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,