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/lib/AGENTS.md CHANGED
@@ -7,12 +7,14 @@ Shell scripts for the crew's infrastructure. Called by workflow scripts, cron, a
7
7
  - `schema.sql` — the crew-owned state schema: projects, tasks, poll_state, config, agent_sessions, events. Vocabularies enforced by CHECK constraints; `rejected` is a valid event type (the 2026-09-11 crash was a stored session whose event was rejected). Column names match the historical dashboard tables for a verbatim migration.
8
8
  - `crew-release.sh` — immutable release manager: deploy, rollback, prune
9
9
  - `merge-lock.sh` — serialized merge lock for concurrent agents: time-based holder lease (bug 2fc8f52f — an unexpired lease is held regardless of process liveness; only an expired lease may be broken). Requires both `CREW_REPO` and `CREW_HOME` (fail closed: BLOCKED, exit 2 when either is unset). Lock file is key=value: task_id, opaque holder identity (never a PID), acquired_at epoch, lease_seconds (default 600, override via MERGE_LOCK_LEASE_SECONDS). acquire/refresh/release/status/force-release; holder-only refresh and release; every op appends to $CREW_HOME/.merge-lock.log
10
- - `worktree-lifecycle.sh` — the worktree lifecycle seam: prepare/cleanup/inspect/integrate/verify-merge/status/post-deploy/terminal-cleanup/refresh-lock/lock-status over git worktrees (`integrate` reconciles `origin/main` under the merge lock after the task merge — canary `a6d8b0c8`, 2026-09-11 — so the push is a fast-forward; fail-soft with no remote, fail closed on conflict) (`.worktrees/<id>`, branch `task/<id>`). Branch resolution order: the crew registry (`<repo>/.worktrees/.registry/<id>`), then the canonical `task/<full-id>` ref, then a `task/<id-prefix>` ref matched by strict prefix enumeration (task 4e1a1bba — a Build agent may create the branch with raw git from an abbreviated id, bypassing prepare; prefix names are tolerated, never created; ambiguous prefixes fail closed). `resolve-branch` prints the resolved branch for agent-side one-liners. Nothing reconstructs the branch name — every command resolves it. Prepare fails closed on dirty `main`; cleanup is forgiving. `terminal-cleanup` is the run's last act at every park/fail boundary (called from `parkTask` in standard/bugfix/chore): releases the merge lock unconditionally and reclaims the worktree+branch only when the task branch is fully merged into main — unmerged work is preserved for the human by design, and a dirty worktree is reported, never force-removed. `post-deploy` reports worktree removal honestly (a lying "removed" echo hid real leftovers — canary run 9, 2026-09-12). Requires both `CREW_REPO` and `CREW_HOME` (fail closed: BLOCKED, exit 2 when either is unset); `CREW_REPO` is exported so internal merge-lock.sh calls inherit the repo being worked on; `LIB_DIR` defaults to `$CREW_HOME/lib` (`CREW_LIB` override). `lock-status` reports the merge-lock state explicitly (`UNLOCKED`, or key=value: locked=true, task_id, holder, acquired_at, lease_seconds, age_seconds, remaining_seconds — always exit 0) so Publish can distinguish an empty-diff Integrate (no lock taken) from a refresh failure. post-deploy's `.worktrees/` guard is add-then-reset, never an all-negative pathspec: `git add -A -- ':!.worktrees/'` still exits 1 on git 2.43.0 against the crew's own gitignored non-empty `.worktrees/` (room #15 J3), killing post-deploy under `set -e` — a plain `add -A` never errors on ignored paths and the follow-up `git reset -q -- .worktrees/` keeps stray lock files out of main even on pre-gitignore-entry repos.
10
+ - `worktree-lifecycle.sh` — the worktree lifecycle seam: prepare/cleanup/inspect/integrate/verify-merge/status/post-deploy/terminal-cleanup/refresh-lock/lock-status over git worktrees (the integration target is the repo's current checkout — a branch name, or HEAD when detached — resolved by `integration_target()`; nothing in the lifecycle ever checks out a branch. `integrate` merges into the target, reconciles `origin/<target>` under the merge lock after the task merge — canary `a6d8b0c8`, 2026-09-11 — then pushes inline via `do_push` (no agent round-trip; 2026-09-19 REVIEW collapsed the old STEP-2 agent run, which added only a lock refresh). `do_push`: PUSHED (the refspec is always explicit — a branch pushes as `<branch>`, a detached HEAD as `HEAD:<destination>` where the destination is the remote's default branch from `resolve_push_destination()`; 2026-09-19 detached-HEAD audit REDO) / NO_REMOTE_PUSH (no origin — fail-soft, 2026-09-19 REVIEW: the old unconditional push exited 128 and mislabeled it); an unresolvable detached destination fails closed (never a silent skip, never a refusal of a knowable push); on push failure one fetch+merge retry while the lock is held, then fail closed without asserting a cause (never force-push). PUSH_SKIPPED survives only for the nothing-merged R5 path (no merge record + no lock held). Merged-but-unpushed retry recovery: a record whose merge commit IS the live HEAD takes the lock and pushes instead of reporting MERGED_EMPTY. `CREW_STAGED_BASE` guard (ferried from get-provenance): the line must descend from the staged publish base — STAGED_BASE_MISMATCH fails closed before the merge (room #21 J3 shape). `verify-merge` always re-resolves the target from the current checkout and checks the recorded commit against the LIVE tip — never the record's word for the tip (2026-09-19 REVIEW: the old read-back compared the record to itself for target=HEAD). A detached HEAD skips the inline reconcile loudly (NO_REMOTE_RECONCILE) — the inline reconcile needs a static branch target, so on detached the destination is resolved explicitly in do_push, which reconciles lazily on push failure (2026-09-19 detached-HEAD audit REDO; the old "local by design" rationale is rejected); `push-target` is the thin R5 wrapper over `do_push`: refreshes the lock, distinguishes nothing-merged (no record + no lock → loud skip) from lock-lost (fail closed). (`.worktrees/<id>`, branch `task/<id>`). Branch resolution order: the crew registry (`<repo>/.worktrees/.registry/<id>`), then the canonical `task/<full-id>` ref, then a `task/<id-prefix>` ref matched by strict prefix enumeration (task 4e1a1bba — a Build agent may create the branch with raw git from an abbreviated id, bypassing prepare; prefix names are tolerated, never created; ambiguous prefixes fail closed). `resolve-branch` prints the resolved branch for agent-side one-liners. Nothing reconstructs the branch name — every command resolves it. Prepare fails closed on a dirty integration target; cleanup is forgiving. `terminal-cleanup` is the run's last act at every park/fail boundary (called from `parkTask` in standard/bugfix/chore): releases the merge lock unconditionally and reclaims the worktree+branch only when the task branch is fully merged into the integration target — unmerged work is preserved for the human by design, and a dirty worktree is reported, never force-removed. `post-deploy` reports worktree removal honestly (a lying "removed" echo hid real leftovers — canary run 9, 2026-09-12). Requires both `CREW_REPO` and `CREW_HOME` (fail closed: BLOCKED, exit 2 when either is unset); `CREW_REPO` is exported so internal merge-lock.sh calls inherit the repo being worked on; `LIB_DIR` defaults to `$CREW_HOME/lib` (`CREW_LIB` override). `lock-status` reports the merge-lock state explicitly (`UNLOCKED`, or key=value: locked=true, task_id, holder, acquired_at, lease_seconds, age_seconds, remaining_seconds — always exit 0) so Publish can distinguish an empty-diff Integrate (no lock taken) from a refresh failure. post-deploy's `.worktrees/` guard is add-then-reset, never an all-negative pathspec: `git add -A -- ':!.worktrees/'` still exits 1 on git 2.43.0 against the crew's own gitignored non-empty `.worktrees/` (room #15 J3), killing post-deploy under `set -e` — a plain `add -A` never errors on ignored paths and the follow-up `git reset -q -- .worktrees/` keeps stray lock files out of the integration target even on pre-gitignore-entry repos.
11
+ - `test-detached-integrate.sh` — behavioral regression tests for the detached-HEAD Integrate fix (room #21 J3, 2026-09-19; extended 2026-09-19 REVIEW): fixture A stages a detached HEAD (reviewed SHA + crew-init commit) and proves integrate leaves HEAD detached, keeps the reviewed SHA and crew-init as ancestors, lands the task merge, never moves main, records `integration_target=HEAD`, verify-merge VERIFIEDs, and push-target reports NO_REMOTE_PUSH (fixture A has no origin); fixture B proves the branch path still merges, reconciles, and pushes inline to a local origin; fixture C proves verify-merge checks the record against the LIVE tip (a detached line reset past the merge does NOT verify; the branch-gone path verifies only when the recorded commit is an ancestor of the live tip); fixture D proves the no-remote branch path fails soft with NO_REMOTE_PUSH (exit 0); fixture E proves the CREW_STAGED_BASE guard (non-ancestor base → STAGED_BASE_MISMATCH before any merge; true base → STAGED_BASE_OK); fixture F proves merged-but-unpushed retry recovery (re-running integrate after a crashed push takes the lock and pushes instead of parking on MERGED_EMPTY); fixture H proves the detached-HEAD audit REDO contract (2026-09-19) on a detached checkout with a local origin: push-destination fails closed when origin/HEAD is unset (genuinely unknowable destination), otherwise resolves the remote default branch; integrate pushes inline as `HEAD:main` (`PUSHED: origin/main (refspec HEAD:main)`), the remote ref advances to the merge commit, local main stays at the reviewed base, HEAD stays detached, verify-merge VERIFIEDs, and push-target re-pushes idempotently. Scratch dirs under /tmp only; CREW_HOME lives outside the scratch repos (as in real rooms) so lock files never trip the integrate preflight.
11
12
  - `test-worktree-backend.sh` — regression tests for the lifecycle script (validate, prepare/reuse, inspect, status, cleanup, idempotent cleanup, dirty-main preflight, integrate remote-reconcile with fixture sensitivity + no-remote fail-soft) on scratch repos
12
13
  - `test-version-write.sh` — regression tests for the escape-preserving step-8 version write in publish-npm.sh (fixture: current package.json with the \\u2014 escape; extracts the shipped block by anchor)
13
14
  - `test-publish-verify.sh` — regression tests for the retry-tolerant step-12 verification in publish-npm.sh (canary 5a027278): extracts the shipped block by anchor and runs it against a fake npm whose read replica lags (non-zero exits, then the old version, then the target) — requires convergence on success, fail-closed `PUBLISH_FAILED=verify` on exhaustion, and `--prefer-online` on every read
14
15
  - `test-publish-skip.sh` — regression tests for the no-lock graceful publish skip (park 2026-09-11): `lock-status` returns exact `UNLOCKED`/`LOCKED by <holder> since <ts> (pid <pid>)`; a fake empty-diff integrate takes the MERGED_EMPTY branch and never takes the lock; the shipped step-5 block from publish-npm.sh (extracted by anchor) skips gracefully on UNLOCKED (no park, no `PUBLISH_COMPLETE`), follows the refresh path when a lock is held, and fails closed on a `lock-status` query failure; the gate sits inside the ALREADY_PUBLISHED=0 branch and mutation steps are gated on NO_LOCK_HELD=0
15
- - `publish-npm.sh` — deterministic npm publish: lock refresh, release install, version write/commit, pack, registry publish, verify, push, post-deploy. Takes TARGET_VERSION as input; idempotent on retry/resume.
16
+ - `publish-npm.sh` — deterministic npm publish: preflight (npm capability + push-destination resolution — the push refspec is resolved before any mutation: a branch name, or `HEAD:<destination>` on a detached checkout via the lifecycle's `push-destination` command; a genuinely unknowable destination fails fast here with `PUBLISH_FAILED=env`, before any mutation — 2026-09-19 detached-HEAD audit REDO: detached publication is legitimate and must work, not refuse), lock refresh, release install, version write/commit, pack, registry publish, verify, push, post-deploy. Takes TARGET_VERSION as input; idempotent on retry/resume.
17
+ - `test-publish-preflight.sh` — regression tests for the npm publish capability preflight: the PREFLIGHT block extracted by anchor, helper -f/-r checks, placement, full-script runs (helper missing / credential missing / credential present + DRY_RUN / old helper without --check / real-helper probe), plus the detached-HEAD audit REDO cases (2026-09-19): a detached fixture repo with a local origin — origin/HEAD set + DRY_RUN=1 passes through to PUBLISH_DRY_RUN=1 (no refusal), origin/HEAD unset fails closed with PUBLISH_FAILED=env before any mutation, repo untouched; and the attached+no-origin case (critic F-A1: the anchor used to check no-origin only on the detached branch — attached+no-origin now fails closed pre-mutation too). Runs against scratch repos; the real-helper probe is credential-dependent (author machine only).
16
18
  - `compose-evidence.py` — deterministic visual-evidence compositor (Pillow): pairs identical PNG stems from baseline/ and postchange/ dirs, emits `<stem>-sidebyside.png` and amplified-difference `<stem>-overlay.png` into composites/, prints `PAIR`/`SKIP` manifests. Byte-deterministic; nonzero exit on errors.
17
19
  - `compose-evidence-caption.js` — deterministic QA-evidence caption composer (2026-09-14): given `--crew-home`, `--task-id`, and the resolved `--audit-dir` name, extracts the task title/description, the QA verdict, the `publish: verified` commit (or the task's project-scoped provenance record when it belongs to this task), and the merge commit's subject line from the project repo — and prints the out-of-context transition Eric asked for (problem → solution → evidence handoff) to stdout. The caller appends the desktop/mobile attachment lines after this caption. Verdict source (2026-09-16): Hazel's `task-evidence/<taskId>/postchange/verdict.json` is authoritative, then the last line of the append-only `verdicts.jsonl` ledger, then the newest legacy `visual_verdict:` note (the parent visual-verdict protocol was retired 2026-09-15). Honest labels: "provenance not yet stamped" when no commit is known, "no QA verdict recorded" when none exists, and the visual-protocol-unavailable note only when no verdict evidence exists at all, the newest `baseline:` note is `baseline: none`, AND captures exist (a `baseline: none` note means no baseline captures to compare against — it never overrules a real QA verdict; the note's premise is the post-deploy capture, so without captures it would fabricate evidence again). Capture guard (2026-09-17, room #14): the composer resolves the audit dir itself (`<home>/workspace/ts-spaces/<deploy_slug>/audits/<auditDir>/`, the same root crew-api.js and readback-disk.js resolve) and verifies `screenshot.png` + `screenshot-mobile.png` are files before claiming "desktop + mobile captures below" — a bare `--audit-dir` name is never trusted. Missing deploy_slug, missing dir, or missing PNGs gets the honest no-captures opener and Evidence line (terminal surfaces named explicitly: "terminal surface — QA judges the CLI, not pixels"); the caller only appends attachment lines when its own freshness check passes. "Latest" is by event timestamp, sorted explicitly. Exits non-zero on missing input.
18
20
  - `edit-image.py` — deterministic pixel-level evidence editing (2026-09-15, Pillow): `crop --region x,y,w,h` (pixel-exact, rejects out-of-bounds), `zoom --factor <f> [--center x,y]` (nearest-neighbor, returns to source frame size), `label --text <caption>` (caption bar), `nup --cols <n> --in <a.png> --in <b.png> [--labels "a|b"]` (side-by-side grid). Byte-deterministic PNG output; no network, no time, no randomness. Derivatives supplement raw frames — the source frame stays archived.
@@ -31,7 +33,7 @@ Shell scripts for the crew's infrastructure. Called by workflow scripts, cron, a
31
33
  - `update-watch.js` — deterministic automatic update watcher (2026-09-16, zero deps): `node update-watch.js --crew-home <path>` (missing arg → usage, exit 2; every other path exits 0). Watches the public npm registry (`npm view muse-crew version` pinned to `https://registry.npmjs.org/`) vs `crew-release.sh current` and files a `workflow: "upgrade"` task with `source: npm@<version>` when policy (`auto_update_crew`, `update_channel`) and channel gating allow; watches `git ls-remote origin HEAD` on the first `deploy_type=artifact` project vs `$CREW_HOME/.update-watch.json` and files a `workflow: "chore"` task carrying the mechanical dashboard-upgrade journey. Reads the `.crew-version` compatibility anchor at the new ref via `git fetch` + `git show <sha>:.crew-version` (never the working tree) and orders dashboard-led: a declared newer crew files the crew upgrade task FIRST and the dashboard task notes it follows the crew upgrade (declaration bypasses `update_channel`, not the `auto_update_crew=false` opt-out); a declared older crew skips the dashboard leg entirely as a human decision; a missing/invalid/unfetchable anchor fails open to the dashboard leg as today. Idempotency via the same state file (records at file time); check failures log to `$CREW_HOME/update-watch.log` and are never thrown. Safety: only files tasks — never deploys, never touches the artifact/config/scheduler. Run by the daily `crew-update-watch` cron through the `current` symlink (latest release); deliberately NOT in the lib-pinning `PIN_BASENAMES`.
32
34
  - `gitignore.js` — deterministic .gitignore management for crew-owned paths (2026-09-17): the crew touches exactly one user-owned file outside `.orchestration/` — the repo's `.gitignore`. `ensureGitignoreEntries(repoPath, entries)` creates the file when missing, appends missing entries (exact line match, no duplicates), preserves existing content byte-for-byte, and is idempotent. `describeGitignoreChange(repoPath, entries)` renders the exact diff for the setup consent conversation. Crew-owned entries: `.worktrees/`, `.orchestration/user/`. CLI: `--repo <path> [--dry-run]`.
33
35
  - `repo-orchestration.js` — repository-local `.orchestration/` scaffold (2026-09-17): `scaffoldRepoOrchestration(repoPath, crewRepoPath)` creates `$REPO/.orchestration/{workflows,identities,phases,user}/`, seeds workflows/identities/phases from the crew repo's platform defaults with no-clobber semantics (existing project customizations never overwritten), and writes a README in `user/` explaining it's for local config. Idempotent. CLI: `--repo <path> --crew-repo <path>`.
34
- - `setup-project-repo.js` — repo-local setup composer for a registered project (2026-09-17, setup-commit 2026-09-18): runs the `.orchestration/` scaffold plus the `.gitignore` update (only with explicit `--consent`), then COMMITS both via `commit-scaffold.js` so the repo's main checkout is clean when setup finishes (room #13: an uncommitted scaffold deterministically broke the first task's Integrate). Refuses loudly when `.gitignore` has pre-existing uncommitted changes (never sweeps the user's edits into the setup commit); `.orchestration/` is crew-owned so pre-existing content there is committed, not refused. The CLI-path equivalent of crew-init's dashboard-mode repo setup; the dashboard calls the same script. No new API endpoint — composition of existing deterministic modules. CLI: `--repo <path> --crew-repo <path> [--consent]`; prints `{ orchestration, gitignore, commit }`, exits non-zero on any failure.
36
+ - `setup-project-repo.js` — repo-local setup composer for a registered project (2026-09-17, setup-commit 2026-09-18): runs the `.orchestration/` scaffold plus the `.gitignore` update (only with explicit `--consent`), then COMMITS both via `commit-scaffold.js` so the repo's primary checkout is clean when setup finishes (room #13: an uncommitted scaffold deterministically broke the first task's Integrate). Refuses loudly when `.gitignore` has pre-existing uncommitted changes (never sweeps the user's edits into the setup commit); `.orchestration/` is crew-owned so pre-existing content there is committed, not refused. The CLI-path equivalent of crew-init's dashboard-mode repo setup; the dashboard calls the same script. No new API endpoint — composition of existing deterministic modules. CLI: `--repo <path> --crew-repo <path> [--consent]`; prints `{ orchestration, gitignore, commit }`, exits non-zero on any failure.
35
37
  - `commit-scaffold.js` — deterministic setup-scaffold commit (2026-09-18): stages only `.orchestration/` (+ `.gitignore` with `--gitignore`) and commits with a message naming exactly what was staged. Idempotent (`nothing-to-commit`, never an empty commit); not-a-git-repo is a clean no-op; missing `.orchestration/` and commit failures (e.g. no git identity) throw loudly — the commit belongs to the user, so no identity is ever invented here. Exports `isPathDirty` (porcelain check for one path) for the composer's pre-mutation guard. CLI: `--repo <path> [--gitignore]`.
36
38
  - `sample-project.js` — optional sample-project setup (2026-09-17): extracts `seed/sample-project.tar.gz` (stripping the `sample-project/` prefix), `git init`, initial commit. No remote required. Registration happens via the existing `create-project` action — no sample-specific endpoint. CLI: `--tarball <path> --dest <dir> --name <project-name>`.
37
39
  - `compute-publish-diff.js` — deterministic publish-diff computer (2026-09-18, room #14): replaces the agent-ferried `git diff` JSON string field (the JSON ferry dropped a valid 700-line diff — "Publish diff parsed to zero files"). Runs `git diff <base> <HEAD>` via argv (no shell), writes the raw diff bytes to `--out`, and prints only a small JSON summary (`commit`, `base`, `diff_file`, `sha256`, `bytes`, `file_count`, `changed_lines` = added+removed, never raw diff output lines, `files`, `has_binary`, `has_rename`). Empty-tree base is the first-publish ancestor (hardcoded, never `merge-base` on the empty tree); non-ancestor is semantic JSON (`ok:false, error:"not-ancestor"`, exit 0); usage errors exit 2 with nothing on stdout. Pinned per-run alongside the other lifecycle files (`COMPUTE_DIFF` in `PIN_BASENAMES`) so a mid-run release swap cannot change it under the workflow.
package/lib/crew-api.js CHANGED
@@ -1721,13 +1721,15 @@ function latestPublishNote(db, taskId) {
1721
1721
  ).get(taskId) || null;
1722
1722
  }
1723
1723
 
1724
- // The trigger anchor for an unknown attempt: the "submitted" ledger entry
1725
- // for the same attempt, recorded when the trigger went out. Returns the
1726
- // entry or null. A null means the trigger instant is unknowable (or the
1727
- // trigger was never issued) — classification cannot run; the task stays
1728
- // parked for human attention. Fail closed, never guess the anchor.
1724
+ // The trigger anchor for an unknown attempt: the OLDEST "submitted" ledger
1725
+ // entry for the same attempt (the trigger issuance). Oldest binds the trigger
1726
+ // instant — verify-publish.js and the decision doc bind oldest; binding newest
1727
+ // misclassifies builds that complete between issuance and receipt (A2/R2/O4).
1728
+ // Returns the entry or null. A null means the trigger instant is unknowable
1729
+ // (or the trigger was never issued) — classification cannot run; the task
1730
+ // stays parked for human attention. Fail closed, never guess the anchor.
1729
1731
  function findTriggerEntry(entries, commit, attempt) {
1730
- for (let i = entries.length - 1; i >= 0; i--) {
1732
+ for (let i = 0; i < entries.length; i++) {
1731
1733
  const e = entries[i];
1732
1734
  if (e.outcome === "submitted" && e.commit === commit &&
1733
1735
  (e.attempt || null) === (attempt || null)) {
@@ -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,7 +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
- PUSH_OUT="$(git -C "$REPO_PATH" push origin main 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)" \
285
322
  || fail "push" "$PUSH_OUT"
286
323
  echo "PUBLISH_PUSHED=1"
287
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
 
@@ -0,0 +1,416 @@
1
+ #!/bin/bash
2
+ # test-detached-integrate.sh — behavioral regression tests for the
3
+ # detached-HEAD Integrate fix (room #21, J3, 2026-09-19).
4
+ #
5
+ # The defect: cmd_integrate ran require_clean_main, whose unconditional
6
+ # `git checkout main` abandoned a detached-HEAD room line (reviewed SHA +
7
+ # crew-init commit). Publish then failed closed on ancestry and the journey
8
+ # parked. The fix: the integration target is the repo's current checkout
9
+ # (branch name, or HEAD when detached); nothing in the lifecycle ever
10
+ # checks out a branch.
11
+ #
12
+ # Fixture A (detached HEAD — the room #21 shape):
13
+ # reviewed commit A, detached HEAD, crew-init commit B on top, task
14
+ # branch from B with work commit C. Integrate must:
15
+ # 1. report integration-target = HEAD
16
+ # 2. MERGE with exit 0
17
+ # 3. leave HEAD detached (no checkout happened)
18
+ # 4. keep A (reviewed SHA) and B (crew-init) as ancestors of HEAD —
19
+ # the exact J3 failure was B being abandoned
20
+ # 5. land C (the task work) as an ancestor of HEAD via a real merge
21
+ # commit with two parents
22
+ # 6. NOT move the main branch (it still points at A)
23
+ # 7. write integration_target=HEAD in the merge record
24
+ # 8. verify-merge → VERIFIED
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)
27
+ #
28
+ # Fixture B (branch — the old behavior is preserved):
29
+ # repo on main with a local bare origin. Integrate merges, reconciles,
30
+ # and pushes inline; push-target also PUSHEDs to origin.
31
+ #
32
+ # Fixture C (detached HEAD, live-tip verify-merge — 2026-09-19 REVIEW):
33
+ # verify-merge must check the record against the LIVE HEAD, never the
34
+ # record's word for the tip: a detached line reset past the merge does
35
+ # NOT verify; with the branch gone it verifies only when the recorded
36
+ # commit is an ancestor of the live tip.
37
+ #
38
+ # Fixture D (branch, no remote — 2026-09-19 REVIEW blocker 3):
39
+ # the old push-target ran `git push origin` with no origin → exit 128
40
+ # mislabeled PUSH_REJECTED. Now the inline push fails soft with
41
+ # NO_REMOTE_PUSH and exit 0.
42
+ #
43
+ # Fixture E (staged-base guard — 2026-09-19 REVIEW #10):
44
+ # CREW_STAGED_BASE naming a non-ancestor fails closed with
45
+ # STAGED_BASE_MISMATCH before any merge; the true staged base proceeds
46
+ # with STAGED_BASE_OK.
47
+ #
48
+ # Fixture F (merged-but-unpushed retry recovery — 2026-09-19 REVIEW):
49
+ # a prior attempt merged but never pushed (lock released to simulate the
50
+ # crash): re-running integrate takes the lock and pushes instead of
51
+ # parking on MERGED_EMPTY.
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
+ #
61
+ # Hermetic: scratch dirs under /tmp only, removed at start (and end).
62
+ set -u
63
+
64
+ REPO_DIR="$(cd "$(dirname "$0")/.." && pwd)"
65
+ LIFECYCLE="$REPO_DIR/lib/worktree-lifecycle.sh"
66
+
67
+ fail() { echo "FAIL: $1"; exit 1; }
68
+
69
+ T=/tmp/crew-detached-integrate-test
70
+ rm -rf "$T"
71
+ mkdir -p "$T"
72
+
73
+ # ---------------- Fixture A: detached HEAD ----------------
74
+ A_DIR="$T/fixture-a"
75
+ mkdir -p "$A_DIR"
76
+ cd "$A_DIR"
77
+ git init -q -b main .
78
+ git config user.email test@test.t
79
+ git config user.name test
80
+ echo reviewed > f.txt
81
+ git add -A
82
+ git commit -qm "reviewed base"
83
+ A_SHA=$(git rev-parse HEAD)
84
+
85
+ # Detach at the reviewed SHA (clean-room staging pins it), then commit the
86
+ # crew-init stamp on top — the detached line Integrate must not abandon.
87
+ git checkout -q --detach HEAD
88
+ echo crew-init >> f.txt
89
+ git commit -qam "crew-init: stamp provenance"
90
+ B_SHA=$(git rev-parse HEAD)
91
+ git symbolic-ref -q HEAD >/dev/null && fail "fixture A: HEAD should be detached"
92
+
93
+ export CREW_REPO="$A_DIR"
94
+ export CREW_HOME="$A_DIR/home"
95
+ export CREW_LIB="$REPO_DIR/lib" # merge-lock.sh lives in the repo's lib, not the scratch crew home
96
+ tid="detached1"
97
+
98
+ # 1. integration-target resolves to HEAD
99
+ out=$(bash "$LIFECYCLE" integration-target) || fail "integration-target exited non-zero: $out"
100
+ [ "$out" = "HEAD" ] || fail "integration-target: expected HEAD, got: $out"
101
+
102
+ # prepare + task work
103
+ out=$(bash "$LIFECYCLE" prepare "$tid") || fail "prepare exited non-zero: $out"
104
+ echo task-work >> "$A_DIR/.worktrees/$tid/f.txt"
105
+ (cd "$A_DIR/.worktrees/$tid" && git commit -qam "task work")
106
+ C_SHA=$(cd "$A_DIR/.worktrees/$tid" && git rev-parse HEAD)
107
+
108
+ # 2. integrate
109
+ out=$(bash "$LIFECYCLE" integrate "$tid" "merge: $tid" 2>&1) || fail "integrate exited non-zero: $out"
110
+ echo "$out" | grep -q '^MERGED:' || fail "integrate: expected MERGED, got: $out"
111
+ echo "$out" | grep -q 'NO_REMOTE_RECONCILE: detached HEAD' || fail "integrate: expected NO_REMOTE_RECONCILE, got: $out"
112
+
113
+ # 3. HEAD is still detached — no checkout happened
114
+ git symbolic-ref -q HEAD >/dev/null && fail "integrate checked out a branch: HEAD is now attached"
115
+
116
+ # 4. the detached line survived: reviewed SHA and crew-init are ancestors
117
+ git merge-base --is-ancestor "$A_SHA" HEAD || fail "reviewed SHA $A_SHA is no longer an ancestor of HEAD"
118
+ git merge-base --is-ancestor "$B_SHA" HEAD || fail "crew-init commit $B_SHA was abandoned (the J3 defect)"
119
+
120
+ # 5. the task work landed via a real merge commit
121
+ git merge-base --is-ancestor "$C_SHA" HEAD || fail "task commit $C_SHA is not an ancestor of HEAD"
122
+ MERGE_SHA=$(git rev-parse HEAD)
123
+ parents=$(git rev-list --parents -n 1 "$MERGE_SHA" | wc -w)
124
+ [ "$parents" -eq 3 ] || fail "expected a 2-parent merge commit, got $parents words in: $(git rev-list --parents -n 1 "$MERGE_SHA")"
125
+
126
+ # 6. main was not touched
127
+ MAIN_SHA=$(git rev-parse main)
128
+ [ "$MAIN_SHA" = "$A_SHA" ] || fail "main moved during integrate: $MAIN_SHA != reviewed $A_SHA"
129
+
130
+ # 7. merge record names the target
131
+ rec=$(bash "$LIFECYCLE" merge-record "$tid")
132
+ echo "$rec" | grep -q '^integration_target=HEAD$' || fail "merge record missing integration_target=HEAD: $rec"
133
+
134
+ # 8. verify-merge
135
+ out=$(bash "$LIFECYCLE" verify-merge "$tid") || fail "verify-merge exited non-zero: $out"
136
+ echo "$out" | grep -q '^VERIFIED:' || fail "verify-merge: expected VERIFIED, got: $out"
137
+
138
+ # 9. push-target on a detached HEAD with no remote: loud NO_REMOTE_PUSH
139
+ out=$(bash "$LIFECYCLE" push-target "$tid") || fail "push-target exited non-zero: $out"
140
+ echo "$out" | grep -q '^NO_REMOTE_PUSH: no origin remote configured' || fail "push-target: expected NO_REMOTE_PUSH, got: $out"
141
+
142
+ echo "fixture A (detached HEAD): all checks passed"
143
+
144
+ # ---------------- Fixture C: verify-merge checks the record against the LIVE tip ----------------
145
+ # (2026-09-19 REVIEW blockers 1+2: the old verify-merge compared the merge
146
+ # record against itself for target=HEAD — is-ancestor(X, X) is a tautology
147
+ # that certified merges no longer on the line.)
148
+ # Still in fixture A's repo ($A_DIR); task detached1 is merged at $MERGE_SHA.
149
+ cd "$A_DIR"
150
+ export CREW_REPO="$A_DIR"
151
+ export CREW_HOME="$A_DIR/home"
152
+
153
+ # C1: reset the detached line backward past the merge — the merge is no
154
+ # longer on the line. verify-merge must NOT verify.
155
+ git reset -q --hard "$B_SHA"
156
+ out=$(bash "$LIFECYCLE" verify-merge "$tid" 2>&1) && fail "C1: verify-merge exited 0 for a merge no longer on the line: $out"
157
+ echo "$out" | grep -q '^VERIFIED:' && fail "C1: VERIFIED a reset-away merge: $out"
158
+ echo "C1: verify-merge rejects a detached line reset past the merge"
159
+
160
+ # C2: restore the merge, remove the worktree, delete the task branch
161
+ # (merge-lease reclaim path). verify-merge must VERIFY from the record —
162
+ # but only because the recorded commit is an ancestor of the LIVE tip.
163
+ git reset -q --hard "$MERGE_SHA"
164
+ BRANCH=$(bash "$LIFECYCLE" resolve-branch "$tid") || fail "C2: resolve-branch failed"
165
+ git worktree remove --force "$A_DIR/.worktrees/$tid" || fail "C2: worktree remove failed"
166
+ git branch -q -D "$BRANCH" || fail "C2: could not delete task branch $BRANCH"
167
+ out=$(bash "$LIFECYCLE" verify-merge "$tid" 2>&1) || fail "C2: verify-merge exited non-zero: $out"
168
+ echo "$out" | grep -q '^VERIFIED:' || fail "C2: expected VERIFIED via record fallback, got: $out"
169
+ echo "$out" | grep -q 'gone but recorded merge commit' || fail "C2: expected the branch-gone record path, got: $out"
170
+ echo "fixture C (live-tip verify-merge): all checks passed"
171
+
172
+ # ---------------- Fixture B: branch with a local origin ----------------
173
+ B_DIR="$T/fixture-b"
174
+ REMOTE="$T/remote.git"
175
+ mkdir -p "$B_DIR"
176
+ git init -q --bare "$REMOTE"
177
+ cd "$B_DIR"
178
+ git init -q -b main .
179
+ git config user.email test@test.t
180
+ git config user.name test
181
+ echo base > f.txt
182
+ git add -A
183
+ git commit -qm "base"
184
+ git remote add origin "$REMOTE"
185
+ git push -q origin main
186
+
187
+ export CREW_REPO="$B_DIR"
188
+ export CREW_HOME="$B_DIR/home"
189
+ tid="branch1"
190
+
191
+ out=$(bash "$LIFECYCLE" integration-target) || fail "B integration-target exited non-zero"
192
+ [ "$out" = "main" ] || fail "B integration-target: expected main, got: $out"
193
+
194
+ out=$(bash "$LIFECYCLE" prepare "$tid") || fail "B prepare exited non-zero: $out"
195
+ echo task-work >> "$B_DIR/.worktrees/$tid/f.txt"
196
+ (cd "$B_DIR/.worktrees/$tid" && git commit -qam "task work")
197
+
198
+ out=$(bash "$LIFECYCLE" integrate "$tid" "merge: $tid" 2>&1) || fail "B integrate exited non-zero: $out"
199
+ echo "$out" | grep -q '^MERGED:' || fail "B integrate: expected MERGED, got: $out"
200
+ echo "$out" | grep -q '^RECONCILED: merged origin/main into main' || fail "B integrate: expected RECONCILED, got: $out"
201
+
202
+ rec=$(bash "$LIFECYCLE" merge-record "$tid")
203
+ echo "$rec" | grep -q '^integration_target=main$' || fail "B merge record missing integration_target=main: $rec"
204
+
205
+ out=$(bash "$LIFECYCLE" verify-merge "$tid") || fail "B verify-merge exited non-zero: $out"
206
+ echo "$out" | grep -q '^VERIFIED:' || fail "B verify-merge: expected VERIFIED, got: $out"
207
+
208
+ # push-target pushes the branch to the local origin
209
+ out=$(bash "$LIFECYCLE" push-target "$tid" 2>&1) || fail "B push-target exited non-zero: $out"
210
+ echo "$out" | grep -q '^PUSHED: origin/main' || fail "B push-target: expected PUSHED, got: $out"
211
+ LOCAL_MAIN=$(git rev-parse main)
212
+ REMOTE_MAIN=$(git --git-dir="$REMOTE" rev-parse refs/heads/main)
213
+ [ "$LOCAL_MAIN" = "$REMOTE_MAIN" ] || fail "B origin/main not updated: $REMOTE_MAIN != $LOCAL_MAIN"
214
+
215
+ echo "fixture B (branch + origin): all checks passed"
216
+
217
+ # ---------------- Fixture D: branch target, no remote ----------------
218
+ # (2026-09-19 REVIEW blocker 3: push-target ran `git push origin` with no
219
+ # origin → exit 128 mislabeled PUSH_REJECTED. Now: fail-soft NO_REMOTE_PUSH.)
220
+ D_DIR="$T/fixture-d"
221
+ mkdir -p "$D_DIR"
222
+ cd "$D_DIR"
223
+ git init -q -b main .
224
+ git config user.email test@test.t
225
+ git config user.name test
226
+ echo base > f.txt
227
+ git add -A
228
+ git commit -qm "base"
229
+ export CREW_REPO="$D_DIR"
230
+ # CREW_HOME lives OUTSIDE the repo (as in real rooms): the lock files are
231
+ # crew state, and nesting them in the repo would trip the integrate
232
+ # preflight on the retry in fixture F.
233
+ export CREW_HOME="$T/home-d"
234
+ tid="noremote1"
235
+
236
+ out=$(bash "$LIFECYCLE" prepare "$tid") || fail "D prepare failed: $out"
237
+ echo work >> "$D_DIR/.worktrees/$tid/f.txt"
238
+ (cd "$D_DIR/.worktrees/$tid" && git commit -qam "work")
239
+
240
+ # integrate pushes inline now: MERGED + NO_REMOTE (reconcile) + NO_REMOTE_PUSH
241
+ out=$(bash "$LIFECYCLE" integrate "$tid" "merge: $tid" 2>&1) || fail "D integrate exited non-zero: $out"
242
+ echo "$out" | grep -q '^MERGED:' || fail "D: expected MERGED, got: $out"
243
+ echo "$out" | grep -q '^NO_REMOTE: no origin/main' || fail "D: expected NO_REMOTE reconcile skip, got: $out"
244
+ echo "$out" | grep -q '^NO_REMOTE_PUSH: no origin remote configured' || fail "D: expected NO_REMOTE_PUSH, got: $out"
245
+
246
+ # push-target directly (record exists, lock still held from integrate):
247
+ # same fail-soft, exit 0
248
+ out=$(bash "$LIFECYCLE" push-target "$tid" 2>&1) || fail "D push-target exited non-zero: $out"
249
+ echo "$out" | grep -q '^NO_REMOTE_PUSH' || fail "D push-target: expected NO_REMOTE_PUSH, got: $out"
250
+
251
+ echo "fixture D (branch, no remote): all checks passed"
252
+
253
+ # ---------------- Fixture E: staged-base guard ----------------
254
+ # (2026-09-19 REVIEW #10: a stray checkout redirecting the line must fail
255
+ # closed at Integrate — before the merge lands on the wrong line.)
256
+ E_DIR="$T/fixture-e"
257
+ mkdir -p "$E_DIR"
258
+ cd "$E_DIR"
259
+ git init -q -b main .
260
+ git config user.email test@test.t
261
+ git config user.name test
262
+ echo reviewed > f.txt
263
+ git add -A
264
+ git commit -qm "reviewed"
265
+ E_BASE=$(git rev-parse HEAD)
266
+ git checkout -q --detach HEAD
267
+ echo init >> f.txt
268
+ git commit -qam "crew-init"
269
+ E_LINE=$(git rev-parse HEAD)
270
+ export CREW_REPO="$E_DIR"
271
+ export CREW_HOME="$E_DIR/home"
272
+ tid="stagedbase1"
273
+
274
+ out=$(bash "$LIFECYCLE" prepare "$tid") || fail "E prepare failed: $out"
275
+ echo work >> "$E_DIR/.worktrees/$tid/f.txt"
276
+ (cd "$E_DIR/.worktrees/$tid" && git commit -qam "work")
277
+
278
+ # A base that is NOT an ancestor of the detached line: a sibling commit on
279
+ # main (the shape a stray `git checkout main` leaves behind).
280
+ git checkout -q main
281
+ echo stray >> f.txt
282
+ git commit -qam "stray main commit"
283
+ STRAY_SHA=$(git rev-parse HEAD)
284
+ git checkout -q --detach "$E_LINE"
285
+
286
+ out=$(CREW_STAGED_BASE="$STRAY_SHA" bash "$LIFECYCLE" integrate "$tid" "merge: $tid" 2>&1) && \
287
+ fail "E: integrate with a non-ancestor staged base should fail: $out"
288
+ echo "$out" | grep -q '^STAGED_BASE_MISMATCH' || fail "E: expected STAGED_BASE_MISMATCH, got: $out"
289
+ [ "$(git rev-parse HEAD)" = "$E_LINE" ] || fail "E: HEAD moved despite STAGED_BASE_MISMATCH"
290
+
291
+ # The real staged base is an ancestor — the merge proceeds.
292
+ out=$(CREW_STAGED_BASE="$E_BASE" bash "$LIFECYCLE" integrate "$tid" "merge: $tid" 2>&1) || \
293
+ fail "E: integrate with the true staged base failed: $out"
294
+ echo "$out" | grep -q '^STAGED_BASE_OK' || fail "E: expected STAGED_BASE_OK, got: $out"
295
+ echo "$out" | grep -q '^MERGED:' || fail "E: expected MERGED, got: $out"
296
+
297
+ echo "fixture E (staged-base guard): all checks passed"
298
+
299
+ # ---------------- Fixture F: merged-but-unpushed retry recovery ----------------
300
+ # (2026-09-19 REVIEW: a prior attempt's merge landed but its push never
301
+ # completed — the retry must push instead of parking on MERGED_EMPTY.)
302
+ # Reuse fixture D: task noremote1 is merged (recorded commit == live HEAD)
303
+ # with no origin. Release the lock to simulate the crashed attempt, then
304
+ # re-run integrate: ahead==0 and recorded==live → acquire + push now.
305
+ cd "$D_DIR"
306
+ export CREW_REPO="$D_DIR"
307
+ export CREW_HOME="$T/home-d"
308
+ "$REPO_DIR/lib/merge-lock.sh" release "noremote1" >/dev/null 2>&1 || fail "F: lock release failed"
309
+ out=$(bash "$LIFECYCLE" integrate "noremote1" "merge: noremote1" 2>&1) || \
310
+ fail "F integrate exited non-zero: $out"
311
+ echo "$out" | grep -q 'recovered: merge landed in a prior attempt' || \
312
+ fail "F: expected push recovery, got: $out"
313
+ echo "$out" | grep -q '^NO_REMOTE_PUSH' || fail "F: expected NO_REMOTE_PUSH in recovery, got: $out"
314
+
315
+ # F2: ancestor-based recovery — a prior attempt's push failed AFTER do_push's
316
+ # retry path added a reconcile merge on top of the recorded commit, then
317
+ # failed again. The record names M1 but live HEAD is M2 (M1's descendant).
318
+ # Equality-based recovery would miss this and wrongly report MERGED_EMPTY —
319
+ # a PASS with the push never completed.
320
+ "$REPO_DIR/lib/merge-lock.sh" release "noremote1" >/dev/null 2>&1 || fail "F2: lock release failed"
321
+ git commit -q --allow-empty -m "simulate: do_push retry-reconcile merge" || fail "F2: empty commit failed"
322
+ out=$(bash "$LIFECYCLE" integrate "noremote1" "merge: noremote1" 2>&1) || \
323
+ fail "F2 integrate exited non-zero: $out"
324
+ echo "$out" | grep -q 'recovered: merge landed in a prior attempt' || \
325
+ fail "F2: expected ancestor-based push recovery, got: $out"
326
+ echo "$out" | grep -q '^MERGED_EMPTY' && fail "F2: wrongly MERGED_EMPTY while the push never completed: $out"
327
+
328
+ echo "fixture F (merged-but-unpushed recovery): all checks passed"
329
+
330
+ # ---------------- Fixture G: push-target after MERGED_EMPTY ----------------
331
+ # (2026-09-19 REVIEW #8: MERGED_EMPTY takes no lock and writes no record —
332
+ # push-target must skip loudly, not report a misleading "lock lost".)
333
+ # Reuse fixture D's repo: a task with no commits ahead merges empty.
334
+ cd "$D_DIR"
335
+ export CREW_REPO="$D_DIR"
336
+ export CREW_HOME="$T/home-d"
337
+ tid="empty1"
338
+ out=$(bash "$LIFECYCLE" prepare "$tid") || fail "G prepare failed: $out"
339
+ # no commits in the worktree — nothing ahead of the target
340
+ out=$(bash "$LIFECYCLE" integrate "$tid" "merge: $tid" 2>&1) || fail "G integrate exited non-zero: $out"
341
+ echo "$out" | grep -q '^MERGED_EMPTY' || fail "G: expected MERGED_EMPTY, got: $out"
342
+ out=$(bash "$LIFECYCLE" push-target "$tid" 2>&1) || fail "G push-target exited non-zero: $out"
343
+ echo "$out" | grep -q 'no merge record' || fail "G: expected nothing-merged skip, got: $out"
344
+ echo "$out" | grep -qi 'lock lost' && fail "G: misleading lock-lost error: $out"
345
+
346
+ echo "fixture G (push-target after MERGED_EMPTY): all checks passed"
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
+
415
+ rm -rf "$T"
416
+ echo "PASS: detached-HEAD integrate fixtures"