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.
- package/AGENTS.md +1 -1
- package/docs/decisions/AGENTS.md +3 -0
- package/docs/decisions/publish-path.md +195 -6
- package/docs/decisions/workflow-core.md +1 -1
- package/docs/guide.md +3 -3
- package/docs/publish-verification.md +5 -2
- package/docs/reviews/critic-0143-redo.md +94 -0
- package/docs/reviews/critic-0143.md +108 -0
- package/lib/AGENTS.md +5 -3
- package/lib/crew-api.js +8 -6
- package/lib/publish-npm.sh +39 -2
- package/lib/retry-publish.js +5 -2
- package/lib/test-detached-integrate.sh +416 -0
- package/lib/test-publish-preflight.sh +94 -5
- package/lib/verify-publish.js +3 -4
- package/lib/worktree-lifecycle.sh +380 -94
- package/package.json +1 -1
- package/workflows/bugfix.js +67 -70
- package/workflows/chore.js +65 -68
- package/workflows/standard.js +67 -70
|
@@ -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
|
|
22
|
-
# integrate <task_id> <commit_msg> — merge lock → merge into
|
|
23
|
-
#
|
|
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
|
|
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>
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
198
|
-
|
|
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
|
-
|
|
246
|
-
|
|
288
|
+
local target
|
|
289
|
+
target=$(integration_target) || return 1
|
|
290
|
+
echo "=== Integration target: $target ==="
|
|
247
291
|
echo ""
|
|
248
|
-
echo "===
|
|
249
|
-
git
|
|
292
|
+
echo "=== Commits ahead of $target ==="
|
|
293
|
+
git log --oneline "$target..$branch"
|
|
250
294
|
echo ""
|
|
251
|
-
|
|
295
|
+
echo "=== Diff: $target...$branch ==="
|
|
296
|
+
git diff --stat "$target...$branch"
|
|
297
|
+
echo ""
|
|
298
|
+
git diff "$target...$branch"
|
|
252
299
|
}
|
|
253
300
|
|
|
254
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
327
|
-
#
|
|
328
|
-
#
|
|
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
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
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
|
|
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"
|
|
385
|
-
echo "VERIFIED: branch $branch gone but recorded merge commit $recorded_commit is an ancestor of
|
|
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
|
|
397
|
-
# genuine empty-diff Integrate (MERGED_EMPTY:
|
|
398
|
-
# verifies vacuously — the tip is then
|
|
399
|
-
|
|
400
|
-
|
|
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 "
|
|
406
|
-
echo "NOT_MERGED: $branch tip $tip is not an ancestor of
|
|
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
|
|
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
|
|
421
|
-
# in main via add -A). Never use an all-negative
|
|
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
|
|
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
|
-
|
|
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 "
|
|
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 "
|
|
596
|
-
behind=$(git rev-list --count "$branch
|
|
597
|
-
echo "Branch: $branch (${ahead} ahead, ${behind} behind
|
|
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
|
|
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