diff --git a/.claude/hooks/protect-files.sh b/.claude/hooks/protect-files.sh
index 662bf266..48c55579 100755
--- a/.claude/hooks/protect-files.sh
+++ b/.claude/hooks/protect-files.sh
@@ -36,8 +36,8 @@ if echo "$path" | grep -Eq '(^|/)apps/admin/'; then
block "apps/admin/ was deleted in WS-3 and must not return" "Use apps/app/ (Organizer SPA, includes Platform Admin under /platform/*)"
fi
-# .claude/ tooling self-modification
-if echo "$path" | grep -Eq '(^|/)\.claude/'; then
+# .claude/ tooling self-modification (allow ephemeral agent worktrees under .claude/worktrees/)
+if echo "$path" | grep -Eq '(^|/)\.claude/' && ! echo "$path" | grep -Eq '(^|/)\.claude/worktrees/'; then
block "tooling self-modification — Bert reviews .claude/ changes by hand" "Open the file in an editor outside Claude Code, or ask Bert to authorize the change explicitly"
fi
diff --git a/CLAUDE.md b/CLAUDE.md
index ea5bf617..e6445e33 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -403,7 +403,8 @@ Rules:
3. One commit per logical unit of work (one feature, one bugfix, one refactor)
4. Never bundle unrelated changes in a single commit
5. Never commit with failing tests
-6. Do NOT push automatically — only commit locally. The developer will push manually.
+6. Push, open PRs, and merge are AUTHORISED for Claude (granted by Bert
+ 2026-06-03), gated on a green merge gate — see "Push & Merge Authority".
Commit message format:
```
@@ -424,3 +425,26 @@ Examples:
- `feat: person tags system with org-level skills and sync endpoint`
- `fix: auth race condition on page refresh`
- `docs: update SCHEMA.md with person_identity_matches table`
+
+### Push & Merge Authority
+
+Claude may push feature branches, open Gitea PRs, and merge them **without
+a separate human approval click**, provided ALL of the following hold:
+
+1. **Green merge gate** — crewli-reviewer `REVIEW VERDICT: PASS` (no MUST
+ FIX), all applicable tests passing, lint + typecheck clean, and for
+ backend changes Larastan clean + the multi-tenancy 403 test present.
+ The gate — not a human click — is the safety mechanism. If any signal
+ is red, Claude does NOT merge; it returns the work to the implementer.
+2. **Never push directly to `main`.** Integrate only via a `--no-ff` merge
+ of a reviewed feature branch (a merged Gitea PR with `merge_style: merge`,
+ or a local `--no-ff` merge then push).
+3. **Never force-push** to `main` or any shared branch.
+4. **Post-merge:** verify the merge landed on `main`, then delete the
+ feature branch locally and remotely (per the rule above).
+5. Still **present the merge gate** for visibility before self-merging, but
+ Claude may proceed to merge once the gate is green rather than blocking
+ on an explicit `merge` reply.
+
+This supersedes the earlier "developer pushes manually" rule and the
+build-module skill's HUMAN GATE 2 (CLAUDE.md takes precedence over skills).
diff --git a/apps/app/src/components-v2/layout/AppSidebar.stories.ts b/apps/app/src/components-v2/layout/AppSidebar.stories.ts
index 1800e2e1..f4eb9e83 100644
--- a/apps/app/src/components-v2/layout/AppSidebar.stories.ts
+++ b/apps/app/src/components-v2/layout/AppSidebar.stories.ts
@@ -68,3 +68,52 @@ export const Collapsed: Story = {
`,
}),
}
+
+/**
+ * Mobile (< lg) drawer, open — MOBILE-SHELL-PARITY visual verification target.
+ *
+ * Verify against the desktop sidebar:
+ * - defect 2: a SINGLE close control (X, "Sluit menu") at the top-right of
+ * the brand row, NOT overlapping the logo. PrimeVue's default header X is
+ * force-hidden (`header: '!hidden'`), so there must be no second X.
+ * - defect 1: the brand row renders EXPANDED (logo + "Crewli" wordmark +
+ * Beta pill) regardless of the desktop collapse state.
+ * - defect 3: the WorkspaceSwitcher is anchored at the BOTTOM of the drawer,
+ * fully visible (not clipped).
+ *
+ * The Drawer mounts only when isMobile (viewport < 1024px). This repo's
+ * Storybook has no viewport addon, so narrow the browser window to < 1024px
+ * (≈ 375px) to render the drawer. The parameters.viewport hint below takes
+ * effect if @storybook/addon-viewport is later added to .storybook/main.ts.
+ */
+export const MobileDrawer: Story = {
+ parameters: {
+ viewport: {
+ viewports: {
+ mobile375: { name: 'Mobile 375', styles: { width: '375px', height: '720px' } },
+ },
+ defaultViewport: 'mobile375',
+ },
+ },
+ decorators: [
+ withPinia(() => {
+ const auth = useAuthStore()
+
+ auth.user = userFixture
+ auth.organisations = [orgA]
+
+ const shellUi = useShellUiStore()
+
+ shellUi.sidebarCollapsed = false
+ shellUi.mobileOpen = true
+ }),
+ ],
+ render: () => ({
+ components: { AppSidebar },
+ template: `
+
+
+
+ `,
+ }),
+}
diff --git a/apps/app/src/components-v2/layout/AppSidebar.vue b/apps/app/src/components-v2/layout/AppSidebar.vue
index 03a7d061..36cdddcc 100644
--- a/apps/app/src/components-v2/layout/AppSidebar.vue
+++ b/apps/app/src/components-v2/layout/AppSidebar.vue
@@ -114,6 +114,43 @@ const mobileVisible = computed({
The children are intentionally repeated (not factored into a slot or
render-fn) — the 3-component block is short and the duplication is
preferable to the indirection of a dynamic component or slot wiring.
+
+ Close control (MOBILE-SHELL-PARITY defect 2): the mobile close affordance
+ lives in SidebarHeader's brand row, which renders an X ("Close menu") on
+ mobile — a single, non-overlapping control in the same top-right slot the
+ desktop sidebar uses for its collapse chevron.
+
+ PrimeVue's OWN default header close-X is suppressed two ways, by design:
+ - `:show-close-icon="false"` stops PrimeVue mounting the default close
+ button at all. This is required for a11y, not just visuals: PrimeVue's
+ Drawer.focus() (no [autofocus] in our slots) falls back to focusing
+ `this.closeButton`; if that button is merely display:none (the earlier
+ `header: 'hidden'` approach), focus() is a no-op and keyboard focus is
+ stranded behind the overlay. With showCloseIcon=false the button is
+ never created, focus() no-ops cleanly, and v-focustrap focuses the
+ first focusable element — the visible brand-row X.
+ - `header: '!hidden'` then collapses the now-empty header band so it adds
+ no space above the content. The important variant beats PrimeVue's base
+ `.p-drawer-header` display (plain `hidden` lost to it in stylesheet
+ order — the original leak that let the default X overlap the brand row).
+ No duplicate control, no overlap, no stranded focus.
+
+ Bottom anchoring (MOBILE-SHELL-PARITY defect 3 — WorkspaceSwitcher):
+ the content pt is a full-height flex COLUMN (`flex flex-col h-full
+ min-h-0`). PrimeVue's @primeuix base styles already give
+ `.p-drawer-left .p-drawer-content { height: 100% }` + `flex-grow: 1`,
+ and `.p-drawer-left .p-drawer { height: 100% }` inside the `height:100%`
+ mask — so the content fills the panel's full viewport height. With the
+ flex column, SidebarNav (`flex-1 min-h-0 overflow-y-auto`) claims the
+ slack and WorkspaceSwitcher (`flex-shrink-0`) anchors to the true bottom,
+ exactly as the desktop