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 CHANGED
@@ -18,4 +18,4 @@ Muse Crew source repository. The repo is the product; the personal instance (`$C
18
18
  - Git source is authoritative.
19
19
  - Never expose this repo publicly.
20
20
  - Ship implementation and documentation together.
21
- - After editing `workflows/*.js`, run `bash tests/run.sh` — `node --check` does NOT catch syntax errors inside function bodies (V8 lazy preparsing), so it cannot validate workflow edits alone.
21
+ - After editing `workflows/*.js`, run `bash tests/run.sh` — `node --check` does NOT catch syntax errors inside function bodies (V8 lazy preparsing), so it cannot validate workflow edits alone. Worse (2026-09-19): raw `node --check` on the unstripped file is a FALSE NEGATIVE — the file parses as a module (top-level `export`), which masks breakage the release gate's export-strip + async-wrap transform exposes (module/script goal confusion). An unterminated string passed raw `node --check` and was caught only by `publish-verdict-first.test.js`'s loader emulation. After any workflow edit, verify with the true gate: `{ echo "async function __crew_workflow__(args) {"; sed 's/^export //' workflows/<f>.js; echo "}"; } > /tmp/w.js && node --check /tmp/w.js` — or just run the suite.
@@ -1125,15 +1125,15 @@ Applies to: standard, bugfix, chore.
1125
1125
  ```
1126
1126
  // Deterministic integrate verification: the agent cannot self-certify a
1127
1127
  // merge. After the Integrate agent claims success, the workflow confirms
1128
- // mechanically that the task branch tip is an ancestor of main via the
1128
+ // mechanically that the task branch tip is an ancestor of the integration target via the
1129
1129
  // lifecycle script's verify-merge command (which resolves the branch
1130
1130
  // through the crew registry — never by reconstructing "task/"+taskId — so
1131
1131
  // the check cannot verify the wrong branch). The VERIFIED marker is matched
1132
1132
  // by regex on the script's own stdout; agent prose is never read. This
1133
1133
  // closes the hole where an agent reported "merged empty" while approved
1134
1134
  // commits were still stranded on the task branch (bug b1b1f919). A genuine
1135
- // empty-diff Integrate (MERGED_EMPTY: no commits ahead of main) verifies
1136
- // vacuously — the tip is then an ancestor of main. Verification failure is
1135
+ // empty-diff Integrate (MERGED_EMPTY: no commits ahead of the integration target) verifies
1136
+ // vacuously — the tip is then an ancestor of the integration target. Verification failure is
1137
1137
  // an operational step failure, not a park: the dispatcher retries Integrate
1138
1138
  // under its consecutive-failure cap, and the retry finds the commits still
1139
1139
  // on the branch and performs the real merge — self-healing.
@@ -1149,14 +1149,14 @@ Applies to: standard, bugfix, chore.
1149
1149
  ```
1150
1150
  // Already-merged idempotency: a `repo_diff: none (already-merged:
1151
1151
  // <sha>)` declaration is verified mechanically — <sha> must resolve
1152
- // and be an ancestor of main in the configured repo. A fabricated or
1152
+ // and be an ancestor of the integration target in the configured repo. A fabricated or
1153
1153
  // mistaken declaration fails the phase here (the dispatcher retries
1154
1154
  // Build under its consecutive-failure cap); a verified declaration is
1155
1155
  // recorded in alreadyMergedSha for Review's no-diff branch. Without
1156
1156
  // this guard, Build correctly doing nothing left Review with no
1157
1157
  // mechanical way to accept an empty diff, and Cass rejected for "no
1158
- // commits ahead of main — the builder likely forgot to commit" while
1159
- // the deliverable sat on main (canary 2026-09-15, task 1d692d91).
1158
+ // commits ahead of the integration target — the builder likely forgot to commit" while
1159
+ // the deliverable sat on the integration target (canary 2026-09-15, task 1d692d91).
1160
1160
  // The sha is hex-only by construction (extractAlreadyMerged), so
1161
1161
  // interpolating it into the shell command cannot inject.
1162
1162
  ```
@@ -44,7 +44,7 @@ Applies to: standard, bugfix, chore.
44
44
  // that parks or fails must not leak its worktree, branch, or merge lock.
45
45
  // The lifecycle's terminal-cleanup releases the lock unconditionally and
46
46
  // reclaims the worktree+branch ONLY when the task branch is fully merged
47
- // into main (then it is redundant); unmerged work is preserved for the
47
+ // into the integration target (then it is redundant); unmerged work is preserved for the
48
48
  // human by design. Fire-and-forget with one bounded retry — the merge-lock
49
49
  // lease expiry and the orphan sweep are the backstop for a dead transport.
50
50
  ```
package/docs/guide.md CHANGED
@@ -201,7 +201,7 @@ If you don't have a project in mind, the crew can set up a sample: a tiny flashc
201
201
 
202
202
  ### Worktrees
203
203
 
204
- Each task builds in an isolated git worktree at `<repo>/.worktrees/<task_id>`, on a branch named `task/<task_id>`. The lifecycle manager (`lib/worktree-lifecycle.sh`) owns creation and removal — workflows and agents never run raw `git worktree` commands. The crew's own task→worktree registry (`<repo>/.worktrees/.registry/`) is the source of truth for task→branch/path; `prepare` fails closed when `main` is dirty, and `cleanup` is forgiving so a failed run never strands a worktree.
204
+ Each task builds in an isolated git worktree at `<repo>/.worktrees/<task_id>`, on a branch named `task/<task_id>`. The lifecycle manager (`lib/worktree-lifecycle.sh`) owns creation and removal — workflows and agents never run raw `git worktree` commands. The crew's own task→worktree registry (`<repo>/.worktrees/.registry/`) is the source of truth for task→branch/path; `prepare` fails closed when the integration target is dirty, and `cleanup` is forgiving so a failed run never strands a worktree.
205
205
 
206
206
  ## Filing a task
207
207
 
@@ -336,7 +336,7 @@ Configuration (per project, via the existing `getconfig` / `updateconfig` action
336
336
 
337
337
  The crew upgrades itself through the normal dispatch loop. An upgrade task carries a `source:` line in its description naming where the new release comes from:
338
338
 
339
- - `source: repo` (default when absent) — deploys the task project's repo at its current HEAD. This is how you ship repo changes to your running crew: change the code, merge to main, then file an upgrade task pointing at that repo.
339
+ - `source: repo` (default when absent) — deploys the task project's repo at its current HEAD. This is how you ship repo changes to your running crew: change the code, merge into the integration target (the repo's current checkout), then file an upgrade task pointing at that repo.
340
340
  - `source: npm@x.y.z` — installs the published `muse-crew` package at that exact version from the public npm registry. Use this to move the crew onto a known-good release — including one that predates the upgrade workflow itself.
341
341
 
342
342
  Both paths deploy through the stable `crew-release.sh` on persistent disk (never the release under upgrade), then verify mechanically: the live release equals the target, the release registry parses, and `crew-api get-state` works. Handover is automatic — the next polling tick launches the dispatcher from the new release through the `current` symlink. In-flight runs finish on the old release via the per-task lib pin, so an upgrade mid-flight does not break running work.
@@ -379,7 +379,7 @@ Each phase has an assigned identity — a character with a defined personality:
379
379
  | Map | **Mara** | Designer |
380
380
  | Build | **Wren** | Quietest one, trusts the plan |
381
381
  | Review | **Cass** | Fair but exacting — holds the spec as the contract |
382
- | Integrate | **Wren** | Merges the work, pushes `main` to the repo (succeeds vacuously when the task branch is empty — runtime-state deliverable) |
382
+ | Integrate | **Wren** | Merges the work into the integration target and pushes it (succeeds vacuously when the task branch is empty — runtime-state deliverable) |
383
383
  | Publish | **Wren** | Ships the merged code to the publish target (skipped when none). The workflow verifies the side effect mechanically — npm via registry version; artifact via an independent content read-back before the parent stamps provenance (see `docs/publish-verification.md`) — and fails closed if the worker's report and system state disagree |
384
384
  | QA | **Hazel** | Code-blind, persistent, wears persona costumes |
385
385
  | Reproduce | **Hazel** | Reproduces bugs before fixing |
package/lib/AGENTS.md CHANGED
@@ -7,7 +7,8 @@ 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 / PUSH_SKIPPED (detached HEAD — the line is local by design, a policy choice not a git limitation) / NO_REMOTE_PUSH (no origin — fail-soft, 2026-09-19 REVIEW: the old unconditional push exited 128 and mislabeled it); on push failure one fetch+merge retry while the lock is held, then fail closed without asserting a cause (never force-push). 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 reconcile loudly (NO_REMOTE_RECONCILE) — room #21 J3, 2026-09-19 — fail-soft with no remote, fail closed on conflict; `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 PUSH_SKIPPEDs; 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). 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
@@ -31,7 +32,7 @@ Shell scripts for the crew's infrastructure. Called by workflow scripts, cron, a
31
32
  - `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
33
  - `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
34
  - `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.
35
+ - `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
36
  - `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
37
  - `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
38
  - `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.
@@ -281,7 +281,15 @@ LOCK_OUT2="$(CREW_HOME="${CREW_HOME:-}" CREW_REPO="$REPO_PATH" bash "${LIFECYCLE
281
281
  || fail "lock-refresh" "$LOCK_OUT2"
282
282
 
283
283
  # 13. Push the version-bump commit (idempotent: no-op if already pushed).
284
- PUSH_OUT="$(git -C "$REPO_PATH" push origin main 2>&1)" \
284
+ # The destination is the integration target resolved by the lifecycle, not a
285
+ # hardcoded `main` (2026-09-19 REVIEW: the old `git push origin main` was a
286
+ # stale clone of the same hardcoding the integrate fix removed).
287
+ PUSH_TARGET="$(CREW_HOME="${CREW_HOME:-}" CREW_REPO="$REPO_PATH" bash "${LIFECYCLE:?LIFECYCLE is required}" integration-target 2>&1)" \
288
+ || fail "integration-target" "$PUSH_TARGET"
289
+ if [ "$PUSH_TARGET" = "HEAD" ]; then
290
+ fail "push" "detached HEAD: publish-npm cannot push a version-bump commit from a detached checkout"
291
+ fi
292
+ PUSH_OUT="$(git -C "$REPO_PATH" push origin "$PUSH_TARGET" 2>&1)" \
285
293
  || fail "push" "$PUSH_OUT"
286
294
  echo "PUBLISH_PUSHED=1"
287
295
 
@@ -0,0 +1,340 @@
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 → PUSH_SKIPPED (detached HEAD has no remote counterpart)
26
+ #
27
+ # Fixture B (branch — the old behavior is preserved):
28
+ # repo on main with a local bare origin. Integrate merges, reconciles,
29
+ # and pushes inline; push-target also PUSHEDs to origin.
30
+ #
31
+ # Fixture C (detached HEAD, live-tip verify-merge — 2026-09-19 REVIEW):
32
+ # verify-merge must check the record against the LIVE HEAD, never the
33
+ # record's word for the tip: a detached line reset past the merge does
34
+ # NOT verify; with the branch gone it verifies only when the recorded
35
+ # commit is an ancestor of the live tip.
36
+ #
37
+ # Fixture D (branch, no remote — 2026-09-19 REVIEW blocker 3):
38
+ # the old push-target ran `git push origin` with no origin → exit 128
39
+ # mislabeled PUSH_REJECTED. Now the inline push fails soft with
40
+ # NO_REMOTE_PUSH and exit 0.
41
+ #
42
+ # Fixture E (staged-base guard — 2026-09-19 REVIEW #10):
43
+ # CREW_STAGED_BASE naming a non-ancestor fails closed with
44
+ # STAGED_BASE_MISMATCH before any merge; the true staged base proceeds
45
+ # with STAGED_BASE_OK.
46
+ #
47
+ # Fixture F (merged-but-unpushed retry recovery — 2026-09-19 REVIEW):
48
+ # a prior attempt merged but never pushed (lock released to simulate the
49
+ # crash): re-running integrate takes the lock and pushes instead of
50
+ # parking on MERGED_EMPTY.
51
+ #
52
+ # Hermetic: scratch dirs under /tmp only, removed at start (and end).
53
+ set -u
54
+
55
+ REPO_DIR="$(cd "$(dirname "$0")/.." && pwd)"
56
+ LIFECYCLE="$REPO_DIR/lib/worktree-lifecycle.sh"
57
+
58
+ fail() { echo "FAIL: $1"; exit 1; }
59
+
60
+ T=/tmp/crew-detached-integrate-test
61
+ rm -rf "$T"
62
+ mkdir -p "$T"
63
+
64
+ # ---------------- Fixture A: detached HEAD ----------------
65
+ A_DIR="$T/fixture-a"
66
+ mkdir -p "$A_DIR"
67
+ cd "$A_DIR"
68
+ git init -q -b main .
69
+ git config user.email test@test.t
70
+ git config user.name test
71
+ echo reviewed > f.txt
72
+ git add -A
73
+ git commit -qm "reviewed base"
74
+ A_SHA=$(git rev-parse HEAD)
75
+
76
+ # Detach at the reviewed SHA (clean-room staging pins it), then commit the
77
+ # crew-init stamp on top — the detached line Integrate must not abandon.
78
+ git checkout -q --detach HEAD
79
+ echo crew-init >> f.txt
80
+ git commit -qam "crew-init: stamp provenance"
81
+ B_SHA=$(git rev-parse HEAD)
82
+ git symbolic-ref -q HEAD >/dev/null && fail "fixture A: HEAD should be detached"
83
+
84
+ export CREW_REPO="$A_DIR"
85
+ export CREW_HOME="$A_DIR/home"
86
+ export CREW_LIB="$REPO_DIR/lib" # merge-lock.sh lives in the repo's lib, not the scratch crew home
87
+ tid="detached1"
88
+
89
+ # 1. integration-target resolves to HEAD
90
+ out=$(bash "$LIFECYCLE" integration-target) || fail "integration-target exited non-zero: $out"
91
+ [ "$out" = "HEAD" ] || fail "integration-target: expected HEAD, got: $out"
92
+
93
+ # prepare + task work
94
+ out=$(bash "$LIFECYCLE" prepare "$tid") || fail "prepare exited non-zero: $out"
95
+ echo task-work >> "$A_DIR/.worktrees/$tid/f.txt"
96
+ (cd "$A_DIR/.worktrees/$tid" && git commit -qam "task work")
97
+ C_SHA=$(cd "$A_DIR/.worktrees/$tid" && git rev-parse HEAD)
98
+
99
+ # 2. integrate
100
+ out=$(bash "$LIFECYCLE" integrate "$tid" "merge: $tid" 2>&1) || fail "integrate exited non-zero: $out"
101
+ echo "$out" | grep -q '^MERGED:' || fail "integrate: expected MERGED, got: $out"
102
+ echo "$out" | grep -q 'NO_REMOTE_RECONCILE: detached HEAD' || fail "integrate: expected NO_REMOTE_RECONCILE, got: $out"
103
+
104
+ # 3. HEAD is still detached — no checkout happened
105
+ git symbolic-ref -q HEAD >/dev/null && fail "integrate checked out a branch: HEAD is now attached"
106
+
107
+ # 4. the detached line survived: reviewed SHA and crew-init are ancestors
108
+ git merge-base --is-ancestor "$A_SHA" HEAD || fail "reviewed SHA $A_SHA is no longer an ancestor of HEAD"
109
+ git merge-base --is-ancestor "$B_SHA" HEAD || fail "crew-init commit $B_SHA was abandoned (the J3 defect)"
110
+
111
+ # 5. the task work landed via a real merge commit
112
+ git merge-base --is-ancestor "$C_SHA" HEAD || fail "task commit $C_SHA is not an ancestor of HEAD"
113
+ MERGE_SHA=$(git rev-parse HEAD)
114
+ parents=$(git rev-list --parents -n 1 "$MERGE_SHA" | wc -w)
115
+ [ "$parents" -eq 3 ] || fail "expected a 2-parent merge commit, got $parents words in: $(git rev-list --parents -n 1 "$MERGE_SHA")"
116
+
117
+ # 6. main was not touched
118
+ MAIN_SHA=$(git rev-parse main)
119
+ [ "$MAIN_SHA" = "$A_SHA" ] || fail "main moved during integrate: $MAIN_SHA != reviewed $A_SHA"
120
+
121
+ # 7. merge record names the target
122
+ rec=$(bash "$LIFECYCLE" merge-record "$tid")
123
+ echo "$rec" | grep -q '^integration_target=HEAD$' || fail "merge record missing integration_target=HEAD: $rec"
124
+
125
+ # 8. verify-merge
126
+ out=$(bash "$LIFECYCLE" verify-merge "$tid") || fail "verify-merge exited non-zero: $out"
127
+ echo "$out" | grep -q '^VERIFIED:' || fail "verify-merge: expected VERIFIED, got: $out"
128
+
129
+ # 9. push-target skips loudly on a detached HEAD
130
+ out=$(bash "$LIFECYCLE" push-target "$tid") || fail "push-target exited non-zero: $out"
131
+ echo "$out" | grep -q '^PUSH_SKIPPED: detached HEAD' || fail "push-target: expected PUSH_SKIPPED, got: $out"
132
+
133
+ echo "fixture A (detached HEAD): all checks passed"
134
+
135
+ # ---------------- Fixture C: verify-merge checks the record against the LIVE tip ----------------
136
+ # (2026-09-19 REVIEW blockers 1+2: the old verify-merge compared the merge
137
+ # record against itself for target=HEAD — is-ancestor(X, X) is a tautology
138
+ # that certified merges no longer on the line.)
139
+ # Still in fixture A's repo ($A_DIR); task detached1 is merged at $MERGE_SHA.
140
+ cd "$A_DIR"
141
+ export CREW_REPO="$A_DIR"
142
+ export CREW_HOME="$A_DIR/home"
143
+
144
+ # C1: reset the detached line backward past the merge — the merge is no
145
+ # longer on the line. verify-merge must NOT verify.
146
+ git reset -q --hard "$B_SHA"
147
+ out=$(bash "$LIFECYCLE" verify-merge "$tid" 2>&1) && fail "C1: verify-merge exited 0 for a merge no longer on the line: $out"
148
+ echo "$out" | grep -q '^VERIFIED:' && fail "C1: VERIFIED a reset-away merge: $out"
149
+ echo "C1: verify-merge rejects a detached line reset past the merge"
150
+
151
+ # C2: restore the merge, remove the worktree, delete the task branch
152
+ # (merge-lease reclaim path). verify-merge must VERIFY from the record —
153
+ # but only because the recorded commit is an ancestor of the LIVE tip.
154
+ git reset -q --hard "$MERGE_SHA"
155
+ BRANCH=$(bash "$LIFECYCLE" resolve-branch "$tid") || fail "C2: resolve-branch failed"
156
+ git worktree remove --force "$A_DIR/.worktrees/$tid" || fail "C2: worktree remove failed"
157
+ git branch -q -D "$BRANCH" || fail "C2: could not delete task branch $BRANCH"
158
+ out=$(bash "$LIFECYCLE" verify-merge "$tid" 2>&1) || fail "C2: verify-merge exited non-zero: $out"
159
+ echo "$out" | grep -q '^VERIFIED:' || fail "C2: expected VERIFIED via record fallback, got: $out"
160
+ echo "$out" | grep -q 'gone but recorded merge commit' || fail "C2: expected the branch-gone record path, got: $out"
161
+ echo "fixture C (live-tip verify-merge): all checks passed"
162
+
163
+ # ---------------- Fixture B: branch with a local origin ----------------
164
+ B_DIR="$T/fixture-b"
165
+ REMOTE="$T/remote.git"
166
+ mkdir -p "$B_DIR"
167
+ git init -q --bare "$REMOTE"
168
+ cd "$B_DIR"
169
+ git init -q -b main .
170
+ git config user.email test@test.t
171
+ git config user.name test
172
+ echo base > f.txt
173
+ git add -A
174
+ git commit -qm "base"
175
+ git remote add origin "$REMOTE"
176
+ git push -q origin main
177
+
178
+ export CREW_REPO="$B_DIR"
179
+ export CREW_HOME="$B_DIR/home"
180
+ tid="branch1"
181
+
182
+ out=$(bash "$LIFECYCLE" integration-target) || fail "B integration-target exited non-zero"
183
+ [ "$out" = "main" ] || fail "B integration-target: expected main, got: $out"
184
+
185
+ out=$(bash "$LIFECYCLE" prepare "$tid") || fail "B prepare exited non-zero: $out"
186
+ echo task-work >> "$B_DIR/.worktrees/$tid/f.txt"
187
+ (cd "$B_DIR/.worktrees/$tid" && git commit -qam "task work")
188
+
189
+ out=$(bash "$LIFECYCLE" integrate "$tid" "merge: $tid" 2>&1) || fail "B integrate exited non-zero: $out"
190
+ echo "$out" | grep -q '^MERGED:' || fail "B integrate: expected MERGED, got: $out"
191
+ echo "$out" | grep -q '^RECONCILED: merged origin/main into main' || fail "B integrate: expected RECONCILED, got: $out"
192
+
193
+ rec=$(bash "$LIFECYCLE" merge-record "$tid")
194
+ echo "$rec" | grep -q '^integration_target=main$' || fail "B merge record missing integration_target=main: $rec"
195
+
196
+ out=$(bash "$LIFECYCLE" verify-merge "$tid") || fail "B verify-merge exited non-zero: $out"
197
+ echo "$out" | grep -q '^VERIFIED:' || fail "B verify-merge: expected VERIFIED, got: $out"
198
+
199
+ # push-target pushes the branch to the local origin
200
+ out=$(bash "$LIFECYCLE" push-target "$tid" 2>&1) || fail "B push-target exited non-zero: $out"
201
+ echo "$out" | grep -q '^PUSHED: origin/main' || fail "B push-target: expected PUSHED, got: $out"
202
+ LOCAL_MAIN=$(git rev-parse main)
203
+ REMOTE_MAIN=$(git --git-dir="$REMOTE" rev-parse refs/heads/main)
204
+ [ "$LOCAL_MAIN" = "$REMOTE_MAIN" ] || fail "B origin/main not updated: $REMOTE_MAIN != $LOCAL_MAIN"
205
+
206
+ echo "fixture B (branch + origin): all checks passed"
207
+
208
+ # ---------------- Fixture D: branch target, no remote ----------------
209
+ # (2026-09-19 REVIEW blocker 3: push-target ran `git push origin` with no
210
+ # origin → exit 128 mislabeled PUSH_REJECTED. Now: fail-soft NO_REMOTE_PUSH.)
211
+ D_DIR="$T/fixture-d"
212
+ mkdir -p "$D_DIR"
213
+ cd "$D_DIR"
214
+ git init -q -b main .
215
+ git config user.email test@test.t
216
+ git config user.name test
217
+ echo base > f.txt
218
+ git add -A
219
+ git commit -qm "base"
220
+ export CREW_REPO="$D_DIR"
221
+ # CREW_HOME lives OUTSIDE the repo (as in real rooms): the lock files are
222
+ # crew state, and nesting them in the repo would trip the integrate
223
+ # preflight on the retry in fixture F.
224
+ export CREW_HOME="$T/home-d"
225
+ tid="noremote1"
226
+
227
+ out=$(bash "$LIFECYCLE" prepare "$tid") || fail "D prepare failed: $out"
228
+ echo work >> "$D_DIR/.worktrees/$tid/f.txt"
229
+ (cd "$D_DIR/.worktrees/$tid" && git commit -qam "work")
230
+
231
+ # integrate pushes inline now: MERGED + NO_REMOTE (reconcile) + NO_REMOTE_PUSH
232
+ out=$(bash "$LIFECYCLE" integrate "$tid" "merge: $tid" 2>&1) || fail "D integrate exited non-zero: $out"
233
+ echo "$out" | grep -q '^MERGED:' || fail "D: expected MERGED, got: $out"
234
+ echo "$out" | grep -q '^NO_REMOTE: no origin/main' || fail "D: expected NO_REMOTE reconcile skip, got: $out"
235
+ echo "$out" | grep -q '^NO_REMOTE_PUSH: no origin remote configured' || fail "D: expected NO_REMOTE_PUSH, got: $out"
236
+
237
+ # push-target directly (record exists, lock still held from integrate):
238
+ # same fail-soft, exit 0
239
+ out=$(bash "$LIFECYCLE" push-target "$tid" 2>&1) || fail "D push-target exited non-zero: $out"
240
+ echo "$out" | grep -q '^NO_REMOTE_PUSH' || fail "D push-target: expected NO_REMOTE_PUSH, got: $out"
241
+
242
+ echo "fixture D (branch, no remote): all checks passed"
243
+
244
+ # ---------------- Fixture E: staged-base guard ----------------
245
+ # (2026-09-19 REVIEW #10: a stray checkout redirecting the line must fail
246
+ # closed at Integrate — before the merge lands on the wrong line.)
247
+ E_DIR="$T/fixture-e"
248
+ mkdir -p "$E_DIR"
249
+ cd "$E_DIR"
250
+ git init -q -b main .
251
+ git config user.email test@test.t
252
+ git config user.name test
253
+ echo reviewed > f.txt
254
+ git add -A
255
+ git commit -qm "reviewed"
256
+ E_BASE=$(git rev-parse HEAD)
257
+ git checkout -q --detach HEAD
258
+ echo init >> f.txt
259
+ git commit -qam "crew-init"
260
+ E_LINE=$(git rev-parse HEAD)
261
+ export CREW_REPO="$E_DIR"
262
+ export CREW_HOME="$E_DIR/home"
263
+ tid="stagedbase1"
264
+
265
+ out=$(bash "$LIFECYCLE" prepare "$tid") || fail "E prepare failed: $out"
266
+ echo work >> "$E_DIR/.worktrees/$tid/f.txt"
267
+ (cd "$E_DIR/.worktrees/$tid" && git commit -qam "work")
268
+
269
+ # A base that is NOT an ancestor of the detached line: a sibling commit on
270
+ # main (the shape a stray `git checkout main` leaves behind).
271
+ git checkout -q main
272
+ echo stray >> f.txt
273
+ git commit -qam "stray main commit"
274
+ STRAY_SHA=$(git rev-parse HEAD)
275
+ git checkout -q --detach "$E_LINE"
276
+
277
+ out=$(CREW_STAGED_BASE="$STRAY_SHA" bash "$LIFECYCLE" integrate "$tid" "merge: $tid" 2>&1) && \
278
+ fail "E: integrate with a non-ancestor staged base should fail: $out"
279
+ echo "$out" | grep -q '^STAGED_BASE_MISMATCH' || fail "E: expected STAGED_BASE_MISMATCH, got: $out"
280
+ [ "$(git rev-parse HEAD)" = "$E_LINE" ] || fail "E: HEAD moved despite STAGED_BASE_MISMATCH"
281
+
282
+ # The real staged base is an ancestor — the merge proceeds.
283
+ out=$(CREW_STAGED_BASE="$E_BASE" bash "$LIFECYCLE" integrate "$tid" "merge: $tid" 2>&1) || \
284
+ fail "E: integrate with the true staged base failed: $out"
285
+ echo "$out" | grep -q '^STAGED_BASE_OK' || fail "E: expected STAGED_BASE_OK, got: $out"
286
+ echo "$out" | grep -q '^MERGED:' || fail "E: expected MERGED, got: $out"
287
+
288
+ echo "fixture E (staged-base guard): all checks passed"
289
+
290
+ # ---------------- Fixture F: merged-but-unpushed retry recovery ----------------
291
+ # (2026-09-19 REVIEW: a prior attempt's merge landed but its push never
292
+ # completed — the retry must push instead of parking on MERGED_EMPTY.)
293
+ # Reuse fixture D: task noremote1 is merged (recorded commit == live HEAD)
294
+ # with no origin. Release the lock to simulate the crashed attempt, then
295
+ # re-run integrate: ahead==0 and recorded==live → acquire + push now.
296
+ cd "$D_DIR"
297
+ export CREW_REPO="$D_DIR"
298
+ export CREW_HOME="$T/home-d"
299
+ "$REPO_DIR/lib/merge-lock.sh" release "noremote1" >/dev/null 2>&1 || fail "F: lock release failed"
300
+ out=$(bash "$LIFECYCLE" integrate "noremote1" "merge: noremote1" 2>&1) || \
301
+ fail "F integrate exited non-zero: $out"
302
+ echo "$out" | grep -q 'recovered: merge landed in a prior attempt' || \
303
+ fail "F: expected push recovery, got: $out"
304
+ echo "$out" | grep -q '^NO_REMOTE_PUSH' || fail "F: expected NO_REMOTE_PUSH in recovery, got: $out"
305
+
306
+ # F2: ancestor-based recovery — a prior attempt's push failed AFTER do_push's
307
+ # retry path added a reconcile merge on top of the recorded commit, then
308
+ # failed again. The record names M1 but live HEAD is M2 (M1's descendant).
309
+ # Equality-based recovery would miss this and wrongly report MERGED_EMPTY —
310
+ # a PASS with the push never completed.
311
+ "$REPO_DIR/lib/merge-lock.sh" release "noremote1" >/dev/null 2>&1 || fail "F2: lock release failed"
312
+ git commit -q --allow-empty -m "simulate: do_push retry-reconcile merge" || fail "F2: empty commit failed"
313
+ out=$(bash "$LIFECYCLE" integrate "noremote1" "merge: noremote1" 2>&1) || \
314
+ fail "F2 integrate exited non-zero: $out"
315
+ echo "$out" | grep -q 'recovered: merge landed in a prior attempt' || \
316
+ fail "F2: expected ancestor-based push recovery, got: $out"
317
+ echo "$out" | grep -q '^MERGED_EMPTY' && fail "F2: wrongly MERGED_EMPTY while the push never completed: $out"
318
+
319
+ echo "fixture F (merged-but-unpushed recovery): all checks passed"
320
+
321
+ # ---------------- Fixture G: push-target after MERGED_EMPTY ----------------
322
+ # (2026-09-19 REVIEW #8: MERGED_EMPTY takes no lock and writes no record —
323
+ # push-target must skip loudly, not report a misleading "lock lost".)
324
+ # Reuse fixture D's repo: a task with no commits ahead merges empty.
325
+ cd "$D_DIR"
326
+ export CREW_REPO="$D_DIR"
327
+ export CREW_HOME="$T/home-d"
328
+ tid="empty1"
329
+ out=$(bash "$LIFECYCLE" prepare "$tid") || fail "G prepare failed: $out"
330
+ # no commits in the worktree — nothing ahead of the target
331
+ out=$(bash "$LIFECYCLE" integrate "$tid" "merge: $tid" 2>&1) || fail "G integrate exited non-zero: $out"
332
+ echo "$out" | grep -q '^MERGED_EMPTY' || fail "G: expected MERGED_EMPTY, got: $out"
333
+ out=$(bash "$LIFECYCLE" push-target "$tid" 2>&1) || fail "G push-target exited non-zero: $out"
334
+ echo "$out" | grep -q 'no merge record' || fail "G: expected nothing-merged skip, got: $out"
335
+ echo "$out" | grep -qi 'lock lost' && fail "G: misleading lock-lost error: $out"
336
+
337
+ echo "fixture G (push-target after MERGED_EMPTY): all checks passed"
338
+
339
+ rm -rf "$T"
340
+ echo "PASS: detached-HEAD integrate fixtures"