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