"""Choreographer — composes existing services into intent-verb sequences.

This module has interface signatures only in Phase 0. Each verb's full
implementation lands in its respective phase (Phase 1: dev verbs, Phase 2:
QA verbs, Phase 3: doc + PM verbs, Phase 4: board verbs).

The signatures are stable contracts that the MCP servers and the
/api/v1/flow/* endpoints will call into. Phase 0 wires the dependency
injection so later phases just fill in the bodies.
"""

from __future__ import annotations

import contextlib
from dataclasses import dataclass
from datetime import UTC, datetime
from typing import TYPE_CHECKING, Any, ClassVar, cast
from uuid import UUID

import structlog

from roboco.exceptions import MergeConflictError
from roboco.foundation.policy import lifecycle as spec_module
from roboco.foundation.policy.batch import is_batch_root_subtask, is_batch_umbrella
from roboco.foundation.policy.content import (
    ContentValidationError,
    markers,
    validate_findings,
)
from roboco.foundation.policy.content.validators import reject_trivial
from roboco.services.content_notes import apply_structured_note
from roboco.services.gateway.choreographer import findings as findings_lib
from roboco.services.gateway.choreographer._protocol import actor_context_fields
from roboco.services.gateway.choreographer._verb_runner import VerbRunner
from roboco.services.gateway.choreographer.collision import build_collision_context
from roboco.services.gateway.claim_guards import (
    already_active_guard,
    paused_tasks_guard,
    unmet_dependency_guard,
)
from roboco.services.gateway.envelope import Envelope
from roboco.services.gateway.evidence_builder import (
    BriefingInputs,
    build_context_briefing,
    build_evidence_for_task,
    build_task_handoff,
    shape_memory_query,
)
from roboco.services.gateway.merge_chain import resolve_parent_branch
from roboco.services.gateway.remediation import (
    hint_for_evidence_not_inspected,
    hint_for_missing_doc_files,
    hint_for_missing_journal_decision,
    hint_for_missing_journal_learning,
    hint_for_missing_progress,
    hint_for_missing_qa_notes,
    hint_for_missing_reflect,
    hint_for_open_findings,
    hint_for_short_dev_notes,
    hint_for_short_doc_notes,
    hint_for_short_pr_reviewer_notes,
    hint_for_short_quick_context,
    hint_for_unaddressed_acceptance_criteria,
)
from roboco.services.repositories.review_findings import (
    STATUS_OPEN,
    ReviewFindingsRepository,
)

logger = structlog.get_logger()

if TYPE_CHECKING:
    # The composed ``Choreographer`` resolves mixin helpers (``_project_slug_for``,
    # etc.) via MRO, but ``_LegacyChoreographer`` itself does not inherit
    # ``ChoreographerHelpers`` — so mypy can't see those names on ``self`` here.
    # The cast below reaches the typed view the mixins use (``_Base`` pattern).
    from roboco.models.base import TaskNature
    from roboco.services.gateway.choreographer._protocol import ChoreographerHelpers

# Minimum character length enforced on rich_plan["approach"] by the PM
# sub-tasks gate. Must match the Pydantic min_length on
# IWillPlanRequest.approach. Raised 20→150: plans were vague
# because 20 chars is a one-liner; the approach + sub_tasks are also the
# progress checklist, so they must be substantive.
_PM_APPROACH_MIN_LEN = 150

# Each PM sub_task is a real work step (it becomes a delegate target AND a
# progress-checklist item). A title alone is not a plan — require a
# description that actually says what the step does.
_PM_SUBTASK_DESC_MIN_LEN = 60

# Per-sub_task ceilings — mirror IWillPlanRequest.SubTaskCreate so direct
# service-layer callers (MCP, fixtures) can't bypass the Pydantic boundary
# with a 5000-char description (the bloat defect: descriptions dominated by
# over-long sub-task prose).
_PM_SUBTASK_TITLE_MAX_LEN = 200
_PM_SUBTASK_DESC_MAX_LEN = 600

# Over-decomposition cap (the 2026-07-07 task-quality defect): a plan with
# more than this many sub_tasks should be split into sibling coordination
# tasks, not one giant plan.
_PM_SUBTASKS_MAX = 7


def _thin_subtask_hint(sub_tasks: list[Any]) -> str | None:
    """Return a hint if any PM sub_task is title-only / thin / over-long.

    Each sub_task is a delegate target AND a progress-checklist item, so
    a title with no real description is not a plan. Also caps title +
    description length so a sub_task can't bloat the plan. Returns None
    when every sub_task carries a title and a substantive description.
    """
    for i, st in enumerate(sub_tasks):
        if not isinstance(st, dict):
            return f"sub_task #{i + 1} must be an object {{title, description}}."
        title = str(st.get("title", "")).strip()
        desc = str(st.get("description") or "").strip()
        if not title:
            return f"sub_task #{i + 1} has no title."
        if len(title) > _PM_SUBTASK_TITLE_MAX_LEN:
            return (
                f"sub_task #{i + 1} ('{title[:40]}') title is too long "
                f"({len(title)} chars) — keep titles <= "
                f"{_PM_SUBTASK_TITLE_MAX_LEN} chars."
            )
        if len(desc) < _PM_SUBTASK_DESC_MIN_LEN:
            return (
                f"sub_task #{i + 1} ('{title[:40]}') description is too thin "
                f"({len(desc)} chars) — need >= {_PM_SUBTASK_DESC_MIN_LEN} "
                "characters describing what the step actually does."
            )
        if len(desc) > _PM_SUBTASK_DESC_MAX_LEN:
            return (
                f"sub_task #{i + 1} ('{title[:40]}') description is too long "
                f"({len(desc)} chars) — keep descriptions <= "
                f"{_PM_SUBTASK_DESC_MAX_LEN} chars; split if larger."
            )
    return None


def _normalize_sub_task(st: dict[str, Any], order: int) -> dict[str, Any]:
    """Shape a sub_task entry to panel/src/types/index.ts::SubTask."""
    from uuid import uuid4 as _uuid4

    return {
        "id": str(st.get("id") or _uuid4()),
        "title": str(st.get("title", "")),
        "description": st.get("description") or None,
        "completed": bool(st.get("completed", False)),
        "order": order,
        "estimated_hours": st.get("estimated_hours"),
        "notes": st.get("notes"),
    }


def _normalize_risk(r: dict[str, Any]) -> dict[str, Any]:
    """Shape a risk entry to panel/src/types/index.ts (description/mitigation/severity).

    Accepts either {description, mitigation, severity?} (panel shape) or
    {risk, mitigation} (pre-gateway agent shape). ``severity`` always
    serializes to a non-None string — the TaskPlanResponse schema declares
    ``risks: list[dict[str, str]]`` and a None severity from an agent who
    omits the field triggers a panel-load 500 on every poll.
    """
    description = r.get("description") or r.get("risk") or ""
    severity_raw = r.get("severity")
    severity = str(severity_raw) if severity_raw not in (None, "") else "medium"
    return {
        "description": str(description),
        "mitigation": str(r.get("mitigation", "")),
        "severity": severity,
    }


def _normalize_open_question(q: Any) -> dict[str, Any] | None:
    """Shape an open_question entry to panel shape.

    Returns None for entries we can't interpret (e.g., None or a number).
    Accepts a bare string (the agent's short-form question), {question,
    answered, answer} (pre-gateway shape), or {question, answer,
    answered_by, answered_at} (panel shape).
    """
    if isinstance(q, str):
        return {
            "question": q,
            "answer": None,
            "answered_by": None,
            "answered_at": None,
        }
    if not isinstance(q, dict):
        return None
    return {
        "question": str(q.get("question", "")),
        "answer": q.get("answer"),
        "answered_by": q.get("answered_by"),
        "answered_at": q.get("answered_at"),
    }


def _build_panel_shaped_plan(
    plan_text: str, rich_plan: dict[str, Any]
) -> dict[str, Any]:
    """Build the Task.plan dict in the exact shape the panel UI consumes.

    Panel reference: panel/src/types/index.ts::TaskPlan. Each list entry is
    normalized so the panel renders without optional-field JS errors.
    """
    sub_tasks = [
        _normalize_sub_task(st, i)
        for i, st in enumerate(rich_plan.get("sub_tasks") or [])
        if isinstance(st, dict)
    ]
    risks = [
        _normalize_risk(r)
        for r in (rich_plan.get("risks") or [])
        if isinstance(r, dict)
    ]
    open_questions = [
        normalized
        for q in (rich_plan.get("open_questions") or [])
        if (normalized := _normalize_open_question(q)) is not None
    ]
    return {
        "text": plan_text,
        "approach": rich_plan.get("approach", ""),
        "sub_tasks": sub_tasks,
        "technical_considerations": rich_plan.get("technical_considerations", []),
        "risks": risks,
        "open_questions": open_questions,
    }


def _extract_original_developer(task: Any) -> str | None:
    """Pull the original_developer slug out of a task's quick_context, if any.

    The ``quick_context`` blob carries handoff hints written by prior
    actors; "original_developer:<slug>" is one such hint. Used by the
    spec's self-review precondition (a documenter who is also the
    original developer cannot self-doc).
    """
    return markers.get_original_developer(task)


@dataclass(frozen=True)
class ChoreographerDeps:
    """All service dependencies bundled for Choreographer.

    Frozen dataclass to avoid PLR0913 (too many arguments) and to make
    dependency injection explicit. Each field is typed as Any in Phase 1 —
    per-service Protocol typing lands alongside verb implementations that
    actually exercise the methods.
    """

    task: Any
    work_session: Any
    git: Any
    a2a: Any
    journal: Any
    audit: Any
    evidence_repo: Any
    # Per-cell project routing for the delegate verb. Optional so existing
    # callsites / tests that don't exercise Product routing don't have to plumb
    # it in; when None, delegate falls back to parent-project inheritance.
    product: Any = None
    # Orchestrator access for the rate-limited i_am_blocked path.
    # Implements get_provider_for_agent(slug) -> str | None,
    # get_active_agent_slugs_for_provider(provider) -> list[str], and
    # async mark_waiting_long(slug, waiting_for, task_id, context).
    # Optional: when None the parking step is skipped (e.g. in unit tests
    # that don't need to verify orchestrator interactions).
    orchestrator: Any = None
    # StreamEventBus for publishing RATE_LIMIT_HIT events.
    # Optional so existing callsites that don't exercise the rate-limit path
    # don't have to plumb it in.
    stream_bus: Any = None


@dataclass(frozen=True)
class _ClaimPlanStartContext:
    """Bundle of fields shared by ``i_will_work_on`` / ``i_will_plan`` helpers.

    Both verbs compose the same (claim, set_plan, start) sequence and
    share gating + recovery branches; they only differ in role gate
    (DEV vs PM) and verb name on rejections / next_hint. Frozen so the
    helper sites can't mutate caller state and to keep PLR0913 (too
    many positional args) at bay.
    """

    agent_id: UUID
    task_id: UUID
    task: Any
    role_str: str
    briefing: dict[str, Any]
    plan: str | dict[str, Any] | None
    verb_name: str


@dataclass(frozen=True)
class _IAmDoneContext:
    """Bundle of fields the ``i_am_done`` helper sites all need.

    Frozen so the helper sites can't mutate caller state and to keep
    PLR0913 (too many positional args) at bay across the dispatcher
    body and its recovery branch.
    """

    agent_id: UUID
    task_id: UUID
    task: Any
    role_str: str
    briefing: dict[str, Any]
    notes: str
    resolved_findings: list[dict[str, Any]] | None = None


@dataclass(frozen=True)
class _ReassignedCtx:
    """Bundle of fields the ``_reassigned_rejection`` helper inspects.

    Shared between ``unclaim`` and ``resume``. Frozen so the helper site
    can't mutate caller state and
    to keep PLR0913 (too many positional args) at bay.
    """

    task: Any
    agent_id: UUID
    task_id: UUID
    role_str: str
    briefing: dict[str, Any]
    upstream_hint: str


@dataclass(frozen=True)
class DelegateInputs:
    """Bundle of fields the ``delegate`` verb receives from the route layer.

    Mirrors :data:`roboco.foundation.policy.task_completeness.TASK_AT_CREATE`:
    `task_type` and `nature` have no defaults — the v1 schema enforces both at
    the HTTP boundary, and defaulting here too would let direct
    callers (tests, internal code) silently pick `'code'`/`'technical'` and
    recreate the no-acceptance-criteria deadlock.

    Optional fields (`acceptance_criteria=None`, `nature=None`) survive the
    construction step and are then rejected by the gateway-side
    `task_completeness.check` before reaching `_create_subtask_from_inputs`:
    the rejection takes the form of `Envelope.incomplete_input`
    so the agent receives a structured field-by-field guide.
    """

    title: str
    description: str
    assigned_to: str
    team: str
    task_type: str
    nature: str | None = None
    acceptance_criteria: list[str] | None = None
    estimated_complexity: str = "medium"
    project_id: UUID | None = None
    # Parent AC ids this subtask is responsible for — the decomposition coverage
    # link. Empty/None means the child covers no specific parent criteria yet.
    covers_parent_criteria: list[str] | None = None
    # Dev-task collision surface (multi-level sequencing — edge kind 3). The
    # cell PM states what each dev task touches so the choreographer can run
    # SequencingService and wire the dev-task collision DAG. Optional: a
    # delegate without surfaces joins no collision edges (parallel).
    intends_to_touch: list[str] | None = None
    adds_migration: bool = False
    touches_shared: bool = False
    # Explicit dependency override — wired verbatim as dependency_ids on the
    # created dev task (an edge the surface rules would miss).
    depends_on: list[UUID] | None = None


class Choreographer:
    """Composes existing services into intent-verb sequences.

    Constructor takes a ``ChoreographerDeps`` bundle (DI). Verb methods are
    async. Each returns a standardized Envelope. Implementations land
    progressively: see __init__ docstring.

    Service deps are typed as ``Any`` in Phase 1 — per-service Protocol typing
    lands alongside the verb implementations that exercise the methods.
    """

    def __init__(self, deps: ChoreographerDeps) -> None:
        """Initialize Choreographer with bundled service dependencies.

        Args:
            deps: Frozen dataclass holding all 7 service dependencies.
        """
        self._deps = deps

    # --- Convenience properties so call-sites stay readable ---

    @property
    def task(self) -> Any:
        return self._deps.task

    @property
    def work_session(self) -> Any:
        return self._deps.work_session

    @property
    def git(self) -> Any:
        return self._deps.git

    @property
    def a2a(self) -> Any:
        return self._deps.a2a

    @property
    def journal(self) -> Any:
        return self._deps.journal

    @property
    def audit(self) -> Any:
        return self._deps.audit

    @property
    def evidence_repo(self) -> Any:
        return self._deps.evidence_repo

    @property
    def product(self) -> Any:
        return self._deps.product

    @property
    def orchestrator(self) -> Any:
        return self._deps.orchestrator

    @property
    def stream_bus(self) -> Any:
        return self._deps.stream_bus

    async def _touch(self, task_id: UUID | None) -> None:
        """Best-effort heartbeat write; silent on missing task."""
        if task_id is not None:
            await self.task.heartbeat(task_id)

    async def _record_milestone_progress(
        self,
        task_id: UUID,
        agent_id: UUID,
        message: str,
        percentage: int | None = None,
    ) -> None:
        """Append a server-emitted progress entry on a lifecycle milestone.

        Agents call ``progress()`` inconsistently. Server-side
        auto-emit on natural milestones (open_pr, i_am_done) guarantees
        the panel + audit view always have entries at the major
        transitions, regardless of how chatty the agent is. Best-effort:
        a missing task_id or write failure must not break the verb path
        — progress is observability, not correctness.
        """
        with contextlib.suppress(Exception):
            await self.task.add_progress(
                task_id=task_id,
                agent_id=agent_id,
                message=message,
                percentage=percentage,
            )

    @staticmethod
    def _reassigned_rejection(
        ctx: _ReassignedCtx,
    ) -> Envelope | None:
        """Build the "task reassigned by upstream verb" rejection envelope.

        Shared between ``unclaim`` and ``resume``. The spec doesn't model
        "task got reassigned out from
        under you by an upstream verb" — when the spec gate accepts but
        ``task.assigned_to != agent_id``, this helper produces the
        envelope with the load-bearing "current owner" hint and
        verb-specific upstream remediate text. Returns ``None`` when the
        caller still owns the task.
        """
        task = ctx.task
        if task.assigned_to == ctx.agent_id:
            return None
        current_owner = (
            str(task.assigned_to) if task.assigned_to is not None else "<unassigned>"
        )
        return Envelope.not_authorized(
            message=(
                f"task {ctx.task_id} is no longer assigned to you "
                f"(current owner: {current_owner})"
            ),
            remediate=ctx.upstream_hint,
            context_briefing=ctx.briefing,
        ).with_introspection(task=task, role=ctx.role_str)

    async def _handle_pm_reentry(
        self,
        ctx: _ClaimPlanStartContext,
        t: Any,
        pm_agent_id: UUID,
        task_id: UUID,
        role_str: str,
        briefing: dict[str, Any],
    ) -> Envelope | None:
        """Handle two distinct re-entry contracts for i_will_plan.

        Idempotent heartbeat: the PM already owns the task in in_progress —
        touch the heartbeat and return OK without re-running the spec gate.
        This is the crash-recovery path: a PM container that respawns after a
        mid-run crash re-calls i_will_plan with thin args ("resume") and must
        receive OK so it can proceed from where it left off.

        Crash-recovery claim: task is stuck in claimed after a crash — skip
        re-claim (claimed is not a valid source for the claim transition) and
        run set_plan+start to complete the interrupted sequence.

        Returns None when neither condition applies, signalling the caller to
        continue to the normal claim-plan-start path. PLR0911 budget is the
        secondary reason this lives in a helper; the domain contract above is
        the primary one.
        """
        status = str(t.status)
        if status == "in_progress" and t.assigned_to == pm_agent_id:
            await self._touch(task_id)
            return Envelope.ok(
                status=status,
                task_id=str(task_id),
                next=spec_module._INTENT_VERBS["i_will_plan"].next_hint(t),
                context_briefing=briefing,
            ).with_introspection(task=t, role=role_str)
        if status == "claimed" and t.assigned_to == pm_agent_id:
            envelope = await self._resume_from_claimed(ctx)
            return await self._post_claim_journal_gate(
                "i_will_plan", pm_agent_id, task_id, envelope
            )
        return None

    async def _pm_sub_tasks_gate(
        self,
        *,
        role_str: str,
        rich_plan: dict[str, Any] | None,
        task: Any,
        agent_id: UUID,
        task_id: UUID,
        briefing: dict[str, Any],
    ) -> Envelope | None:
        """Plan-depth gate: PMs must supply a substantive approach + sub_tasks.

        Enforces both fields at the choreographer layer so direct service-layer
        callers (MCP server, test fixtures, orchestrator-internal Python) cannot
        persist a plan that bypassed the HTTP Pydantic boundary.

        A 20-char approach and title-only sub_tasks were "no
        effort" plans. approach must be >= _PM_APPROACH_MIN_LEN and every
        sub_task needs a real title + a description that says what the
        step does (it is both a delegate target and a progress-checklist
        item). Returns a rejection Envelope when the caller is a PM role
        and any field is absent/thin; returns None when the gate passed.
        """
        if role_str not in ("cell_pm", "main_pm"):
            return None
        missing: list[str] = []
        field_hints: dict[str, str] = {}
        approach_raw = (rich_plan or {}).get("approach", "")
        if len(str(approach_raw).strip()) < _PM_APPROACH_MIN_LEN:
            missing.append("approach")
            field_hints["approach"] = (
                f"approach must be a non-empty string of at least "
                f"{_PM_APPROACH_MIN_LEN} characters describing HOW you will "
                "decompose and route this task — not a one-liner."
            )
        sub_tasks = (rich_plan or {}).get("sub_tasks") or []
        if not sub_tasks:
            missing.append("sub_tasks")
            field_hints["sub_tasks"] = (
                "PMs must list at least one sub_task — a non-empty list of "
                "{title, description}. Each becomes a delegate target AND a "
                "progress-checklist item."
            )
        elif thin := _thin_subtask_hint(sub_tasks):
            missing.append("sub_tasks")
            field_hints["sub_tasks"] = thin
        elif len(sub_tasks) > _PM_SUBTASKS_MAX:
            # Over-decomposition cap (the 2026-07-07 task-quality defect):
            # a plan with >7 sub_tasks is one giant plan that should be split
            # into sibling coordination tasks. Per-item title/description
            # bounds are in _thin_subtask_hint above. We deliberately do NOT
            # ban sub_tasks on code tasks (PMs legitimately plan code-typed
            # parents into dev subtasks — the 2026-05-08 rule,
            # test_cell_pm_can_plan_code_typed_parent_via_i_will_plan) nor
            # require >=2 on roots (a single-cell root → one cell task is a
            # legitimate pass-through, test_pm_can_plan_non_code_parent) —
            # both judgment calls the gate can't make without false positives.
            missing.append("sub_tasks")
            field_hints["sub_tasks"] = (
                f"too many sub_tasks ({len(sub_tasks)}) — decompose into "
                f"at most {_PM_SUBTASKS_MAX}. If the work is genuinely "
                "larger, split it into sibling coordination tasks instead "
                "of one giant plan."
            )
        if not missing:
            return None
        return await self._emit_rejection(
            Envelope.incomplete_input(
                missing=missing,
                field_hints=field_hints,
                remediate=(
                    "re-issue i_will_plan(task_id, plan, approach, "
                    "sub_tasks=[{'title': '...', 'description': '...'}, ...]) "
                    f"with approach >= {_PM_APPROACH_MIN_LEN} and <= 800 "
                    f"chars, every sub_task description >= "
                    f"{_PM_SUBTASK_DESC_MIN_LEN} and <= "
                    f"{_PM_SUBTASK_DESC_MAX_LEN} chars, and at most "
                    f"{_PM_SUBTASKS_MAX} sub_tasks."
                ),
                context_briefing=briefing,
            ).with_introspection(task=task, role=role_str),
            agent_id=agent_id,
            task_id=task_id,
            verb="i_will_plan",
        )

    async def _emit_rejection(
        self,
        env: Envelope,
        *,
        agent_id: UUID,
        task_id: UUID | None,
        verb: str,
    ) -> Envelope:
        """Audit-log a rejection envelope; pass through unchanged on success.

        Idempotent on success envelopes: the early `env.error is None`
        return is the only fast path. Audit writes are best-effort —
        failures must NEVER block the verb (the agent's response is the
        contract; the audit row is observability-only).

        Introspection (`current_state` + `valid_next_verbs`) is applied
        at the call site via `Envelope.with_introspection(task, role)`
        rather than here — this keeps the rejection path's signature
        narrow and lets the helper stay framework-clean.

        Stashes ``correlation_id`` from the structlog contextvars (bound
        by ``CorrelationIdMiddleware`` for the inbound request) and a
        per-attempt id into the audit row's ``details`` JSONB. The
        attempt_id is unique per rejection event so post-mortem queries
        can group "all attempts on task X within a window" without
        confusing two distinct calls that share a correlation_id.
        """
        if env.error is None:
            return env
        # Refresh heartbeat on every rejection so an
        # agent stuck in a verb-rejection loop (e.g., tracing_gap while
        # retrying) does not look idle to the reaper. Best-effort: a
        # heartbeat failure must never alter the envelope returned to the
        # agent. _touch already guards task_id=None.
        try:
            await self._touch(task_id)
        except Exception as exc:
            logger.warning(
                "heartbeat touch failed on rejection", error=str(exc), verb=verb
            )
        from uuid import uuid4 as _uuid4

        details: dict[str, Any] = {
            "verb": verb,
            "reason": env.error,
            "message": env.message,
            "missing": env.missing or [],
            "attempt_id": str(_uuid4()),
        }
        if env.remediate:
            # Conventions-gate rejections carry the file:line violation
            # listing ONLY here — without it the audit row is unactionable.
            details["remediate"] = env.remediate
        cid = structlog.contextvars.get_contextvars().get("correlation_id")
        if cid is not None:
            details["correlation_id"] = cid
        try:
            await self.audit.log_event(
                event_type="gateway.rejected",
                agent_id=agent_id,
                task_id=task_id,
                details=details,
            )
        except Exception as exc:
            # Audit is best-effort: it must NEVER block the verb. The agent's
            # response is the contract; the audit row is observability-only.
            logger.warning("audit.log_event failed", error=str(exc), verb=verb)
        return env

    async def _teardown_sandbox_best_effort(self, agent_id: UUID) -> None:
        """Release the caller's request_sandbox sidecar on successful exit.

        Called from the six verbs whose success means the caller's
        engagement with its work has ended (i_am_done, unclaim, i_am_idle,
        pass_review/fail_review, i_documented) — no shared success-emit
        path spans all six (they live across three mixin files, each
        building its own ``Envelope.ok`` at its own site), so this is
        called once at each verb's existing success point rather than
        duplicated teardown logic. No orchestrator (e2e harness, startup)
        is a silent no-op; any other failure is logged, never raised —
        the container-removal teardown + janitor sweep remain the backstop.
        """
        orch = self.orchestrator
        if orch is None:
            return
        from roboco.agents_config import _resolve_to_slug

        try:
            await orch.release_sandbox(_resolve_to_slug(str(agent_id)))
        except Exception as exc:
            logger.warning(
                "sandbox_release_failed", agent_id=str(agent_id), error=str(exc)
            )

    @classmethod
    def _free_text_soup(
        cls, checks: tuple[tuple[str, Any, int], ...]
    ) -> Envelope | None:
        """Return a bare ``invalid_state`` envelope for the first soupy field.

        ``checks`` is a tuple of ``(field_name, value, min_chars)``. A value
        that is ``None`` or empty/whitespace is skipped — presence is gated
        elsewhere; this rejects *filler* in text the agent actually supplied
        (``wip``, ``asdf``, ``tbd``, ``...``). A list value has each item
        checked. The returned envelope carries no introspection or audit row:
        the caller folds it into its existing rejection ``return`` so the soup
        check adds no extra return (verbs stay under the complexity bound), and
        the agent always gets a remediable envelope, never a 422 (which would
        trip the do-server circuit breaker).
        """
        for name, value, min_chars in checks:
            items = value if isinstance(value, list) else [value]
            for idx, item in enumerate(items):
                if item is None or not str(item).strip():
                    continue
                label = f"{name}[{idx}]" if isinstance(value, list) else name
                env = cls._soup_reason(str(item), label, min_chars)
                if env is not None:
                    return env
        return None

    async def _guard_free_text(
        self,
        *,
        checks: tuple[tuple[str, Any, int], ...],
        task: Any,
        agent_id: UUID,
        role_str: str,
        verb: str,
    ) -> Envelope | None:
        """Emit-on-soup wrapper over :meth:`_free_text_soup`.

        For verbs that have return-count headroom: stamps introspection, audits
        via ``_emit_rejection``, and returns the rejection (or ``None`` clean).
        Verbs already at the return bound call ``_free_text_soup`` directly and
        fold the result into an existing rejection return instead.
        """
        env = self._free_text_soup(checks)
        if env is None:
            return None
        return await self._emit_rejection(
            env.with_introspection(task=task, role=role_str),
            agent_id=agent_id,
            task_id=getattr(task, "id", None),
            verb=verb,
        )

    @staticmethod
    def _soup_reason(value: str, field: str, min_chars: int) -> Envelope | None:
        """Build an ``invalid_state`` envelope when ``value`` is filler, else None."""
        try:
            reject_trivial(value, field=field, min_chars=min_chars)
        except ValueError as exc:
            return Envelope.invalid_state(
                message=str(exc),
                remediate=(
                    f"write a substantive {field} (>={min_chars} chars, no filler "
                    "like 'asdf'/'wip'/'tbd'/'...'); state what actually happened."
                ),
                context_briefing={},
            )
        return None

    @staticmethod
    def _soup_or_decision_env(
        soup: Envelope | None, decision: Any, briefing: dict[str, Any]
    ) -> Envelope | None:
        """Pick the rejection envelope: soup first, then the spec decision.

        Lets a verb fold the free-text soup check into its existing
        spec-gate rejection ``return`` with a single branch — the two
        fallback ``or``s live here, keeping the verb body under the
        cyclomatic bound. Returns ``None`` when neither rejects; the caller
        stamps introspection + emits.
        """
        if soup is not None:
            return soup
        if not decision.allowed:
            return Envelope.from_decision(decision, briefing=briefing)
        return None

    # --- Phase 1 (developer) verbs ---

    @staticmethod
    def _claim_verb_hint(role: str, task: Any) -> str:
        """Role + status aware 'how to start this task' hint.

        give_me_work used to hard-code
        ``i_will_work_on(...)`` for every role/status. A documenter
        handed an awaiting_documentation task (or QA an awaiting_qa
        task) was told to call a dev verb it doesn't have — it looped.
        Map to the verb that actually claims the task for this role.
        """
        tid = str(getattr(task, "id", ""))
        status = str(getattr(task, "status", ""))
        if status == "awaiting_documentation":
            return f"call claim_doc_task(task_id='{tid}') to start"
        if status == "awaiting_qa":
            return f"call claim_review(task_id='{tid}') to start"
        if role in ("cell_pm", "main_pm", "product_owner", "head_marketing"):
            return f"call i_will_plan(task_id='{tid}', plan='<plan>') to start"
        return f"call i_will_work_on(task_id='{tid}', plan='<plan>') to start"

    async def _drop_dependency_held(self, tasks: list[Any]) -> list[Any]:
        """Drop pre-assigned PENDING tasks whose non-terminal dependencies are
        still unresolved.

        ``give_me_work``'s ``list_assigned_for_agent`` fallback includes PENDING
        rows with no dependency filter, so without this a held pre-assigned
        subtask (e.g. a frontend dev's task waiting on the UX/UI design) would
        still be offered and the agent only bounced at claim time. Mirrors the
        gate in ``TaskService.list_pending_for_agent`` and ``_run_claim_guards``.
        Only PENDING rows are gated — an already-claimed task is past the gate.
        """
        offerable: list[Any] = []
        for task in tasks:
            dep_ids = list(getattr(task, "dependency_ids", []) or [])
            if (
                str(task.status) == "pending"
                and dep_ids
                and await self._deps.task.unmet_dependency_ids(dep_ids)
            ):
                continue
            offerable.append(task)
        return offerable

    async def give_me_work(self, agent_id: UUID) -> Envelope:
        """Return the agent's most-actionable task or signal idle."""
        agent = await self._deps.task.agent_for(agent_id)
        role = str(agent.role) if agent is not None else "developer"
        # Pre-assigned pending tasks take priority. Smoke run 3 (2026-05-12)
        # showed agents missing tasks that were seeded with assigned_to=<them>
        # and status=pending because the earlier code only walked
        # list_assigned_for_agent (ordered by priority/updated_at — pending
        # could rank behind in_progress rows) and the PM path checked
        # awaiting_* queues but not the pre-assigned pending case.
        pre_assigned = await self._pending_not_lane_held(
            await self._deps.task.list_pending_for_agent(agent_id)
        )
        if pre_assigned:
            t = pre_assigned[0]
            return Envelope.ok(
                status=str(t.status),
                task_id=str(t.id),
                next=self._claim_verb_hint(role, t),
                context_briefing=await self._briefing_for(
                    agent_id, t.id, task=t, full=True
                ),
            ).with_introspection(task=t, role=role)
        assigned = await self._drop_dependency_held(
            await self._deps.task.list_assigned_for_agent(agent_id)
        )
        if assigned:
            t = assigned[0]
            return Envelope.ok(
                status=str(t.status),
                task_id=str(t.id),
                next=self._claim_verb_hint(role, t),
                context_briefing=await self._briefing_for(
                    agent_id, t.id, task=t, full=True
                ),
            ).with_introspection(task=t, role=role)
        paused = await self._deps.task.list_paused_for_agent(agent_id)
        if paused:
            t = paused[0]
            return Envelope.ok(
                status=str(t.status),
                task_id=str(t.id),
                next=f"call resume(task_id='{t.id}') to continue paused work",
                context_briefing=await self._briefing_for(
                    agent_id, t.id, task=t, full=True
                ),
            ).with_introspection(task=t, role=role)
        return Envelope.ok(
            status="idle",
            task_id=None,
            next="call i_am_idle() — no work available",
            context_briefing=await self._briefing_for(agent_id, None),
        )

    async def _briefing_for(
        self,
        agent_id: UUID,
        task_id: UUID | None,
        *,
        task: Any | None = None,
        include_ac_coverage: bool = False,
        full: bool = False,
        include_company_goals: bool = False,
    ) -> dict[str, Any]:
        """Assemble context_briefing for agent_id, optionally scoped to task_id.

        ``full`` gates the heavy, verb-invariant sections (company_goals,
        recent_team_activity, blockers_in_my_lane, task_handoff,
        institutional_memory). Only context-acquisition verbs (give_me_work /
        claim / plan / resume / triage) pass ``full=True``; every other verb —
        progress, transition and rejection envelopes included — gets the slim
        signals-only briefing (unread a2a/mentions/notifications + metadata
        gaps). The agent already holds the heavy context from its claim, and
        every extra copy is re-read at cache-read price on all later turns.

        ``include_company_goals`` is a narrow, cheap-only opt-in for callers
        that want the charter (north_star/brand_voice/…) without paying for
        the rest of ``full``'s heavy sections (team activity, blockers, an
        institutional-memory RAG search). ``company_goals`` is a single
        capped-singleton lookup, so this stays safe on a low-cardinality path
        like ``board_triage``'s idle branch — hit when the Product Owner /
        Head of Marketing's one-shot roadmap / feature-spotlight exploration
        spawn finds no strategic root to review, which is not a "strategic
        root" itself so the ``full=True`` branch never fires for it. A no-op
        when ``full`` is already True (company_goals is already fetched).

        ``task`` is the already-loaded row (every claim / give_me_work / done
        path holds it). The prior-work handoff is built only when it is passed —
        no extra fetch — so task-scoped error paths that carry only an id simply
        omit the digest rather than pay a redundant read for it.

        ``include_ac_coverage`` is set only on PM decomposition touchpoints
        (give_me_work / i_will_plan / delegate / submit_up) so the PM sees, per
        parent criterion, what is still unclaimed and can pass
        ``covers_parent_criteria`` on delegate. Off everywhere else so a leaf's
        own criteria never surface as bogus "unclaimed" noise to a developer.
        It is functional (not bulk), so it stays independent of ``full``.
        """
        repo = self._deps.evidence_repo
        heavy = (
            await self._heavy_briefing_sections(agent_id, task_id, task) if full else {}
        )
        company_goals = await self._resolve_company_goals(
            heavy, full=full, include_company_goals=include_company_goals
        )
        inputs = BriefingInputs(
            unread_a2a=await repo.list_unread_a2a(agent_id),
            unread_mentions=await repo.list_unread_mentions(agent_id),
            pending_notifications=await repo.list_pending_notifications(agent_id),
            task_metadata_gaps=(
                await repo.task_metadata_gaps(task_id) if task_id else []
            ),
            recent_team_activity=heavy.get("recent_team_activity", []),
            blockers_in_my_lane=heavy.get("blockers_in_my_lane", []),
            task_handoff=heavy.get("task_handoff"),
            company_goals=company_goals,
        )
        briefing = build_context_briefing(inputs)
        memory_block = heavy.get("institutional_memory")
        if memory_block is not None:
            # "What the company already knows about work like this" — distilled
            # lessons + approved playbooks, pushed so the agent never has to ask.
            # The block always carries ``institutional_memory_status`` so an agent
            # can tell "searched, nothing" (below_floor / empty) from "search broke"
            # (error) from "subsystem off" (disabled); lessons is empty unless ok.
            briefing = {**briefing, "institutional_memory": memory_block}
        # The collision map: surfaced siblings (same parent) that would collide
        # with this task. A PM planning a batch root sees the other
        # root-subtasks' declared surfaces; a dev claiming a leaf sees its
        # cell-sibling overlaps. No actual files at plan/claim time, so drift is
        # omitted here (the QA/gate envelopes carry it). Best-effort — a failure
        # omits the block, never breaks the briefing.
        briefing = await self._with_collision_briefing(briefing, full, task)
        if include_ac_coverage and task_id is not None:
            coverage = await self.task.parent_ac_coverage(task_id)
            if coverage:
                # Compact decomposition view: surface each parent criterion's id
                # so the PM can map subtasks to it via covers_parent_criteria,
                # and call out the ones still unclaimed. Mirrors how sizing_hint
                # is merged ad-hoc — context_briefing is free-form dict[str, Any].
                briefing = {
                    **briefing,
                    "parent_ac_coverage": coverage,
                    "unclaimed_parent_acs": [
                        c["id"] for c in coverage if not c["claimed"]
                    ],
                }
        return briefing

    async def _collision_context_for(
        self, t: Any, *, actual_files: list[str] | None = None
    ) -> list[dict[str, Any]] | None:
        """The collision map for ``t`` against its surfaced siblings — one
        indexed ``get_subtasks(parent_task_id)`` query + the pure builder.

        ``None`` for a root (no parent) or a fetch failure (best-effort: the
        briefing / envelope omit the block rather than break). The QA and
        PR-gate evidence builders call the pure ``build_collision_context``
        directly (they hold the real touched files); this helper covers the
        planning/claim briefing path, which has no actual files yet.
        """
        parent_id = getattr(t, "parent_task_id", None)
        if not parent_id:
            return None
        try:
            siblings = await self.task.get_subtasks(parent_id)
            return build_collision_context(
                task=t, siblings=siblings, actual_files=actual_files
            )
        except Exception:  # best-effort: omit on any fetch/build failure
            return None

    async def _with_collision_briefing(
        self, briefing: dict[str, Any], full: bool, task: Any
    ) -> dict[str, Any]:
        """Merge the collision block into ``briefing`` when the planning/claim
        path warrants it (``full`` + a real task). Keeps the branch count out of
        ``_briefing_for`` — the collision fetch + merge live here."""
        if not full or task is None:
            return briefing
        collision = await self._collision_context_for(task)
        if not collision:
            return briefing
        return {**briefing, "collision_context": collision}

    async def _resolve_company_goals(
        self,
        heavy: dict[str, Any],
        *,
        full: bool,
        include_company_goals: bool,
    ) -> dict[str, Any] | None:
        """The briefing's company_goals section — split out of ``_briefing_for``
        to keep its complexity down.

        ``full=True`` already resolved it into ``heavy`` (one query, via
        ``_heavy_briefing_sections``); otherwise a standalone cheap fetch runs
        only under the narrower ``include_company_goals`` opt-in. Never both —
        no double query when a caller somehow sets both.
        """
        goals: dict[str, Any] | None
        if full:
            goals = heavy.get("company_goals")
        elif include_company_goals:
            goals = await self._deps.evidence_repo.company_goals()
        else:
            goals = None
        return goals

    async def _heavy_briefing_sections(
        self, agent_id: UUID, task_id: UUID | None, task: Any | None
    ) -> dict[str, Any]:
        """The full-briefing-only sections (see ``_briefing_for``'s docstring).

        The prior-work handoff is built only when the loaded ``task`` row is
        passed — no extra fetch — so task-scoped error paths that carry only an
        id simply omit the digest rather than pay a redundant read for it.
        """
        repo = self._deps.evidence_repo
        task_handoff: dict[str, Any] | None = None
        if task_id is not None and task is not None:
            # Push the prior-work digest so a freshly spawned / respawned agent
            # resumes from the previous worker's PR + commits + journal rather
            # than re-exploring the codebase cold on every lifecycle hand-off.
            handoff_highlights = await repo.journal_highlights_for_task(
                task_id, include_ancestors=True
            )
            open_findings = await findings_lib.open_findings_for_task(
                self.task.session, task_id
            )
            parent_context = await repo.ancestor_context_for_task(task_id)
            task_handoff = build_task_handoff(
                task, handoff_highlights, open_findings, parent_context
            )
        return {
            "recent_team_activity": await repo.recent_team_activity(agent_id),
            "blockers_in_my_lane": await repo.blockers_in_lane(agent_id),
            "task_handoff": task_handoff,
            "company_goals": await repo.company_goals(),
            "institutional_memory": await self._institutional_memory(agent_id, task),
        }

    async def _institutional_memory(
        self, agent_id: UUID, task: Any | None
    ) -> dict[str, Any]:
        """Org-memory keystone: top-K relevant past lessons/playbooks for this
        claim, role-shaped and relevance-floored. Empty unless
        ``org_memory_enabled`` and a task is in hand (nothing to query on
        otherwise). Best-effort — never breaks the briefing.

        Returns ``{"status": ..., "lessons": [...]}`` where status is one of
        ``disabled`` (subsystem off / no task — ponytail: both mean no search ran),
        ``error`` (search raised), ``empty`` (search yielded nothing),
        ``below_floor`` (searched, nothing met the floor), ``ok`` (lessons
        injected). Lessons is empty unless status is ``ok`` — the status is
        additive, the injection behavior is unchanged."""
        from roboco.config import settings as _settings

        if not _settings.org_memory_enabled or task is None:
            return {"status": "disabled", "lessons": []}
        agent = await self.task.agent_for(agent_id)
        role = str(agent.role) if agent is not None else ""
        title = str(getattr(task, "title", "") or "")
        raw_type = getattr(task, "task_type", None)
        if raw_type is None:
            task_type = ""
        elif hasattr(raw_type, "value"):
            task_type = str(raw_type.value)
        else:
            task_type = str(raw_type)
        query = shape_memory_query(role, title, task_type)
        result = await self._deps.evidence_repo.similar_memory(
            query=query,
            top_k=_settings.org_memory_top_k,
            min_score=_settings.org_memory_min_score,
        )
        return {
            "status": str(result.get("status", "error")),
            "lessons": list(result.get("items", [])),
        }

    # PM coordinator roles plan + delegate many roots in parallel; the actual
    # work then runs in the delegated children/cells, not in the PM's own hands.
    # So the single-active-task concurrency invariants (already_active / paused)
    # that keep a *developer* to one task at a time must NOT gate a coordinator
    # — only a genuine upstream sequence dependency (unmet_dependency) may hold a
    # PM's root back. Without this exemption a PM that claimed one root could
    # never plan a second and thrashed between its claimed roots, respawning
    # forever and burning tokens for zero progress.
    _COORDINATOR_ROLES: ClassVar[frozenset[str]] = frozenset({"main_pm", "cell_pm"})

    async def _run_claim_guards(
        self,
        *,
        agent_id: UUID,
        task: Any,
        role_str: str | None = None,
        skip_dev_guards: bool = False,
    ) -> Envelope | None:
        """Run concurrency-invariant claim guards. Returns rejection or None.

        Scope: only system-level concurrency invariants the lifecycle spec
        does NOT model. Role/state/task_type checks now route through
        ``spec.can_invoke_action`` (CLAIM_RULES + ActionSpec.allowed_task_types)
        in the verb's spec gate; the former role-typed and
        pm_cannot_execute_code guards have been deleted.

        ``role_str`` selects whether the single-active-task guards apply: a PM
        coordinator (``_COORDINATOR_ROLES``) is exempt from ``already_active`` /
        ``paused`` (it holds many roots in parallel by design) but the sequence
        guard ``unmet_dependency`` still applies to everyone. A ``None`` role
        keeps the full guards (safe default for non-PM callers).

        ``skip_dev_guards`` skips the dev-only guards (``already_active`` /
        ``paused`` / ``_lane_claim_guard``) for a non-transitioning inspection
        claim — a pr_reviewer claiming an awaiting_pr_review gate task does not
        start work, so the single-active-task / code-lane invariants that gate a
        developer starting a code task do not apply (the dependency guard still
        runs). See ``claim_gate_review`` (#192).

        Pre-gateway location: _helpers.py:124-204.
        """
        if not skip_dev_guards and role_str not in self._COORDINATOR_ROLES:
            in_progress = await self.task.list_in_progress_for_agent(agent_id)
            if guard := already_active_guard(in_progress, task.id):
                return guard
            paused = await self.task.list_paused_for_agent(agent_id)
            if guard := paused_tasks_guard(paused, task.id):
                return guard
        dep_ids = list(task.dependency_ids)
        if dep_ids:
            unmet = await self.task.unmet_dependency_ids(dep_ids)
            if guard := unmet_dependency_guard(task, unmet):
                # Re-check before mutating: the read above is an unlocked SELECT,
                # and an upstream dependency may have reached a terminal state
                # (completed/cancelled) in the microseconds between that read and
                # now. Dependencies are monotonic — unmet -> met only, terminal
                # states never reopen — so a fresh read that now finds them met
                # stays met, and the task can proceed. Releasing it anyway would
                # needlessly clear its branch + abandon its WorkSession and
                # bounce the assignee, only for the dependency-completion
                # re-dispatch to re-dispatch + re-claim it a moment later. Skip
                # the release and let the caller proceed (return None). The
                # cross-task residual window (upstream completes between this
                # re-check and the release below) is not closable by a row lock
                # on the dependent — but the re-check narrows the window from
                # [first read -> release] to [re-check -> release] and, in the
                # common case, the first read already sees met (no guard).
                fresh_unmet = await self.task.unmet_dependency_ids(dep_ids)
                if not fresh_unmet:
                    return None
                # Still unmet — park the dependency-gated task back to pending so
                # the orchestrator stops respawning its assignee (the respawn
                # loop targets only claimed/in_progress) and the dispatch
                # dependency filter holds it until the upstream completes. No-op
                # unless the task is currently claimed/in_progress.
                await self.task.release_dependency_blocked_claim(task.id)
                return guard
        if skip_dev_guards:
            return None
        return await self._lane_claim_guard(task)

    async def _lane_claim_guard(self, task: Any) -> Envelope | None:
        """Refuse a code leaf behind an earlier open same-assignee sibling.

        The out-of-order start wedge: a later PR cut from a base that predates
        the earlier sibling's unmerged changes. CODE-only predicate ->
        coordinator PMs are inert. Fail-closed on lookup error so a DB hiccup
        never lets an out-of-order claim through.
        """
        try:
            lane_held = await self.task.has_earlier_incomplete_code_sibling(task)
        except Exception:
            return Envelope.invalid_state(
                message=(
                    f"lane order check failed for task {task.id}; retry give_me_work."
                ),
                remediate="call give_me_work() to re-fetch available work",
            )
        if lane_held is not True:
            return None
        await self.task.release_dependency_blocked_claim(task.id)
        return Envelope.invalid_state(
            message=(
                f"task {task.id} waits behind an earlier open task in your "
                "code lane; start that one first."
            ),
            remediate="call give_me_work() to pick up the earlier task",
        )

    async def _non_terminal_subtask_ids(self, parent_task_id: UUID) -> str:
        """Return a human-readable comma-separated list of non-terminal subtasks.

        Used by Gate Set F closure-time guards to name exactly which
        subtasks are blocking parent completion.
        """
        terminal = {"completed", "cancelled"}
        subtasks: list[Any] = await self.task.get_subtasks(parent_task_id)
        non_terminal = [s for s in subtasks if str(s.status) not in terminal]
        if not non_terminal:
            return "(none — query out-of-sync, retry)"
        # Format: "<id> (<status>)"
        return ", ".join(f"{s.id} ({s.status})" for s in non_terminal)

    async def _subtasks_not_terminal_envelope(
        self,
        agent_id: UUID,
        task_id: UUID,
        *,
        context_phrase: str,
    ) -> Envelope | None:
        """Return a tracing_gap rejection if any subtask of ``task_id`` is non-terminal.

        Centralizes the closure-time "all subtasks terminal" gate that fires
        in submit_up, cell_pm_complete, main_pm_complete.
        ``context_phrase`` lets each caller name the action being blocked
        (e.g., "bubbling up", "completing parent").
        """
        if await self.task.all_subtasks_terminal(task_id):
            return None
        non_terminal = await self._non_terminal_subtask_ids(task_id)
        return Envelope.tracing_gap(
            missing=["subtasks not all terminal"],
            remediate=(
                f"all subtasks must be in completed/cancelled before"
                f" {context_phrase}. Non-terminal subtasks: {non_terminal}"
            ),
            context_briefing=await self._briefing_for(agent_id, task_id),
        )

    async def _parent_acs_covered_envelope(
        self,
        agent_id: UUID,
        task_id: UUID,
        *,
        context_phrase: str,
    ) -> Envelope | None:
        """Reject roll-up if any parent acceptance criterion is unsatisfied.

        A parent AC is satisfied only when a COMPLETED child declared it via
        ``covers_parent_criteria`` (→ ``parent_ac_refs``). Safe-by-construction:
        ``uncovered_parent_acceptance_criteria`` returns nothing unless coverage
        is declared on the children, so this is inert for tasks decomposed before
        coverage tracking and bites only once a PM maps children to parent
        criteria. The backstop that stops a half-built parent (criteria silently
        dropped at decompose-time) from rolling up "done" — the PR #175 hole.
        """
        uncovered = await self.task.uncovered_parent_acceptance_criteria(task_id)
        # isinstance keeps the gate inert under partial test mocks (an AsyncMock
        # TaskService returns a truthy stub, not a concrete list) and on any
        # unexpected return — enforce only on a real, non-empty list.
        if not isinstance(uncovered, list) or not uncovered:
            return None
        listing = "; ".join(uncovered)
        return Envelope.tracing_gap(
            missing=["parent acceptance criteria not satisfied"],
            remediate=(
                f"{len(uncovered)} parent acceptance criteria are not covered by a "
                f"completed subtask before {context_phrase}: {listing}. Delegate "
                "(or reassign) subtasks covering them and let those pass QA + "
                "complete first. If a completed subtask already implements a "
                "criterion, stamp it: declare_coverage(task_id=<child>, "
                "criteria=[...])."
            ),
            context_briefing=await self._briefing_for(agent_id, task_id),
        )

    def _verb_runner(self) -> VerbRunner:
        """Construct a VerbRunner bound to this Choreographer's services.

        Cheap to allocate; one per verb invocation keeps the runner
        stateless across requests.
        """
        return VerbRunner(task_service=self.task, git_service=self.git)

    async def _resume_from_claimed(
        self,
        ctx: _ClaimPlanStartContext,
    ) -> Envelope:
        """Recover from a stuck `claimed` state owned by the same agent.

        spec's composed `claim` action's source-statuses do not include
        CLAIMED, so the spec gate would reject re-claiming an
        already-owned task. This branch keeps the spec contract intact
        (we never call `claim` again) but lets the agent recover by
        running just set_plan (if a plan was supplied or stored) and
        start. Shared between ``i_will_work_on`` and ``i_will_plan`` —
        ``ctx.verb_name`` selects the verb-specific labels / next_hint.
        """
        agent_id = ctx.agent_id
        task_id = ctx.task_id
        t = ctx.task
        briefing = ctx.briefing
        role_str = ctx.role_str
        plan = ctx.plan
        verb_name = ctx.verb_name
        if not t.plan and not plan:
            return await self._emit_rejection(
                Envelope.tracing_gap(
                    missing=["plan"],
                    remediate=(
                        f"call {verb_name}(task_id='{task_id}',"
                        f" plan='<one-paragraph plan describing what you will do>')"
                    ),
                    context_briefing=briefing,
                ).with_introspection(task=t, role=role_str),
                agent_id=agent_id,
                task_id=task_id,
                verb=verb_name,
            )
        # Concurrency guards still apply on resumption (paused / already-active
        # in another task).
        if guard := await self._run_claim_guards(
            agent_id=agent_id,
            task=t,
            role_str=role_str,
        ):
            return await self._emit_rejection(
                self._with_briefing(guard, briefing).with_introspection(
                    task=t, role=role_str
                ),
                agent_id=agent_id,
                task_id=task_id,
                verb=verb_name,
            )
        try:
            if plan and not t.plan:
                t = await self.task.set_plan(task_id, plan)
            t = await self.task.start(task_id, agent_id)
        except Exception as exc:
            return await self._emit_rejection(
                Envelope.invalid_state(
                    message=f"verb runner failed: {exc}",
                    remediate="check workspace + retry; if persistent, escalate",
                    context_briefing=briefing,
                ).with_introspection(task=t, role=role_str),
                agent_id=agent_id,
                task_id=task_id,
                verb=verb_name,
            )
        if t is None:
            return await self._emit_rejection(
                Envelope.invalid_state(
                    message=f"start failed for task {task_id}",
                    remediate=(
                        "task not in a startable state"
                        " (claimed/paused/needs_revision) or no plan recorded"
                    ),
                    context_briefing=briefing,
                ).with_introspection(task=None, role=role_str),
                agent_id=agent_id,
                task_id=task_id,
                verb=verb_name,
            )
        # Pre-gateway parity: ensure the WorkSession row
        # exists on the stuck-claimed recovery path too (same guarantee as
        # _claim_plan_start_run). Re-entry guard inside ensure_work_session.
        await self.task.ensure_work_session(task_id, agent_id)
        await self._touch(task_id)
        return Envelope.ok(
            status=str(t.status),
            task_id=str(task_id),
            next=spec_module._INTENT_VERBS[verb_name].next_hint(t),
            context_briefing=briefing,
        ).with_introspection(task=t, role=role_str)

    async def _claim_plan_start_gate(
        self,
        ctx: _ClaimPlanStartContext,
        role: spec_module.Role,
        spec_ctx: spec_module.Context,
    ) -> Envelope | None:
        """Run all gates for an ``i_will_work_on`` / ``i_will_plan`` call.

        Order: spec.can_invoke_intent -> behavioral claim guards
        (already_active / paused / unmet_dependency). Any rejection
        short-circuits with the appropriate envelope.

        Per-role claim authority (CLAIM_RULES) is enforced inside
        spec.can_invoke_action when action == "claim", called by
        can_invoke_intent, so no separate spec.can_claim call is needed.
        A PM recovering a NEEDS_REVISION coordination task is scoped by
        give_me_work routing (it only ever offers an agent its OWN assigned
        tasks), exactly as developer leaf-revisions are — not by a gateway-only
        ownership gate, which would diverge from the spec (the parity invariant
        requires gateway authorization == spec authorization).
        """
        t, briefing, role_str = ctx.task, ctx.briefing, ctx.role_str
        verb_name = ctx.verb_name
        decision = spec_module.can_invoke_intent(role, verb_name, t, spec_ctx)
        if not decision.allowed:
            return await self._emit_rejection(
                Envelope.from_decision(decision, briefing=briefing).with_introspection(
                    task=t, role=role_str
                ),
                agent_id=ctx.agent_id,
                task_id=ctx.task_id,
                verb=verb_name,
            )
        # Behavioral pre-flight guards the spec doesn't yet model:
        # - already_active: agent has another in_progress task elsewhere
        # - paused_tasks: agent has a paused task they should resume first
        # - unmet_dependency: an upstream dependency is still non-terminal
        # The role/state/task_type checks already passed via the spec gate
        # above. These migrate into spec.extra_preconditions in a later
        # task; until then, keep them imperative so concurrency invariants
        # stay enforced.
        #
        # The agent-wide guards below read the agent's OTHER tasks via plain
        # (unlocked) SELECTs, and claim()'s FOR UPDATE locks only the TARGET
        # row — neither serializes two concurrent claims by the SAME agent on
        # TWO DIFFERENT pending tasks. A transaction-scoped advisory lock keyed
        # by agent_id, acquired here BEFORE the guard reads and held until the
        # request transaction commits, makes the second concurrent claim's read
        # see the first's committed in_progress task and get rejected. The
        # in-process asyncio.Lock is lost on orchestrator-restart split-brain,
        # so this is the only DB-level guarantee of the one-task-per-agent
        # invariant. Coordinators (cell_pm / main_pm) are exempt — matching the
        # already_active/paused guard exemption below — so a PM can still plan +
        # delegate many roots in parallel (the PM coordinator concurrency
        # feature). A hash collision only causes benign false serialization.
        if role_str not in self._COORDINATOR_ROLES:
            await self.task.acquire_claim_lock(ctx.agent_id)
        if guard := await self._run_claim_guards(
            agent_id=ctx.agent_id,
            task=t,
            role_str=role_str,
        ):
            return await self._emit_rejection(
                self._with_briefing(guard, briefing).with_introspection(
                    task=t, role=role_str
                ),
                agent_id=ctx.agent_id,
                task_id=ctx.task_id,
                verb=verb_name,
            )
        return None

    async def _claim_plan_start_run(
        self, ctx: _ClaimPlanStartContext, agent: Any, spec_ctx: spec_module.Context
    ) -> Envelope:
        """Execute composed (claim, set_plan, start) via the verb runner.

        Caller has already validated all gates. Translates runner
        exceptions and ``None`` returns into invalid_state envelopes
        so the agent gets a remediation instead of a 500. Shared
        between ``i_will_work_on`` and ``i_will_plan``.
        """
        t, briefing, role_str = ctx.task, ctx.briefing, ctx.role_str
        verb_name = ctx.verb_name
        runner = self._verb_runner()
        try:
            t = await runner.run_intent(verb_name, t, agent, spec_ctx)
        except Exception as exc:
            return await self._emit_rejection(
                Envelope.invalid_state(
                    message=f"verb runner failed: {exc}",
                    remediate="check workspace + retry; if persistent, escalate",
                    context_briefing=briefing,
                ).with_introspection(task=t, role=role_str),
                agent_id=ctx.agent_id,
                task_id=ctx.task_id,
                verb=verb_name,
            )
        if t is None:
            # A composed atomic action returned None (e.g. start() rejected
            # because of an ownership/state mismatch the spec gate could not
            # see). Surface as invalid_state so the agent gets a remediation
            # rather than a 500.
            return await self._emit_rejection(
                Envelope.invalid_state(
                    message=f"start failed for task {ctx.task_id}",
                    remediate=(
                        "task not in a startable state"
                        " (claimed/paused/needs_revision) or no plan recorded"
                    ),
                    context_briefing=briefing,
                ).with_introspection(task=None, role=role_str),
                agent_id=ctx.agent_id,
                task_id=ctx.task_id,
                verb=verb_name,
            )
        # Pre-gateway parity: create the WorkSession
        # row so downstream subsystems (panel, PR, merge chain) can track
        # this agent's per-task git activity. work_session_id stored on the
        # task; one WorkSession per (agent, task) claim cycle; re-entry
        # guard inside ensure_work_session prevents duplicate rows.
        await self.task.ensure_work_session(ctx.task_id, ctx.agent_id)
        await self._touch(ctx.task_id)
        return Envelope.ok(
            status=str(t.status),
            task_id=str(ctx.task_id),
            next=spec_module._INTENT_VERBS[verb_name].next_hint(t),
            context_briefing=briefing,
        ).with_introspection(task=t, role=role_str)

    @staticmethod
    def _build_rich_plan(
        plan: str | None,
        steps: list[dict[str, Any]] | None,
        technical_considerations: list[str] | None,
        risks: list[dict[str, Any]] | None,
        open_questions: list[dict[str, Any]] | None,
    ) -> dict[str, Any]:
        """Assemble the panel-shaped rich plan from a dev's inputs."""
        return {
            "approach": plan or "",
            "sub_tasks": steps or [],
            "technical_considerations": technical_considerations or [],
            "risks": risks or [],
            "open_questions": open_questions or [],
        }

    async def i_will_work_on(
        self,
        agent_id: UUID,
        task_id: UUID,
        plan: str | None = None,
        steps: list[dict[str, Any]] | None = None,
        technical_considerations: list[str] | None = None,
        risks: list[dict[str, Any]] | None = None,
        open_questions: list[dict[str, Any]] | None = None,
    ) -> Envelope:
        """Claim a task and start work on it.

        Atomic: spec.can_invoke_intent runs before any state mutation;
        the composed (claim, set_plan, start) sequence is wrapped in a
        savepoint by the runner so a mid-sequence failure rolls back
        the DB. Idempotent re-entry: a respawned dev re-calling on a
        task they already own in_progress just refreshes the heartbeat.

        ``steps`` is the developer's execution checklist (same
        SubTask shape as a PM's sub_tasks). Persisted into
        ``task.plan.sub_tasks`` via the panel-shaped path so it renders
        identically AND feeds plan-driven progress. A developer
        on a fresh claim must supply substantive steps —
        ``_dev_steps_gate`` enforces depth; the re-entry / recovery
        paths short-circuit before the gate so a respawned dev is never
        re-blocked for steps it already submitted.
        """
        t = await self.task.get(task_id)
        if t is None:
            return await self._emit_rejection(
                Envelope.not_found(message=f"task {task_id} not found"),
                agent_id=agent_id,
                task_id=task_id,
                verb="i_will_work_on",
            )
        agent = await self.task.agent_for(agent_id)
        role_str = str(agent.role) if agent is not None else "developer"
        briefing = await self._briefing_for(agent_id, task_id, task=t, full=True)
        try:
            role = spec_module.Role(role_str)
        except ValueError:
            return await self._emit_rejection(
                Envelope.not_authorized(
                    message=f"unknown role '{role_str}'",
                    remediate="role is not declared in the lifecycle spec",
                    context_briefing=briefing,
                ).with_introspection(task=t, role=role_str),
                agent_id=agent_id,
                task_id=task_id,
                verb="i_will_work_on",
            )
        # Full parity: a dev authors the same rich plan a PM does. The
        # dev's `plan` doubles as the Approach; `steps` become sub_tasks. Built
        # via the panel-shaped path so the Plan tab renders identically and
        # feeds progress. With no rich fields (re-entry/recovery) this
        # falls through to unchanged string behaviour.
        rich_plan = self._build_rich_plan(
            plan, steps, technical_considerations, risks, open_questions
        )
        effective_plan: str | dict[str, Any] | None = self._resolve_effective_plan(
            plan or "", rich_plan
        )
        spec_ctx = spec_module.Context(
            plan=effective_plan,
            actor_id=agent_id,
            actor_slug=getattr(agent, "slug", None) if agent is not None else None,
            agent_team=str(agent.team) if agent is not None and agent.team else None,
            original_developer_slug=_extract_original_developer(t),
        )
        ctx = _ClaimPlanStartContext(
            agent_id=agent_id,
            task_id=task_id,
            task=t,
            role_str=role_str,
            briefing=briefing,
            plan=effective_plan,
            verb_name="i_will_work_on",
        )
        if reentry := await self._dev_reentry(
            ctx, t, agent_id, task_id, role_str, briefing
        ):
            return reentry
        return await self._fresh_dev_claim(
            ctx,
            role,
            spec_ctx,
            agent,
            rich_plan,
            role_str,
            t,
            agent_id,
            task_id,
            briefing,
        )

    async def _dev_reentry(
        self,
        ctx: _ClaimPlanStartContext,
        t: Any,
        agent_id: UUID,
        task_id: UUID,
        role_str: str,
        briefing: dict[str, Any],
    ) -> Envelope | None:
        """Re-entry short-circuits for i_will_work_on (extracted to keep
        i_will_work_on under the cyclomatic-complexity gate; mirrors
        _handle_pm_reentry). Returns an Envelope to short-circuit, or None
        to fall through to the fresh-claim path.
        """
        # Idempotent re-entry: agent already owns the task in_progress.
        # Short-circuit before the spec gate (in_progress is not a source
        # state for the composed `claim` action).
        if str(t.status) == "in_progress" and t.assigned_to == agent_id:
            await self._touch(task_id)
            return Envelope.ok(
                status=str(t.status),
                task_id=str(task_id),
                next=spec_module._INTENT_VERBS["i_will_work_on"].next_hint(t),
                context_briefing=briefing,
            ).with_introspection(task=t, role=role_str)
        # Recovery re-entry: task stuck in `claimed` (orchestrator restart
        # or partial-claim race) and the agent already owns it. The spec
        # `claim` source-statuses exclude CLAIMED, so run only set_plan +
        # start.
        if str(t.status) == "claimed" and t.assigned_to == agent_id:
            envelope = await self._resume_from_claimed(ctx)
            return await self._post_claim_journal_gate(
                "i_will_work_on", agent_id, task_id, envelope
            )
        return None

    async def _fresh_dev_claim(
        self,
        ctx: _ClaimPlanStartContext,
        role: Any,
        spec_ctx: Any,
        agent: Any,
        rich_plan: dict[str, Any],
        role_str: str,
        t: Any,
        agent_id: UUID,
        task_id: UUID,
        briefing: dict[str, Any],
    ) -> Envelope:
        """Fresh (non-re-entry) i_will_work_on tail: spec gate → dev-plan
        gate → claim/plan/start → post-claim journal gate. Extracted so
        i_will_work_on stays within the return-count budget; the dev-plan
        gate mirrors _pm_sub_tasks_gate's placement (after the spec gate).
        """
        if rejection := await self._claim_plan_start_gate(ctx, role, spec_ctx):
            return rejection
        if rejection := await self._dev_plan_gate(
            role_str=role_str,
            rich_plan=rich_plan,
            task=t,
            agent_id=agent_id,
            task_id=task_id,
            briefing=briefing,
        ):
            return rejection
        envelope = await self._claim_plan_start_run(ctx, agent, spec_ctx)
        return await self._post_claim_journal_gate(
            "i_will_work_on", agent_id, task_id, envelope
        )

    @staticmethod
    def _dev_plan_field_gaps(rich_plan: dict[str, Any]) -> dict[str, str]:
        """Collect missing/thin rich-plan fields for a fresh dev claim.

        Full parity with PMs: approach (the dev's `plan`, >= min chars),
        substantive sub_tasks (the `steps` checklist), technical_considerations
        and risks. open_questions stay optional. Returns {field: hint}.
        """
        gaps: dict[str, str] = {}
        approach = str(rich_plan.get("approach") or "").strip()
        if len(approach) < _PM_APPROACH_MIN_LEN:
            gaps["plan"] = (
                f"plan must be >= {_PM_APPROACH_MIN_LEN} chars describing HOW "
                "you will implement this (it is the plan's Approach)."
            )
        steps = rich_plan.get("sub_tasks") or []
        if not steps:
            gaps["steps"] = (
                "a non-empty execution checklist — list of {title, "
                "description}; each step is also a progress-checklist item."
            )
        elif thin := _thin_subtask_hint(steps):
            gaps["steps"] = thin
        if not rich_plan.get("technical_considerations"):
            gaps["technical_considerations"] = (
                "list >= 1 architectural / library / approach note (strings)."
            )
        if not rich_plan.get("risks"):
            gaps["risks"] = (
                "list >= 1 {risk, mitigation} entry — what could go wrong and "
                "how you'll handle it."
            )
        return gaps

    async def _dev_plan_gate(
        self,
        *,
        role_str: str,
        rich_plan: dict[str, Any],
        task: Any,
        agent_id: UUID,
        task_id: UUID,
        briefing: dict[str, Any],
    ) -> Envelope | None:
        """A developer's FRESH claim must author the same rich plan a PM does,
        so the task's Plan tab is fully populated for audit/tracing. Enforces
        approach + steps + technical_considerations + risks (open_questions
        optional). Non-developer callers and re-entry are unaffected — the
        re-entry/recovery paths return before this is reached. Returns a
        rejection Envelope when the plan is thin; None when it passes.
        """
        if role_str != "developer":
            return None
        gaps = self._dev_plan_field_gaps(rich_plan)
        if not gaps:
            return None
        return await self._emit_rejection(
            Envelope.incomplete_input(
                missing=sorted(gaps),
                field_hints=gaps,
                remediate=(
                    "re-issue i_will_work_on(task_id, plan='<how, >= "
                    f"{_PM_APPROACH_MIN_LEN} chars>', "
                    "steps=[{'title': '...', 'description': '...'}, ...], "
                    "technical_considerations=['...'], "
                    "risks=[{'risk': '...', 'mitigation': '...'}]) — the same "
                    "rich plan a PM authors, so your task's Plan tab is filled."
                ),
                context_briefing=briefing,
            ).with_introspection(task=task, role=role_str),
            agent_id=agent_id,
            task_id=task_id,
            verb="i_will_work_on",
        )

    @staticmethod
    def _with_briefing(env: Envelope, briefing: dict[str, Any]) -> Envelope:
        """Attach a context_briefing to an Envelope (mutate-and-return helper)."""
        env.context_briefing = briefing
        return env

    async def open_pr(self, agent_id: UUID, task_id: UUID) -> Envelope:
        """Push the dev's branch and open a PR.

        Atomic: spec.can_invoke_intent runs first and enforces ALL
        preconditions (PRECONDITION_OWNERSHIP, PRECONDITION_COMMITS,
        PRECONDITION_NO_PR) BEFORE any git side effect. If any check
        fails, no PR is opened. After success, the dev calls
        ``i_am_done`` to actually transition the task to awaiting_qa.

        Renamed from ``submit_for_qa`` (2026-05-08): the old name
        suggested this verb advanced the lifecycle, but it only opens
        the PR. Agents misread the name, called it expecting a QA
        handoff, then never called i_am_done — orphaning open PRs.

        Idempotent on re-call: if the caller already owns the task and
        a PR is already open, return OK pointing at ``i_am_done`` rather
        than the spec's ``tracing_gap`` for ``no_prior_pr``. Two calls
        in a row should not surface a misleading "open a PR" hint.
        """
        # Serialize concurrent open_pr on the SAME task across the
        # idempotent-guard fetch -> runner -> milestone-emit critical section.
        # The guard reads t.pr_number from an unlocked fetch; without a lock,
        # two concurrent same-task open_pr calls (the alive-but-unresponsive
        # respawn race) both fetch pr_number=None, both pass the guard, both
        # run the runner (create_pr's GitHub 422 'already exists' path ensures
        # only one PR — no double PR), and both reach _open_pr_success_envelope
        # -> _record_milestone_progress -> a double-emitted 70% "opened PR #N"
        # progress entry (the audit/milestone view double-counts one PR-open).
        # The per-task transaction-scoped advisory lock is acquired BEFORE the
        # fetch so the second concurrent call blocks until the first commits,
        # then its fetch sees the first's committed pr_number and the guard
        # short-circuits without re-emitting the milestone. Per-task (not
        # per-agent) — the single-active-task guard means concurrent open_pr
        # on the same task is purely the respawn-race bug case, never a
        # legitimate-concurrency regression.
        await self.task.acquire_task_lock(task_id)
        t = await self.task.get(task_id)
        if t is None:
            return await self._emit_rejection(
                Envelope.not_found(message=f"task {task_id} not found"),
                agent_id=agent_id,
                task_id=task_id,
                verb="open_pr",
            )
        briefing = await self._briefing_for(agent_id, task_id, task=t)
        agent = await self.task.agent_for(agent_id)
        role_str = str(agent.role) if agent is not None else "developer"
        # Idempotent re-entry: caller owns the task and a PR is already
        # open. The spec would otherwise reject with PRECONDITION_NO_PR
        # tracing_gap, but agents calling open_pr twice should get the
        # existing PR's i_am_done hint, not a misleading "open a PR" remediate.
        if t.pr_number is not None and t.assigned_to == agent_id:
            return Envelope.ok(
                status=str(t.status),
                task_id=str(task_id),
                next=(
                    f"PR #{t.pr_number} already open; call "
                    f"i_am_done(task_id, notes='...') when self-verified"
                ),
                context_briefing=briefing,
            ).with_introspection(task=t, role=role_str)
        spec_ctx = spec_module.Context(
            actor_id=agent_id,
            actor_slug=getattr(agent, "slug", None) if agent is not None else None,
            agent_team=str(agent.team) if agent is not None and agent.team else None,
            original_developer_slug=_extract_original_developer(t),
        )
        if rejection := self._open_pr_preflight_rejection(
            agent_id, task_id, t, role_str, briefing, spec_ctx
        ):
            return await self._emit_rejection(
                rejection, agent_id=agent_id, task_id=task_id, verb="open_pr"
            )
        await self._touch(task_id)
        runner = self._verb_runner()
        try:
            await runner.run_intent("open_pr", t, agent, spec_ctx)
        except Exception as exc:
            return await self._emit_rejection(
                self._open_pr_failure_env(exc, t, briefing, role_str),
                agent_id=agent_id,
                task_id=task_id,
                verb="open_pr",
            )
        return await self._open_pr_success_envelope(
            agent_id, task_id, t, briefing, role_str
        )

    def _open_pr_preflight_rejection(
        self,
        agent_id: UUID,
        task_id: UUID,
        t: Any,
        role_str: str,
        briefing: dict[str, Any],
        spec_ctx: Any,
    ) -> Envelope | None:
        """Role + reassignment + spec-gate rejection for open_pr (or None).

        A stale/superseded agent (task reassigned away) is steered to
        give_me_work with a clear not_authorized BEFORE the spec gate would
        emit an owns_task tracing_gap the agent retries forever (same pattern
        as i_am_done / resume).
        """
        try:
            role = spec_module.Role(role_str)
        except ValueError:
            return Envelope.not_authorized(
                message=f"unknown role '{role_str}'",
                remediate="role is not declared in the lifecycle spec",
                context_briefing=briefing,
            ).with_introspection(task=t, role=role_str)
        reassigned = self._reassigned_rejection(
            _ReassignedCtx(
                task=t,
                agent_id=agent_id,
                task_id=task_id,
                role_str=role_str,
                briefing=briefing,
                upstream_hint=(
                    "this task was reassigned away from you; you are no longer "
                    "its owner. Call give_me_work() to find your current task, "
                    "or i_am_idle() if there is none. Do NOT retry open_pr."
                ),
            )
        )
        if reassigned is not None:
            return reassigned
        decision = spec_module.can_invoke_intent(role, "open_pr", t, spec_ctx)
        if not decision.allowed:
            return Envelope.from_decision(
                decision, briefing=briefing
            ).with_introspection(task=t, role=role_str)
        return None

    @staticmethod
    def _open_pr_failure_env(
        exc: Exception, t: Any, briefing: dict[str, Any], role_str: str
    ) -> Envelope:
        """Map an open_pr runner failure to its rejection envelope.

        Empty-diff subtask: a branch with zero commits relative to its base
        makes GitHub 422 with "No commits between ...". open_pr can never
        succeed here (an overlapping-decomposition leaf whose work was
        delivered by the parent), so a generic invalid_state "retry" loops the
        dev forever (15x on one task in a single run). Steer to a terminal
        i_am_blocked hand-off so the PM completes/cancels the redundant leaf;
        every other failure keeps the generic retry hint.
        """
        if "No commits between" in str(exc):
            return Envelope.invalid_state(
                message=(
                    "this branch has no commits relative to its base — there is "
                    "nothing to open a PR for"
                ),
                remediate=(
                    "do NOT retry open_pr — your branch has no diff, so this "
                    "leaf's work was almost certainly delivered by its parent "
                    "task. Call i_am_blocked(reason='no commits to PR — work "
                    "delivered via parent') so your PM can complete or cancel "
                    "this redundant leaf."
                ),
                context_briefing=briefing,
            ).with_introspection(task=t, role=role_str)
        return Envelope.invalid_state(
            message=f"verb runner failed: {exc}",
            remediate="check workspace + retry; if persistent, escalate",
            context_briefing=briefing,
        ).with_introspection(task=t, role=role_str)

    async def _open_pr_success_envelope(
        self,
        agent_id: UUID,
        task_id: UUID,
        t: Any,
        briefing: dict[str, Any],
        role_str: str,
    ) -> Envelope:
        """Refresh the task, auto-emit milestone progress, build the OK envelope.

        git_service.create_pr writes pr_number / pr_url onto the task row;
        the runner doesn't bubble that update back so we re-fetch.
        Milestone progress fires server-side so the panel + audit
        log always show "opened PR #N" regardless of agent chattiness.
        """
        refreshed = await self.task.get(task_id)
        t = refreshed if refreshed is not None else t
        if t.pr_number is not None:
            await self._record_milestone_progress(
                task_id,
                agent_id,
                f"opened PR #{t.pr_number}",
                percentage=70,
            )
        return Envelope.ok(
            status=str(t.status),
            task_id=str(task_id),
            next=spec_module._INTENT_VERBS["open_pr"].next_hint(t),
            context_briefing=briefing,
        ).with_introspection(task=t, role=role_str)

    async def i_am_done(
        self,
        agent_id: UUID,
        task_id: UUID,
        notes: str,
        resolved_findings: list[dict[str, Any]] | None = None,
    ) -> Envelope:
        """Submit work for QA.

        Atomic: ``spec.can_invoke_intent`` runs first and enforces the
        intent's role membership and the ``PRECONDITION_OWNERSHIP`` /
        ``PRECONDITION_COMMITS`` extra preconditions BEFORE any state
        mutation. After the spec gate accepts, two additional gate sets
        run as defense-in-depth (the spec doesn't yet model them):

          - tracing-gate preconditions (progress entry, journal:reflect,
            acceptance criteria addressed, revision-findings-ledger
            addressed)
          - field-level submit-qa gates (currently: PR open; commits and
            ownership are already covered by the spec extras above)

        ``resolved_findings`` ({finding_id, commit?, note?} entries) marks
        open ledger rows ``addressed`` BEFORE the tracing gate runs (in
        ``_i_am_done_gate``, shared by both the fresh path and the
        resume-from-verifying recovery path below) — a write-then-gate
        pattern mirroring ``dev_notes``: apply the resolution, then check
        whether the task's open-findings count is now zero.

        Once all gates pass, ``VerbRunner.run_intent("i_am_done", ...)``
        dispatches the (submit_verification, submit_qa) atomic chain
        wrapped in a savepoint so a mid-sequence failure rolls back the
        DB. Recovery re-entry: a task already in ``verifying`` owned by
        the caller has its first composed action (submit_verification,
        source IN_PROGRESS) rejected by the spec gate. We short-circuit
        before the spec gate and run only ``submit_qa`` — the spec
        doesn't model partial-progress recovery, so that branch lives
        in the verb body.

        The previous strict path required a separate ``submit_for_verification``
        verb that wasn't on any manifest, making i_am_done unreachable.
        Removed that requirement; the act of calling i_am_done
        IS the self-verification.
        """
        t = await self.task.get(task_id)
        if t is None:
            return await self._emit_rejection(
                Envelope.not_found(message=f"task {task_id} not found"),
                agent_id=agent_id,
                task_id=task_id,
                verb="i_am_done",
            )
        agent = await self.task.agent_for(agent_id)
        role_str = str(agent.role) if agent is not None else "developer"
        briefing = await self._briefing_for(agent_id, task_id, task=t)
        ctx = _IAmDoneContext(
            agent_id=agent_id,
            task_id=task_id,
            task=t,
            role_str=role_str,
            briefing=briefing,
            notes=notes,
            resolved_findings=resolved_findings,
        )
        try:
            role = spec_module.Role(role_str)
        except ValueError:
            return await self._reject_i_am_done(
                ctx,
                Envelope.not_authorized(
                    message=f"unknown role '{role_str}'",
                    remediate="role is not declared in the lifecycle spec",
                    context_briefing=briefing,
                ),
            )
        actor_slug, agent_team = actor_context_fields(agent)
        spec_ctx = spec_module.Context(
            actor_id=agent_id,
            actor_slug=actor_slug,
            agent_team=agent_team,
            original_developer_slug=_extract_original_developer(t),
            notes=notes,
        )
        # Pre-spec-gate short-circuits (verifying-resume recovery + the
        # work-already-done fast path), folded into one helper to keep i_am_done
        # within its return-count budget (mirrors _fresh_dev_claim's extraction).
        if dispatched := await self._i_am_done_pre_gate_dispatch(ctx, t, agent_id):
            return dispatched
        # Stale/superseded agent: the task was reassigned out from under this
        # agent (PM reassign / reaper unclaim / escalation redirect) while its
        # container kept running. Without this, the spec gate's
        # PRECONDITION_OWNERSHIP emits an owns_task *tracing_gap*, which the
        # agent reads as a fixable precondition and retries i_am_done forever
        # (41 such loops in one run). A clear "no longer yours" not_authorized
        # steers to give_me_work — terminal for this agent. (resume/unclaim
        # already do this; i_am_done did not.) Folded into the soup/spec return
        # below to stay within the return-count budget.
        reassigned = self._reassigned_rejection(
            _ReassignedCtx(
                task=t,
                agent_id=agent_id,
                task_id=task_id,
                role_str=role_str,
                briefing=briefing,
                upstream_hint=(
                    "this task was reassigned away from you; you are no longer "
                    "its owner. Call give_me_work() to find your current task, "
                    "or i_am_idle() if there is none. Do NOT retry i_am_done."
                ),
            )
        )
        # i_am_done notes is optional and supplementary (the real summary lives
        # in commits + journal:reflect), so guard it lightly — a banned token
        # ('wip'/'x') is soup, but a terse real word like 'done' is fine.
        soup = self._free_text_soup(checks=(("notes", notes, 4),))
        decision = spec_module.can_invoke_intent(role, "i_am_done", t, spec_ctx)
        if env := (reassigned or self._soup_or_decision_env(soup, decision, briefing)):
            return await self._reject_i_am_done(ctx, env)
        if gate_rejection := await self._i_am_done_gate(ctx):
            return gate_rejection
        return await self._i_am_done_run(ctx, agent, spec_ctx)

    async def _reject_i_am_done(self, ctx: _IAmDoneContext, env: Envelope) -> Envelope:
        """Stamp introspection + emit audit row for an i_am_done rejection."""
        env.with_introspection(task=ctx.task, role=ctx.role_str)
        return await self._emit_rejection(
            env, agent_id=ctx.agent_id, task_id=ctx.task_id, verb="i_am_done"
        )

    async def _apply_resolved_findings(self, ctx: _IAmDoneContext) -> None:
        """Best-effort: mark ledger findings 'addressed' from resolved_findings.

        Write-then-gate (mirrors dev_notes / the a2a warning pattern): this
        runs BEFORE ``_check_tracing_gates``, so a resolution applied here is
        immediately reflected in that gate's open-findings count. A malformed
        entry (bad ref, wrong task, already non-open) is silently skipped —
        the FINDINGS_ADDRESSED gate below re-surfaces anything that didn't
        actually get marked, naming the id, so nothing is lost quietly.
        """
        await self._apply_resolved_findings_for(ctx.task_id, ctx.resolved_findings)

    async def _apply_resolved_findings_for(
        self, task_id: UUID, resolved_findings: list[dict[str, Any]] | None
    ) -> None:
        """Task-id-keyed sibling of ``_apply_resolved_findings`` — shared by
        ``submit_up`` / ``submit_root``, which have no ``_IAmDoneContext`` of
        their own. Same write-then-gate posture: mark ledger rows 'addressed'
        BEFORE the FINDINGS_ADDRESSED gate re-reads open_finding_ids.
        """
        if not resolved_findings:
            return
        repo = ReviewFindingsRepository(self.task.session)
        for item in resolved_findings:
            ref = str(item.get("finding_id") or "").strip()
            if not ref:
                continue
            await repo.mark_addressed(
                task_id,
                ref,
                commit=item.get("commit"),
                note=item.get("note"),
            )

    async def _i_am_done_gate(self, ctx: _IAmDoneContext) -> Envelope | None:
        """Run defense-in-depth tracing + field-level gates the spec doesn't model.

        Also pushes the branch to origin so a task cannot reach awaiting_qa
        with commits that exist only in the developer's local workspace.
        Returns the rejection envelope if any gate fails; None on pass. Shared
        by the normal and resume-from-verifying paths so both push.
        """
        await self._apply_resolved_findings(ctx)
        guards = (
            lambda: self._check_tracing_gates(ctx.agent_id, ctx.task_id, ctx.task),
            lambda: self._check_submit_qa_field_gates(
                ctx.agent_id, ctx.task_id, ctx.task
            ),
            lambda: self._ensure_branch_pushed(ctx),
            lambda: self._behind_base_gate(ctx),
            lambda: self._check_quality_gate(ctx),
            lambda: self._toolchain_broken_guard(ctx.agent_id, ctx.task),
            lambda: self._conventions_gate(ctx),
        )
        for guard in guards:
            if rejection := await guard():
                return await self._reject_i_am_done(ctx, rejection)
        # Pre-gateway parity: persist per-criterion
        # status now that all gates have passed. The write runs AFTER the
        # verdict so it cannot change i_am_done's rejection behavior.
        await self._write_criteria_status(ctx.agent_id, ctx.task_id, ctx.task)
        return None

    async def _check_quality_gate(self, ctx: _IAmDoneContext) -> Envelope | None:
        """Run the project's fast quality gate (lint + typecheck) in the dev's
        workspace before the task reaches QA, so a red gate is caught at the
        dev's desk instead of in QA review or CI. The full test suite stays on
        CI. Fail-open: a gate-infrastructure error (missing workspace or
        toolchain) is logged and never blocks the submit; only an actual check
        failure blocks.
        """
        try:
            result = await self.git.run_pre_submit_quality_gate(ctx.agent_id, ctx.task)
        except Exception as exc:
            logger.warning(
                "quality_gate_skipped", task_id=str(ctx.task_id), error=str(exc)
            )
            return None
        if result.passed:
            return None
        return Envelope.invalid_state(
            message=f"quality gate failed before QA — {result.summary}",
            remediate=(
                "Fix these in your workspace, commit, and call i_am_done again "
                "— QA reviews working code, not a red gate:\n\n" + result.output_excerpt
            ),
            context_briefing=ctx.briefing,
        )

    async def _fast_path_quality_verdict(
        self, ctx: _IAmDoneContext
    ) -> tuple[Envelope | None, bool]:
        """Quality posture for the work-already-done fast path.

        Trusts the PR's CI-green signal — the same signal the downstream
        ``pr_pass`` gate trusts (``_ci_status_guard``) — and skips the local
        workspace gate when CI is green. A repo with no CI configured (or an
        unresolvable lookup) has no CI signal, so the local gate is the only
        signal and runs. A CI ``failure`` is a known-bad build: refuse the fast
        path rather than shipping red, even before the local gate runs.

        Returns ``(rejection|None, ran_local)``. ``ran_local`` lets the caller
        distinguish "the local gate already decided" from "CI green, gate
        skipped" so it never double-gates.
        """
        # ``_resolve_ci_status`` lives on ``PRGateMixin``; ``_LegacyChoreographer``
        # reaches it via the typed-helpers cast (same idiom as the pr_pass path).
        status = await cast("ChoreographerHelpers", self)._resolve_ci_status(
            ctx.task_id, ctx.task
        )
        if status is not None:
            state = status.get("state")
            if state == "success":
                return None, False
            if state == "failure":
                names = (
                    ", ".join(status.get("failing_checks") or [])
                    or "one or more checks"
                )
                return (
                    Envelope.invalid_state(
                        message=(
                            f"fast path refused — PR CI is failing ({names}); "
                            "QA reviews working code, not a red build"
                        ),
                        remediate=(
                            "fix the failing CI checks, commit, and call i_am_done "
                            "again — or run the standard path (leave "
                            "possibilities_matrix off)"
                        ),
                        context_briefing=ctx.briefing,
                    ),
                    False,
                )
        # No CI signal (no_ci_configured / pending / pending_not_scheduled /
        # error / unresolvable): the local gate is the only signal — run it.
        return await self._check_quality_gate(ctx), True

    async def _toolchain_broken_guard(
        self, agent_id: UUID, task: Any, *, reviewer: bool = False
    ) -> Envelope | None:
        """Refuse a delivery gate when the acting agent's workspace cannot run
        the project's suite (interpreter mismatch).

        A role that cannot execute the suite degrades to "verifying by reading
        source", which the QA + PR-review gates exist to prevent. Inert when the
        flag is off; only a recorded ``broken`` status blocks — a missing or
        ``unknown`` status never strands a task (fail-open).

        ``reviewer=True`` for the pr_pass gate: a PR reviewer has no
        ``i_am_blocked`` verb, so the remediation points at ``pr_fail`` (their
        reject lever, sending the PR back to needs_revision for the dev to fix
        the environment) instead of a verb they cannot call.
        """
        from roboco.config import settings as _settings

        if not _settings.toolchain_match_enabled:
            return None
        status = await self.git.toolchain_status_for_task(agent_id, task)
        if status == "unknown":
            # Fail-open (precision over recall) but never silent: a recorded
            # 'unknown' means provisioning ran yet the smoke could not confirm
            # the suite is collectable under the interpreter — a possible hollow
            # pass. Surface it so an operator can see the gate proceeded blind.
            logger.warning(
                "toolchain.unverified_gate_pass",
                agent_id=str(agent_id),
                task_id=str(getattr(task, "id", "")),
                detail=(
                    "gate proceeding past an unverified toolchain — the "
                    "workspace smoke could not confirm the project's suite is "
                    "collectable under the provisioned interpreter"
                ),
            )
        if status != "broken":
            return None
        if reviewer:
            remediate = (
                "the workspace Python does not match the project's requirement, "
                "so the suite cannot be executed to verify this PR. call "
                "pr_fail(issues=['toolchain: interpreter mismatch — suite "
                "cannot run']) so the PR returns to needs_revision and the dev "
                "rebuilds the environment — do NOT pr_pass on a source read"
            )
        else:
            remediate = (
                "the workspace Python does not match the project's requirement; "
                "call i_am_blocked(reason='toolchain') so the environment is "
                "rebuilt against the right interpreter — do NOT pass on a "
                "source read"
            )
        return Envelope.invalid_state(
            message=(
                "the project's test suite cannot be executed in this workspace "
                "(interpreter mismatch) — verifying by reading source is hollow"
            ),
            remediate=remediate,
            context_briefing={},
        )

    async def _conventions_gate(self, ctx: _IAmDoneContext) -> Envelope | None:
        """Block i_am_done on unresolved block-level architectural violations.

        Also persists the findings for the panel's violations feed — even the
        ones that block this submit.
        """
        from roboco.config import settings as _settings

        if not _settings.conventions_enabled:
            return None
        result = await self.git.conventions_check_for_task(ctx.agent_id, ctx.task)
        await self._record_convention_findings(ctx.task, result)
        return self._conventions_rejection(result, ctx.briefing)

    @staticmethod
    async def _record_convention_findings(task: Any, result: dict[str, Any]) -> None:
        """Persist the task's findings for the feed, best-effort.

        Runs in its OWN committed session so a finding that *blocks* the submit
        is captured regardless of the verb's transaction outcome.
        """
        project_id = getattr(task, "project_id", None)
        task_id = getattr(task, "id", None)
        if project_id is None or task_id is None:
            return
        try:
            from roboco.db.base import get_session_factory
            from roboco.services.conventions import get_conventions_service

            factory = get_session_factory()
            async with factory() as db:
                await get_conventions_service(db).record_findings(
                    UUID(str(project_id)),
                    UUID(str(task_id)),
                    result.get("findings", []),
                )
                await db.commit()
        except Exception as exc:
            logger.warning(
                "Recording convention findings failed (non-fatal)", error=str(exc)
            )

    async def _conventions_guard(
        self,
        agent_id: UUID,
        task: Any,
        briefing: dict[str, Any],
        preferred_parent: str | None = None,
    ) -> Envelope | None:
        """Run the conventions validator on the actor's changed files (gated).

        A ``block`` finding (a misplaced definition, a lint suppression) or a
        validator that could not run returns a rejection with the offending
        ``file:line`` + fix hint. ``warn`` findings never block. Inert when the
        flag is off. This is the pr_pass (reviewer) path — the remediation is
        reviewer-aware (``pr_fail``, not ``i_am_blocked`` which a reviewer
        lacks) via ``_conventions_rejection(..., reviewer=True)``.

        ``preferred_parent`` is the assembled task's real parent branch (see
        ``PRGateMixin._gate_diff_parent``) — the same cross-team-correct base
        the gate's own diff evidence uses, so a misplaced-definition finding
        can't be raised against inherited base-branch content.
        """
        from roboco.config import settings as _settings

        if not _settings.conventions_enabled:
            return None
        result = await self.git.conventions_check_for_task(
            agent_id, task, preferred_parent=preferred_parent
        )
        return self._conventions_rejection(result, briefing, reviewer=True)

    @staticmethod
    def _conventions_rejection(
        result: dict[str, Any], briefing: dict[str, Any], *, reviewer: bool = False
    ) -> Envelope | None:
        """Turn a validator result into a rejection Envelope, or None to pass.

        ``reviewer=True`` for the pr_pass gate: a reviewer has no
        ``i_am_blocked`` verb, so the could_not_run remediation points at
        ``pr_fail`` instead.
        """
        if result.get("could_not_run"):
            if reviewer:
                remediate = (
                    "the validator failed to analyze the diff (a parse or grammar "
                    "error). call pr_fail(issues=['conventions: validator could "
                    "not run on the changed files']) so the PR returns to "
                    "needs_revision and the dev resolves it — do NOT pr_pass"
                )
            else:
                remediate = (
                    "the validator failed to analyze the diff (a parse or grammar "
                    "error). resolve it and call the verb again; if it persists, "
                    "call i_am_blocked"
                )
            return Envelope.invalid_state(
                message=(
                    "the architectural-conventions validator could not run on "
                    "your changed files — this blocks rather than passing silently"
                ),
                remediate=remediate,
                context_briefing=briefing,
            )
        blocks = [f for f in result.get("findings", []) if f.get("level") == "block"]
        if not blocks:
            return None
        listing = "\n".join(
            f"- {f.get('file')}:{f.get('line')} — {f.get('fix_hint')}" for f in blocks
        )
        if reviewer:
            # The pr_pass gate runs on the REVIEWER, who has no commit verb on
            # the assembled branch; the only lever is pr_fail — bounce the PR
            # back to needs_revision with the findings as issues so the dev
            # fixes the violation or commits a waiver.
            remediate = (
                "the assembled PR carries block-level architectural-convention"
                " violations. call pr_fail(issues=[<file:line — fix_hint>, ...])"
                " with the findings below so the PR returns to needs_revision and"
                " the dev places each definition in the module the architecture"
                " map assigns it, or — if a finding is a false positive — commits"
                " a waiver to .roboco/conventions.yml in the PR branch for review"
                " and re-submits:\n\n" + listing
            )
        else:
            remediate = (
                "place each definition in the module the architecture map assigns "
                "it, then commit and call the verb again. if a finding is a false "
                "positive, add a waiver to .roboco/conventions.yml in your branch "
                "for the PR to review:\n\n" + listing
            )
        return Envelope.invalid_state(
            message=(
                f"{len(blocks)} architectural-convention violation(s) must be "
                "fixed before this can proceed"
            ),
            remediate=remediate,
            context_briefing=briefing,
        )

    async def _ensure_branch_pushed(self, ctx: _IAmDoneContext) -> Envelope | None:
        """Push the task branch to origin before it reaches awaiting_qa.

        QA reviews the remote PR branch. A fix committed during a revision
        cycle lives only in the developer's local workspace until pushed —
        without this, QA re-reviews the stale remote and fails the same task
        every cycle (a non-converging loop). Idempotent: a no-op when nothing
        is unpushed, so first-submit (already pushed by open_pr) is unaffected.
        """
        try:
            await self.git.push_task_branch(ctx.agent_id, ctx.task_id)
        except Exception as exc:
            return Envelope.invalid_state(
                message=f"could not push your branch to origin: {exc}",
                remediate=(
                    "your latest commits are local-only and QA reviews the "
                    "pushed PR branch. resolve the push error (often a "
                    "transient network / fetch timeout) and call i_am_done "
                    "again. if it says your branch is behind its remote "
                    "counterpart, call sync_branch() to rebase + force-push. "
                    "if sync_branch reports a dirty workspace, commit(...) "
                    "(omit files to stage everything) THEN sync_branch() "
                    "THEN i_am_done() again. still stuck? unclaim() releases "
                    "the task back to the pool."
                ),
                context_briefing=ctx.briefing,
            )
        return None

    async def _assembled_submit_guards(
        self, t: Any, task_id: UUID, verb: str
    ) -> Envelope | None:
        """Assembly integrity (#11) then behind-base auto-sync (B2) for the
        assembled PM submits. Base resolution fails open (a malformed parent
        ref must not strand the submit — the merge layer keeps its checks)."""
        guard = await self._assembly_integrity_guard(t, verb=verb)
        if guard is not None:
            return guard
        try:
            base_branch = await resolve_parent_branch(t, self.task)
        except Exception as exc:
            logger.warning(
                "assembled_freshen_base_skip",
                task_id=str(task_id),
                error=str(exc),
            )
            base_branch = ""
        return await self._freshen_assembled_branch(
            t, base_branch=base_branch, verb=verb
        )

    async def _assembly_integrity_guard(self, t: Any, *, verb: str) -> Envelope | None:
        """Refuse the assembled submit when a completed child's work is missing.

        Live incident #11: a completed revert subtask's commit never landed on
        the assembled cell branch, so the review gate re-flagged the exact
        violation the revert fixed. Patch-equivalence check (rebase-safe);
        fail-open on git errors — the review gate remains the backstop.
        """
        if not getattr(t, "branch_name", None):
            return None
        try:
            missing = list(await self.git.unmerged_child_commits(t) or [])
        except Exception as exc:
            logger.warning("assembly_integrity_skip", task_id=str(t.id), error=str(exc))
            return None
        if not missing:
            return None
        listing = "; ".join(
            f"{m['title']} ({m['task_id']}, {m['unmerged']} commit(s))" for m in missing
        )
        return Envelope.invalid_state(
            message=(
                f"{verb} refused: completed subtask work is MISSING from the "
                f"assembled branch — {listing}"
            ),
            remediate=(
                "merge each listed child's branch into the assembled branch "
                "(their completion recorded a merge that is not reflected on "
                "origin), then re-submit. Submitting now re-reviews a diff "
                "that silently drops finished work."
            ),
            context_briefing={},
        )

    async def _freshen_assembled_branch(
        self, t: Any, *, base_branch: str, verb: str
    ) -> Envelope | None:
        """Behind-base auto-sync for the assembled PM submits (B2).

        Re-submitting a cell/root head whose base moved re-reviews stale work
        and ping-pongs needs_revision ↔ awaiting_pr_review (live 2026-07-02).
        Every child is terminal at submit time, so rebasing the assembled
        branch onto its base is safe; ``sync_task_branch`` pushes only the
        HEAD branch (master/main are never written). Fail-open on probe/sync
        errors — the PR/merge layer keeps its own behind checks — but a rebase
        CONFLICT is a hard reject naming the files, so the PM routes a
        conflict-resolution revision instead of re-submitting blind.
        """
        if not getattr(t, "branch_name", None) or not base_branch:
            return None
        try:
            behind, _ahead = await self.git.is_behind_base(t, base_branch=base_branch)
        except Exception as exc:
            logger.warning("assembled_freshen_skip", task_id=str(t.id), error=str(exc))
            return None
        if behind <= 0:
            return None
        try:
            result = await self.git.sync_task_branch(t, base_branch=base_branch)
        except Exception as exc:
            logger.warning(
                "assembled_freshen_sync_failed", task_id=str(t.id), error=str(exc)
            )
            return None
        if result.get("status") == "conflicts":
            files = ", ".join(result.get("files") or []) or "unknown files"
            return Envelope.invalid_state(
                message=(
                    f"{verb} refused: the assembled branch was {behind} "
                    f"commit(s) behind its base '{base_branch}' and the "
                    f"auto-rebase hit conflicts in: {files}"
                ),
                remediate=(
                    "the base moved under this branch and the conflict needs a "
                    "human-quality merge: delegate a conflict-resolution "
                    "revision for the listed files, then re-submit. Do NOT "
                    "re-submit unchanged — the review gate will fail the same "
                    "stale diff again."
                ),
                context_briefing={},
            )
        logger.info(
            "assembled_branch_freshened",
            task_id=str(t.id),
            verb=verb,
            base_branch=base_branch,
            behind=behind,
            status=result.get("status"),
        )
        return None

    async def _behind_base_gate(self, ctx: _IAmDoneContext) -> Envelope | None:
        """Refuse i_am_done when the task branch has fallen behind its base.

        A sibling's PR merged into the parent branch while this dev worked →
        the head lacks that merged work, so the assembled PR can't merge
        cleanly and a sibling's changes go missing from this branch (the
        2026-06-27 out-of-order dev-task break). The dev must ``sync_branch``
        (the gate-level rebase verb) before submitting. Fail-open on git /
        base-resolution error so a flaky fetch can't strand a task at the
        submit gate — the merge layer has its own behind checks. Skipped for
        branchless coordination roots (no ``branch_name``) and when the base
        resolves to a protected branch (master/main) or a ``-``-prefixed ref.
        Runs after ``_ensure_branch_pushed`` so origin reflects the pushed head.
        """
        t = ctx.task
        if not getattr(t, "branch_name", None):
            return None
        try:
            base_branch = await resolve_parent_branch(t, self.task)
        except Exception as exc:
            logger.warning("behind_base_skip", task_id=str(ctx.task_id), error=str(exc))
            return None
        if (
            not base_branch
            or base_branch.startswith("-")
            or base_branch in ("master", "main")
        ):
            return None
        try:
            behind, _ahead = await self.git.is_behind_base(
                t, base_branch=base_branch, actor_agent_id=ctx.agent_id
            )
        except Exception as exc:
            logger.warning("behind_base_skip", task_id=str(ctx.task_id), error=str(exc))
            return None
        if behind > 0:
            return Envelope.invalid_state(
                message=(
                    f"your branch is {behind} commit(s) behind its base "
                    f"'{base_branch}' — a sibling's PR merged into the parent "
                    f"branch while you worked; your branch is missing that work"
                ),
                remediate=(
                    "call sync_branch(task_id) to rebase your branch onto its base "
                    "through the gate, then i_am_done again. do NOT submit a branch "
                    "that is behind its base — the PR cannot merge cleanly"
                ),
                context_briefing=ctx.briefing,
            )
        return None

    @staticmethod
    def _extract_first_commit_sha(t: Any) -> str | None:
        """Read the first commit hash off the task, dict or model alike."""
        commits: list[Any] = list(getattr(t, "commits", []) or [])
        if not commits:
            return None
        first = commits[0]
        if isinstance(first, dict):
            return first.get("hash") or first.get("sha")
        return getattr(first, "hash", None) or getattr(first, "sha", None)

    @staticmethod
    def _already_addressed_criteria(existing_status: list[dict[str, Any]]) -> set[str]:
        """Criteria already carrying a non-empty referencing_artifact_id."""
        return {
            s["criterion"]
            for s in existing_status
            if isinstance(s, dict) and s.get("referencing_artifact_id")
        }

    @staticmethod
    def _find_existing_entry(
        existing_status: list[dict[str, Any]], criterion: str
    ) -> dict[str, Any] | None:
        """First existing entry whose criterion key matches; None otherwise."""
        for entry in existing_status:
            if isinstance(entry, dict) and entry.get("criterion") == criterion:
                return entry
        return None

    @staticmethod
    def _new_criterion_entry(
        criterion: str, has_reflect: bool, first_commit_sha: str | None, now_iso: str
    ) -> dict[str, Any]:
        """Build the per-criterion status row for an unaddressed criterion."""
        addressed = has_reflect or first_commit_sha is not None
        if not addressed:
            artifact_ref: str | None = None
        else:
            artifact_ref = first_commit_sha if first_commit_sha else "reflect-note"
        return {
            "criterion": criterion,
            "addressed": addressed,
            "artifact_ref": artifact_ref,
            "checked_at": now_iso,
        }

    async def _write_criteria_status(
        self, agent_id: UUID, task_id: UUID, t: Any
    ) -> None:
        """Persist per-criterion addressing status to task.acceptance_criteria_status.

        Pre-gateway parity: the i_am_done gate uses
        journal:reflect as a blanket addressing artifact when it is present
        (one reflect note covers all criteria — spec §9 item 1). We surface
        that decision as a structured per-criterion list so the panel and
        audit log can render per-criterion checkmarks.

        Already-addressed entries (those already carrying a non-empty
        referencing_artifact_id) are preserved as-is. Only criteria not yet
        addressed receive a new entry. The artifact_ref for newly-addressed
        criteria is the first commit sha if one exists, otherwise "reflect-note".
        """
        criteria: list[str] = list(getattr(t, "acceptance_criteria", []) or [])
        if not criteria:
            return

        existing_status: list[dict[str, Any]] = list(
            getattr(t, "acceptance_criteria_status", []) or []
        )
        already_addressed = self._already_addressed_criteria(existing_status)
        if already_addressed >= set(criteria):
            return

        first_commit_sha = self._extract_first_commit_sha(t)
        has_reflect = await self.journal.has_reflect_for_task(agent_id, task_id)
        now_iso = datetime.now(UTC).isoformat()

        new_status: list[dict[str, Any]] = []
        for criterion in criteria:
            if criterion in already_addressed:
                preserved = self._find_existing_entry(existing_status, criterion)
                if preserved is not None:
                    new_status.append(preserved)
                continue
            new_status.append(
                self._new_criterion_entry(
                    criterion, has_reflect, first_commit_sha, now_iso
                )
            )

        await self.task.set_acceptance_criteria_status(task_id, new_status)

    async def _i_am_done_run(
        self, ctx: _IAmDoneContext, agent: Any, spec_ctx: spec_module.Context
    ) -> Envelope:
        """Dispatch the spec-composed (submit_verification, submit_qa) chain."""
        runner = self._verb_runner()
        try:
            t = await runner.run_intent("i_am_done", ctx.task, agent, spec_ctx)
        except Exception as exc:
            return await self._reject_i_am_done(
                ctx,
                Envelope.invalid_state(
                    message=f"verb runner failed: {exc}",
                    remediate="check workspace + retry; if persistent, escalate",
                    context_briefing=ctx.briefing,
                ),
            )
        await self._notify_qa(ctx.agent_id, ctx.task_id, t)
        await self._touch(ctx.task_id)
        # Server-side milestone progress so the panel always
        # records the QA handoff regardless of agent's progress() habits.
        await self._record_milestone_progress(
            ctx.task_id,
            ctx.agent_id,
            "submitted for QA review",
            percentage=90,
        )
        return await self._build_i_am_done_ok(ctx.agent_id, ctx.task_id, t)

    async def _i_am_done_resume_from_verifying(self, ctx: _IAmDoneContext) -> Envelope:
        """Recovery path: task is already in `verifying` owned by caller.

        The spec's i_am_done composes (submit_verification, submit_qa) and
        the runner dispatches the FIRST action; submit_verification's
        source_status is IN_PROGRESS so a `verifying` task hits invalid_state
        through the spec gate. Run submit_qa directly, plus the same tracing
        + field-level gates the standard path enforces.
        """
        if gate_rejection := await self._i_am_done_gate(ctx):
            return gate_rejection
        try:
            submitted = await self.task.submit_qa(ctx.agent_id, ctx.task_id, ctx.notes)
        except Exception as exc:
            return await self._reject_i_am_done(
                ctx,
                Envelope.invalid_state(
                    message=f"submit_qa failed: {exc}",
                    remediate="check workspace + retry; if persistent, escalate",
                    context_briefing=ctx.briefing,
                ),
            )
        t = submitted if submitted is not None else ctx.task
        await self._notify_qa(ctx.agent_id, ctx.task_id, t)
        await self._touch(ctx.task_id)
        return await self._build_i_am_done_ok(ctx.agent_id, ctx.task_id, t)

    async def _i_am_done_pre_gate_dispatch(
        self, ctx: _IAmDoneContext, t: Any, agent_id: UUID
    ) -> Envelope | None:
        """Pre-spec-gate short-circuits, folded to keep i_am_done within its
        return-count budget:

        1. verifying-resume recovery (task already ``verifying`` owned by the
           caller — orchestrator restart between submit_verification and
           submit_qa; the spec gate would reject the first composed action).
        2. the possibilities-matrix fast path (work already done).

        Returns the dispatched envelope, or None to fall through to the
        standard spec/soup/gate path.
        """
        if str(t.status) == "verifying" and t.assigned_to == agent_id:
            return await self._i_am_done_resume_from_verifying(ctx)
        return await self._maybe_i_am_done_fast_path(ctx, t, agent_id)

    async def _maybe_i_am_done_fast_path(
        self, ctx: _IAmDoneContext, t: Any, agent_id: UUID
    ) -> Envelope | None:
        """Possibilities-matrix fast-path gate: returns the fast-path envelope
        when the flag is armed, the task is claimed/in_progress and owned by
        the caller, and ``_work_appears_done`` holds; None to fall through to
        the standard i_am_done path. Extracted from i_am_done to keep that
        verb within its return-count budget. ``assigned_to`` is asserted here
        so a reassigned agent cannot fast-path a task that is no longer theirs.
        """
        from roboco.config import settings as _settings

        if not _settings.possibilities_matrix_enabled:
            return None
        if str(t.status) not in ("claimed", "in_progress"):
            return None
        if t.assigned_to != agent_id:
            return None
        if not await self._work_appears_done(t):
            return None
        return await self._i_am_done_fast_path(ctx)

    async def _fast_path_rejection(self, ctx: _IAmDoneContext) -> Any:
        """The fast path's ordered rejection cascade; None means proceed.

        Order matters: notes first (cheap, no writes), then finding
        resolution + guards, then the open-findings re-check, then the
        quality verdict with its toolchain backstop.
        """
        if soup := self._soup_reason(ctx.notes, "notes", 4):
            return soup
        await self._apply_resolved_findings(ctx)
        guards = (
            lambda: self._check_submit_qa_field_gates(
                ctx.agent_id, ctx.task_id, ctx.task
            ),
            # Push-first, mirroring the standard gate: _behind_base_gate reads
            # origin, which must already reflect the pushed head.
            lambda: self._ensure_branch_pushed(ctx),
            lambda: self._behind_base_gate(ctx),
            lambda: self._conventions_gate(ctx),
        )
        for guard in guards:
            if rejection := await guard():
                return rejection
        # FINDINGS_ADDRESSED re-checked post-resolution: a resolved_findings entry
        # in THIS call may have closed the open set; anything still open blocks.
        if await self._open_finding_ids(ctx.task_id):
            return Envelope.invalid_state(
                message="fast path blocked — open findings remain on the ledger",
                remediate=(
                    "name every open finding in resolved_findings "
                    "({finding_id, note}), or leave possibilities_matrix off "
                    "and run the standard i_am_done path"
                ),
                context_briefing=ctx.briefing,
            )
        rejection, ran_local = await self._fast_path_quality_verdict(ctx)
        # The local gate ran (no CI signal) without a toolchain that can
        # execute it is a hollow pass — pair the two like the standard gate
        # does. Skipped when CI-green already decided (no local run at all).
        if rejection is None and ran_local:
            rejection = await self._toolchain_broken_guard(ctx.agent_id, ctx.task)
        return rejection

    async def _i_am_done_fast_path(self, ctx: _IAmDoneContext) -> Envelope:
        """Work-already-done fast path: slimmed gates + direct transition chain.

        Skips the turn-costly gates the standard ``_i_am_done_gate`` runs: the
        retroactive rich-plan gate (``start`` without ``set_plan`` — a done task
        has no plan to author), the journal tracing gates (``notes`` is the
        reflect/handoff substitute, recorded as a progress entry by
        ``submit_verification`` / ``submit_qa``), and the local quality gate
        (the CI-green proxy in ``_fast_path_quality_verdict``). Keeps the
        non-negotiable gates the predicate already asserts, plus conventions
        (leaf dev tasks skip ``awaiting_pr_review`` so the conventions check
        must not also be skipped here — it is tree-sitter, sub-second, 0 turns
        when green). Since ``notes`` is the sole compensating control for the
        skipped journal gates, it is enforced here directly (empty AND soup —
        unlike the standard path's soup-only check, which skips an empty value
        because presence there is gated by the journal requirements this path
        doesn't run).
        """
        if rejection := await self._fast_path_rejection(ctx):
            return await self._reject_i_am_done(ctx, rejection)
        try:
            if str(ctx.task.status) == "claimed":
                await self.task.start(ctx.task_id, ctx.agent_id, "developer")
            await self.task.submit_verification(ctx.agent_id, ctx.task_id, ctx.notes)
            submitted = await self.task.submit_qa(ctx.agent_id, ctx.task_id, ctx.notes)
        except Exception as exc:
            return await self._reject_i_am_done(
                ctx,
                Envelope.invalid_state(
                    message=f"fast-path transition failed: {exc}",
                    remediate="check workspace + retry; if persistent, call i_am_idle",
                    context_briefing=ctx.briefing,
                ),
            )
        t = submitted if submitted is not None else ctx.task
        await self._notify_qa(ctx.agent_id, ctx.task_id, t)
        await self._touch(ctx.task_id)
        await self._record_milestone_progress(
            ctx.task_id, ctx.agent_id, "submitted for QA review", percentage=90
        )
        return await self._build_i_am_done_ok(ctx.agent_id, ctx.task_id, t)

    async def _open_finding_ids(self, task_id: UUID) -> tuple[str, ...]:
        """8-char ids of the task's still-OPEN revision-ledger findings.

        Read AFTER ``_apply_resolved_findings`` has already run for this
        call (see ``_i_am_done_gate``), so a resolution supplied in THIS
        i_am_done call is already reflected. Empty for a task with no
        findings at all — the FINDINGS_ADDRESSED gate is then trivially
        satisfied. Fails OPEN (treats a lookup failure as "nothing open")
        on any error — a transient ledger-read hiccup must never turn a new
        guardrail into an i_am_done outage, mirroring every other
        best-effort DB/git lookup in this module (e.g. ``_resolve_ci_status``).
        """
        try:
            repo = ReviewFindingsRepository(self.task.session)
            open_rows = await repo.list_for_task(task_id, status=STATUS_OPEN)
        except Exception:
            logger.warning(
                "open_finding_ids lookup failed — failing open", task_id=str(task_id)
            )
            return ()
        return tuple(str(row.id)[:8] for row in open_rows)

    async def _work_appears_done(self, t: Any) -> bool:
        """True when a task's work is provably already done — the gate for the
        possibilities-matrix fast path. status in {claimed, in_progress,
        verifying} + >=1 commit + PR open + every acceptance criterion
        addressed + no open findings. Ownership is NOT checked here (the
        fast-path branch asserts ``assigned_to`` so a reassigned agent cannot
        fast-path a task that is no longer theirs).

        A criterion counts as addressed if its per-criterion row carries a
        non-empty artifact reference OR an explicit ``addressed`` flag. The
        writer (``_new_criterion_entry``) stores ``artifact_ref`` while the
        canonical reader (``_already_addressed_criteria``) looks for
        ``referencing_artifact_id`` — a latent key drift masked today because
        the standard AC gate passes via the ``journal_reflect_present``
        blanket. The fast path skips that blanket (it skips the reflect gate),
        so this predicate reads BOTH keys plus ``addressed`` to see real data
        rather than ship dead code.
        """
        if str(t.status) not in ("claimed", "in_progress", "verifying"):
            return False
        if not getattr(t, "commits", None):
            return False
        if not (getattr(t, "pr_created", False) or getattr(t, "pr_number", None)):
            return False
        if not self._all_criteria_addressed(t):
            return False
        return not await self._open_finding_ids(t.id)

    @staticmethod
    def _all_criteria_addressed(t: Any) -> bool:
        """Every acceptance criterion has a per-criterion row marked addressed.

        A criterion counts as addressed if its row carries a non-empty
        artifact reference OR an explicit ``addressed`` flag. The writer
        (``_new_criterion_entry``) stores ``artifact_ref`` while the canonical
        reader (``_already_addressed_criteria``) looks for
        ``referencing_artifact_id`` — a latent key drift masked today because
        the standard AC gate passes via the ``journal_reflect_present``
        blanket. The fast path skips that blanket (it skips the reflect gate),
        so this reads BOTH keys plus ``addressed`` to see real data rather
        than ship dead code. A task with no criteria is trivially covered.
        """
        criteria = list(getattr(t, "acceptance_criteria", []) or [])
        if not criteria:
            return True
        rows = list(getattr(t, "acceptance_criteria_status", []) or [])
        addressed: set[str] = set()
        for row in rows:
            if not isinstance(row, dict):
                continue
            if not (
                row.get("referencing_artifact_id")
                or row.get("artifact_ref")
                or row.get("addressed")
            ):
                continue
            crit = row.get("criterion")
            if crit:
                addressed.add(str(crit))
        return addressed >= set(criteria)

    async def _check_tracing_gates(
        self, agent_id: UUID, task_id: UUID, t: Any
    ) -> Envelope | None:
        """Run progress / reflect / acceptance-criteria / during-work tracing gates.

        The spec composes (submit_verification, submit_qa) for i_am_done and
        the auto-run submit_verification flips self_verified=True before
        submit_qa runs. SELF_VERIFIED therefore acts as a defense-in-depth
        backstop *after* the spec — checking it pre-flight would block the
        auto-verify path. It is filtered here and re-asserted by the spec
        action's own preconditions.
        """
        from roboco.config import settings as _settings
        from roboco.foundation.policy import tracing as _tr

        has_reflect = await self.journal.has_reflect_for_task(agent_id, task_id)
        has_decision = await self.journal.has_decision_for_task(agent_id, task_id)
        has_learning = await self.journal.has_learning_for_task(agent_id, task_id)
        has_struggle = await self.journal.has_struggle_for_task(agent_id, task_id)
        during_work_count = sum([has_decision, has_learning, has_struggle])
        open_finding_ids = await self._open_finding_ids(task_id)

        ctx = _tr.GateContext(
            journal_reflect_present=has_reflect,
            journal_decision_present=has_decision,
            journal_learning_present=has_learning,
            journal_struggle_present=has_struggle,
            journal_during_work_count=during_work_count,
            # DEV_NOTES_MIN_CHARS reads the persisted task.dev_notes — the dev
            # pre-writes it via note(scope='handoff') before i_am_done.
            dev_notes_min_chars=_settings.dev_notes_min_chars,
            open_finding_ids=open_finding_ids,
        )
        requirements: list[_tr.Requirement] = [
            r
            for r in _tr.requirements_for("i_am_done")
            if r is not _tr.Requirement.SELF_VERIFIED
        ]
        result = _tr.check_requirements(
            task=t,
            requirements=requirements,
            ctx=ctx,
        )
        if result.passed:
            return None
        return await self._build_tracing_gap(agent_id, task_id, result.missing, task=t)

    async def _post_claim_journal_gate(
        self,
        verb: str,
        agent_id: UUID,
        task_id: UUID,
        envelope: Envelope,
    ) -> Envelope:
        """Apply the claim-time journal tracing gate AFTER a successful claim.

        Pre-gateway parity (spec §11 P1, P3): the (claim, set_plan, start)
        sequence is allowed to commit so the agent owns the task, then we
        verify the matching journal entry exists. If absent, the agent
        gets a tracing_gap with a remediation hint — they journal and
        retry the verb (idempotent re-entry shortcuts back to OK once the
        entry is present).

        If `envelope` is already an error (claim failed or the runner
        rejected), we pass it through untouched — no point demanding a
        journal note when the claim itself didn't stick.
        """
        if envelope.error is not None:
            return envelope
        t = await self.task.get(task_id)
        if t is None:
            return envelope
        gap = await self._check_claim_journal_at_claim(verb, agent_id, task_id, t)
        return gap if gap is not None else envelope

    async def _check_claim_journal_at_claim(
        self, verb: str, agent_id: UUID, task_id: UUID, t: Any
    ) -> Envelope | None:
        """Post-claim tracing gate for i_will_work_on / i_will_plan.

        Pre-gateway parity (spec §11 P1, P3): developers wrote a
        journal:note on every claim; PMs wrote a journal:decision on
        plan. The check runs AFTER the composed (claim, set_plan, start)
        sequence has succeeded — the claim itself stays. If the journal
        entry is missing, the agent receives a tracing_gap envelope and
        must journal then retry the verb (similar to how i_am_done's
        post-claim gates work).

        ``PLAN`` is filtered out of the required-set because the spec's
        composed action has already enforced PRECONDITION_PLAN before
        reaching this point — re-asserting it here would be redundant
        and produce a misleading hint when the only real failure is the
        missing journal entry.
        """
        from roboco.foundation.policy import tracing as _tr

        ctx = _tr.GateContext()
        if verb == "i_will_work_on":
            has_note = await self.journal.has_note_for_task(agent_id, task_id)
            ctx = _tr.GateContext(journal_note_at_claim_present=has_note)
        elif verb == "i_will_plan":
            has_decision = await self.journal.has_decision_for_task(agent_id, task_id)
            ctx = _tr.GateContext(journal_decision_present=has_decision)
        requirements: list[_tr.Requirement] = [
            r for r in _tr.requirements_for(verb) if r is not _tr.Requirement.PLAN
        ]
        result = _tr.check_requirements(
            task=t,
            requirements=requirements,
            ctx=ctx,
        )
        if result.passed:
            return None
        return await self._build_tracing_gap(agent_id, task_id, result.missing, task=t)

    async def _ensure_pm_decision(
        self, agent_id: UUID, task_id: UUID, rationale: str | None
    ) -> None:
        """Auto-record a journal:decision from a PM verb's own rationale.

        The write-then-gate pattern (mirrors i_am_blocked → write_struggle):
        a PM verb that already carries a substantive rationale — complete /
        submit_up / submit_root ``notes``, escalate ``reason``, or a
        synthesized unblock context line — records it as the decision
        *before* the journal:decision tracing gate runs. A loaded or weak
        model that forgot the separate note(scope='decision') call then no
        longer stalls in a tracing_gap → respawn loop; the gate still runs
        as defense-in-depth and now passes off the just-written entry.

        Idempotent within the decision window: when a fresh decision already
        satisfies the gate, no duplicate is written. Best-effort — a journal
        write failure is logged and swallowed so the verb falls through to
        the normal gate (which rejects as before), never crashing the verb.
        """
        from roboco.config import settings as _settings

        text = (rationale or "").strip()
        if not text:
            return
        try:
            latest = await self.journal.latest_decision_at(agent_id, task_id)
            window = _settings.pm_decision_window_seconds
            if (
                latest is not None
                and (datetime.now(UTC) - latest).total_seconds() <= window
            ):
                return
            await self.journal.write_decision(
                agent_id=agent_id, task_id=task_id, content=text
            )
        except Exception as exc:  # best-effort; gate rejects normally on failure
            logger.warning(
                "auto-record pm decision failed",
                error=str(exc),
                task_id=str(task_id),
            )

    async def _check_pm_decision_required(
        self, verb: str, agent_id: UUID, task_id: UUID, t: Any
    ) -> Envelope | None:
        """Standard PM-verb tracing gate driven by VERB_REQUIREMENTS.

        Used by ``unblock``, ``escalate_up``, ``escalate_to_ceo``, and
        ``delegate`` — each declares only ``JOURNAL_DECISION`` in the
        foundation table. Verbs requiring more (``complete``,
        ``submit_up``) use the verb-specific helpers below which thread
        the additional state (reflect, notes, subtasks) into GateContext.

        Pre-gateway parity: the gate requires the
        *most recent* journal:decision for (agent, task) to be no older
        than ``settings.pm_decision_window_seconds``. Older decisions are
        treated as missing so PMs write a fresh decision around each
        decision point rather than once at task creation.
        """
        from roboco.config import settings as _settings
        from roboco.foundation.policy import tracing as _tr

        # C8: recency-window only. Per-verb-group consumption tracking
        # (one decision satisfies exactly one delegate/unblock/escalate
        # call, then is consumed) is out of scope — Choreographer is
        # per-request so multi-call state would need a persistent store.
        latest = await self.journal.latest_decision_at(agent_id, task_id)
        window_seconds = _settings.pm_decision_window_seconds
        fresh = (
            latest is not None
            and (datetime.now(UTC) - latest).total_seconds() <= window_seconds
        )

        # QUICK_CONTEXT_MIN_CHARS applies only to ``delegate`` (its
        # required-set is the only one carrying it); the PM pre-writes the
        # parent's quick_context via note(scope='handoff') before delegating.
        # Setting the threshold here is inert for unblock / escalate, which do
        # not require it.
        ctx = _tr.GateContext(
            journal_decision_present=fresh,
            quick_context_min_chars=_settings.quick_context_min_chars,
        )
        result = _tr.check_requirements(
            task=t,
            requirements=list(_tr.requirements_for(verb)),
            ctx=ctx,
        )
        if result.passed:
            return None
        return await self._build_tracing_gap(agent_id, task_id, result.missing, task=t)

    async def _check_complete_gates(
        self, agent_id: UUID, task_id: UUID, notes: str
    ) -> Envelope | None:
        """Tracing gate for cell-PM and main-PM ``complete`` verbs.

        VERB_REQUIREMENTS["complete"] = JOURNAL_DECISION + JOURNAL_REFLECT
        + NOTES_MIN_CHARS. ``SUBTASKS_TERMINAL`` is enforced separately by
        ``_subtasks_not_terminal_envelope`` in the cell/main complete
        guards because that gate emits a richer remediation message
        listing the non-terminal subtasks; keeping it inline preserves
        that UX.
        """
        from types import SimpleNamespace

        from roboco.config import settings as _settings
        from roboco.foundation.policy import tracing as _tr

        has_decision = await self.journal.has_decision_for_task(agent_id, task_id)
        has_reflect = await self.journal.has_reflect_for_task(agent_id, task_id)
        task_view = SimpleNamespace(notes=notes)
        ctx = _tr.GateContext(
            journal_decision_present=has_decision,
            # A PM closing/submitting a task documents it in its *decision* note;
            # a separate *reflect* adds little for a coordination/review close and
            # is exactly the artifact weak-model PMs forget — looping on the
            # reflect gate until reaped (re-confirmed live 2026-06-10). Accept a
            # decision as satisfying reflect for the PM complete/submit_up close;
            # the gate still requires a decision + substantive notes, so the close
            # stays documented — only the redundant second-artifact demand drops.
            journal_reflect_present=has_reflect or has_decision,
            notes_min_chars=getattr(_settings, "notes_min_chars", 20),
        )
        result = _tr.check_requirements(
            task=task_view,
            requirements=list(_tr.requirements_for("complete")),
            ctx=ctx,
        )
        if result.passed:
            return None
        return await self._build_tracing_gap(agent_id, task_id, result.missing)

    async def _check_submit_up_gates(
        self, agent_id: UUID, task_id: UUID, notes: str
    ) -> Envelope | None:
        """Tracing gate for ``submit_up`` (cell PM bubble-up).

        VERB_REQUIREMENTS["submit_up"] = SUBTASKS_TERMINAL + JOURNAL_DECISION
        + JOURNAL_REFLECT + NOTES_MIN_CHARS + FINDINGS_ADDRESSED. The notes
        value is threaded through a SimpleNamespace shim because the verb
        hasn't persisted it to the task yet. ``SUBTASKS_TERMINAL`` is filtered
        out here because the inline ``_subtasks_not_terminal_envelope`` that
        follows enumerates the non-terminal subtask ids — strictly richer
        remediation than the generic foundation hint. Shared verbatim by
        ``submit_root`` (both route through ``_submit_up_guard``), so
        ``open_finding_ids`` covers pr_gate/pm/ceo-origin findings on either
        a cell root or a Main-PM root.
        """
        from types import SimpleNamespace

        from roboco.config import settings as _settings
        from roboco.foundation.policy import tracing as _tr

        has_decision = await self.journal.has_decision_for_task(agent_id, task_id)
        has_reflect = await self.journal.has_reflect_for_task(agent_id, task_id)
        open_finding_ids = await self._open_finding_ids(task_id)
        task_view = SimpleNamespace(notes=notes)
        ctx = _tr.GateContext(
            journal_decision_present=has_decision,
            # A PM closing/submitting a task documents it in its *decision* note;
            # a separate *reflect* adds little for a coordination/review close and
            # is exactly the artifact weak-model PMs forget — looping on the
            # reflect gate until reaped (re-confirmed live 2026-06-10). Accept a
            # decision as satisfying reflect for the PM complete/submit_up close;
            # the gate still requires a decision + substantive notes, so the close
            # stays documented — only the redundant second-artifact demand drops.
            journal_reflect_present=has_reflect or has_decision,
            notes_min_chars=getattr(_settings, "notes_min_chars", 20),
            open_finding_ids=open_finding_ids,
        )
        requirements: list[_tr.Requirement] = [
            r
            for r in _tr.requirements_for("submit_up")
            if r is not _tr.Requirement.SUBTASKS_TERMINAL
        ]
        result = _tr.check_requirements(
            task=task_view,
            requirements=requirements,
            ctx=ctx,
        )
        if result.passed:
            return None
        return await self._build_tracing_gap(agent_id, task_id, result.missing)

    async def _check_submit_qa_field_gates(
        self, agent_id: UUID, task_id: UUID, t: Any
    ) -> Envelope | None:
        """Gate Set E field-level gates restored from tasks.py:903-940 at 0c3d15a.

        Each missing field becomes its own tracing_gap entry with the
        matching pre-gateway error code.
        """
        missing: list[str] = []
        hints: list[str] = []
        # NOTE: self_verified is no longer a precondition — i_am_done auto-runs
        # the in_progress → verifying transition which sets it. The previous
        # NOT_SELF_VERIFIED gate required a separate submit_for_verification
        # verb that wasn't on any manifest.
        if not t.commits:
            missing.append("NO_COMMITS")
            hints.append(
                "no commits on this task yet — call commit(message='<subject>')"
                " before i_am_done"
            )
        if t.pr_number is None:
            missing.append("NO_PR")
            hints.append(
                "no PR open — call open_pr(task_id) to push your"
                " branch and open the PR, then retry i_am_done"
            )
        if not missing:
            return None
        return Envelope.tracing_gap(
            missing=missing,
            remediate=" ; ".join(hints),
            context_briefing=await self._briefing_for(agent_id, task_id, task=t),
        )

    async def _build_i_am_done_ok(
        self, agent_id: UUID, task_id: UUID, t: Any
    ) -> Envelope:
        """Assemble the success envelope for i_am_done / _with_catchup.

        files_changed sourced from git (authoritative) so the
        i_am_done envelope shows the same file list QA / docs / PMs will
        see — independent of legacy ``add_files_modified`` plumbing.
        """
        journal_highlights = await self.evidence_repo.journal_highlights_for_task(
            task_id
        )
        files_changed: list[str] = []
        if t.branch_name:
            files_changed = await self.git.list_changed_files(branch_name=t.branch_name)
        # Normally empty here — i_am_done's FINDINGS_ADDRESSED gate already
        # required every open finding resolved before this point — but wired
        # in for consistency with every other evidence call site.
        open_findings = await findings_lib.open_findings_for_task(
            self.task.session, task_id
        )
        evidence = build_evidence_for_task(
            t,
            journal_highlights=journal_highlights,
            files_changed=files_changed,
            revision_findings=open_findings,
        )
        agent = await self.task.agent_for(agent_id)
        role = str(agent.role) if agent is not None else "developer"
        await self._teardown_sandbox_best_effort(agent_id)
        return Envelope.ok(
            status=str(t.status),
            task_id=str(task_id),
            next="idle until QA responds",
            evidence=evidence.as_dict(),
            context_briefing=await self._briefing_for(agent_id, task_id, task=t),
        ).with_introspection(task=t, role=role)

    @staticmethod
    def _hint_for_missing_key(missing_key: str, task_id: UUID) -> str | None:
        """Map a single tracing-requirement key to its agent-facing hint.

        Returns ``None`` for keys that need composite handling (i.e.,
        ``acceptance_criterion:<name>``, which the caller batches into
        a single multi-criterion hint).
        """
        from roboco.config import settings as _roboco_settings

        tid = str(task_id)
        notes_min = getattr(_roboco_settings, "notes_min_chars", 20)
        simple_hints: dict[str, str] = {
            "progress>=1": hint_for_missing_progress(),
            "journal:reflect": hint_for_missing_reflect(task_id=tid),
            "journal:decision": hint_for_missing_journal_decision(),
            "qa_notes>=min": hint_for_missing_qa_notes(),
            "journal:learning": hint_for_missing_journal_learning(),
            "qa_evidence_inspected": hint_for_evidence_not_inspected(task_id=tid),
            "docs_notes>=min": hint_for_short_doc_notes(
                min_chars=_roboco_settings.docs_notes_min_chars
            ),
            "docs_files_non_empty": hint_for_missing_doc_files(),
            "dev_notes>=min": hint_for_short_dev_notes(
                min_chars=getattr(_roboco_settings, "dev_notes_min_chars", 40),
                task_id=tid,
            ),
            "pr_reviewer_notes>=min": hint_for_short_pr_reviewer_notes(
                min_chars=getattr(_roboco_settings, "pr_reviewer_notes_min_chars", 40),
            ),
            "quick_context>=min": hint_for_short_quick_context(
                min_chars=getattr(_roboco_settings, "quick_context_min_chars", 30),
                task_id=tid,
            ),
            "journal:note_at_claim": (
                "pre-gateway parity P1: write a journal:note at claim. "
                f"Call note(scope='note', task_id='{tid}', "
                "text='<initial assessment>') describing your read of the "
                "task, then retry i_will_work_on."
            ),
            "journal:decision_at_claim": (
                "pre-gateway parity P3: PMs write a journal:decision on plan. "
                f"Call note(scope='decision', task_id='{tid}', "
                "text='<delegation rationale>') with your planning rationale, "
                "then retry i_will_plan."
            ),
            "notes>=min": (
                f"`notes` must be at least {notes_min} chars describing the "
                "merge / escalation rationale; pass a longer notes argument "
                "and retry."
            ),
            "subtasks_terminal": (
                "all subtasks must be in a terminal state (completed or "
                "cancelled) before this transition; wait for the closure "
                "dispatcher to bring you back when ready."
            ),
            "journal:during_work>=1": (
                "no journal:decision / :learning / :struggle entry exists "
                "for this task yet. Pre-gateway parity: developers write "
                "at least one work-progress journal entry before submit. "
                f"Call note(scope='decision'|'learning'|'struggle', "
                f"task_id='{tid}', text='<what you decided or learned>') "
                "with a substantive entry, then retry i_am_done. "
                "NOTE: scope='reflect' does NOT count for this requirement — "
                "reflect is the post-work summary; during_work demands "
                "an entry written while the work was happening."
            ),
            "journal:struggle": (
                f"call note(scope='struggle', task_id='{tid}', "
                "text='<what is blocking you>') with the blocker details, "
                "then retry."
            ),
            "commits>=1": (
                "no commits linked to this task yet. Use commit(message=...) "
                "to record your changes, then retry."
            ),
            "pr_open": (
                "no PR has been opened for this task. Call open_pr() to "
                "push the branch + open the PR, then retry."
            ),
            "self_verified": (
                "task has not been self-verified. i_am_done normally runs "
                "submit_verification automatically; if you see this gap, "
                "retry i_am_done after the previous call returned."
            ),
        }
        return simple_hints.get(missing_key)

    def _tracing_gap_hints(self, missing: list[str], task_id: UUID) -> list[str]:
        """Categorize missing requirement keys into agent-facing hints.

        ``acceptance_criterion:<name>`` and ``finding:<id8>`` are prefix-based
        tokens batched into ONE hint each (not one per item); every other key
        maps through ``_hint_for_missing_key``, falling back to a generic
        hint (defense-in-depth: the agent gets at least the key name even
        when no hint is registered) when unrecognized.
        """
        hints: list[str] = []
        unaddressed: list[str] = []
        open_findings: list[str] = []
        unhinted: list[str] = []
        for m in missing:
            if m.startswith("acceptance_criterion:"):
                unaddressed.append(m.split(":", 1)[1])
                continue
            if m.startswith("finding:"):
                open_findings.append(m.split(":", 1)[1])
                continue
            hint = self._hint_for_missing_key(m, task_id)
            if hint is not None:
                hints.append(hint)
            else:
                unhinted.append(m)
        if unaddressed:
            hints.append(
                hint_for_unaddressed_acceptance_criteria(
                    criteria=unaddressed,
                    task_id=str(task_id),
                )
            )
        if open_findings:
            hints.append(
                hint_for_open_findings(finding_ids=open_findings, task_id=str(task_id))
            )
        for token in unhinted:
            hints.append(
                f"requirement {token!r} not satisfied — see lifecycle docs "
                f"or escalate via i_am_blocked if you do not know how to "
                f"satisfy this."
            )
        return hints

    async def _build_tracing_gap(
        self,
        agent_id: UUID,
        task_id: UUID,
        missing: list[str],
        *,
        task: Any | None = None,
    ) -> Envelope:
        """Translate missing requirement keys into agent-facing hints.

        Multi-missing remediate uses a numbered list so the agent sees each
        requirement as a distinct step instead of a single semicolon-joined
        sentence the model parses as one instruction.
        """
        hints = self._tracing_gap_hints(missing, task_id)
        if len(hints) <= 1:
            remediate = hints[0] if hints else ""
        else:
            numbered = "\n".join(f"{i + 1}. {h}" for i, h in enumerate(hints))
            remediate = (
                f"Multiple requirements missing — address ALL of the "
                f"following before retrying:\n{numbered}"
            )
        return Envelope.tracing_gap(
            missing=missing,
            remediate=remediate,
            context_briefing=await self._briefing_for(agent_id, task_id, task=task),
        )

    async def _notify_qa(self, agent_id: UUID, task_id: UUID, t: Any) -> None:
        """Reassign + A2A-notify the QA agent for this task's team.

        ``submit_qa`` clears ``assigned_to`` to None. We then explicitly
        reassign to the QA agent so the orchestrator's per-agent task
        polling spawns QA (not the dev again) for the next stage.
        """
        qa_agent = await self.task.qa_agent_for_team(t.team)
        if qa_agent is not None:
            await self.task.reassign(task_id, qa_agent.id)
            skill = self._resolve_skill(qa_agent, ["code_review", "qa_review"])
            await self.a2a.send(
                from_agent=agent_id,
                to_agent=qa_agent.id,
                skill=skill,
                task_id=task_id,
                body=f"Ready for review. PR: {t.pr_url}",
            )

    def _resolve_skill(self, target_agent: Any, preference: list[str]) -> str:
        """Pick first skill in preference list that target_agent has.

        Reads from either ``skills`` (gateway view, list of dicts with
        ``id`` keys) or ``capabilities`` (SQLAlchemy AgentTable, list of
        strings). The DB-side AgentTable has no ``skills`` attribute,
        so a naive ``target_agent.skills`` raises AttributeError on
        production agents. Falls back to the first entry
        in ``preference`` when no match is found.
        """
        skills_attr = getattr(target_agent, "skills", None)
        capabilities_attr = getattr(target_agent, "capabilities", None)
        raw = skills_attr if skills_attr is not None else capabilities_attr
        have: set[str] = set()
        for s in raw or []:
            if isinstance(s, dict):
                sid = s.get("id")
                if sid:
                    have.add(sid)
            elif isinstance(s, str):
                have.add(s)
        for skill in preference:
            if skill in have:
                return skill
        return preference[0]

    @staticmethod
    def _build_struggle_body(
        reason: str, blocker_type: str | None, what_needed: str | None
    ) -> str:
        """Render the reason + optional Blocker Type / What Needed sections."""
        # Pre-gateway parity (G8 part b): typed blocker_type / what_needed
        # render as structured markdown blocks instead of a flat sentence.
        if not (blocker_type or what_needed):
            return reason
        parts = [reason.strip()] if reason.strip() else []
        if blocker_type:
            parts.append(f"## Blocker Type\n{blocker_type}")
        if what_needed:
            parts.append(f"## What Needed\n{what_needed}")
        return "\n\n".join(parts)

    async def _run_i_am_blocked_intent(
        self,
        agent_id: UUID,
        task_id: UUID,
        t: Any,
        agent: Any,
        spec_ctx: spec_module.Context,
        role_str: str,
        briefing: dict[str, Any],
    ) -> tuple[Any, Envelope | None]:
        """Dispatch (block,) via the verb runner; return (task, rejection)."""
        runner = self._verb_runner()
        try:
            updated = await runner.run_intent("i_am_blocked", t, agent, spec_ctx)
        except Exception as exc:
            return t, await self._emit_rejection(
                Envelope.invalid_state(
                    message=f"verb runner failed: {exc}",
                    remediate="check workspace + retry; if persistent, escalate",
                    context_briefing=briefing,
                ).with_introspection(task=t, role=role_str),
                agent_id=agent_id,
                task_id=task_id,
                verb="i_am_blocked",
            )
        # ``block`` is the LAST composed action, so a ``None`` return (escalate
        # resolved no target) re-binds ``t`` to ``None``; guard the deref with
        # an invalid_state so the agent gets a retryable rejection, not a 500.
        if updated is None:
            return t, await self._emit_rejection(
                Envelope.invalid_state(
                    message=(
                        "i_am_blocked did not transition the task — no escalation "
                        "target could be resolved for your role (the PM above you "
                        "is missing or unassignable)."
                    ),
                    remediate=(
                        "re-fetch with evidence(task_id); escalate to the CEO "
                        "directly via your PM, or retry once the escalation target "
                        "is staffed."
                    ),
                    context_briefing=briefing,
                ).with_introspection(task=t, role=role_str),
                agent_id=agent_id,
                task_id=task_id,
                verb="i_am_blocked",
            )
        return updated, None

    @staticmethod
    def _parse_retry_after(what_needed: str | None) -> float | None:
        """Extract a retry-after seconds value from ``what_needed``, or None.

        Agents may embed the Retry-After seconds in the ``what_needed``
        field as a numeric string (e.g. ``"30"`` or ``"60.5"``). This
        helper tries to parse it; any non-numeric or absent value returns
        ``None``, which maps to the nullable ``retryAfterSeconds`` in the
        RATE_LIMIT_HIT event.
        """
        if what_needed is None:
            return None
        try:
            return float(what_needed.strip())
        except (ValueError, AttributeError):
            return None

    async def _handle_rate_limited_parking(
        self,
        agent_id: UUID,
        task_id: UUID,
        t: Any,
        agent: Any,
        role_str: str,
        briefing: dict[str, Any],
        what_needed: str | None,
    ) -> Envelope:
        """Rate-limited fast path: park agents, publish event, persist state.

        Called from ``i_am_blocked`` when ``reason == 'rate_limited'``.
        The task stays in its current status (``in_progress``) — no block
        transition occurs. Instead, every orchestrator-tracked active agent
        sharing the same provider as the calling agent is parked via
        ``mark_waiting_long(waiting_for='rate_limit_lifted')``.  A
        ``RATE_LIMIT_HIT`` event is published so downstream consumers (the
        orchestrator backpressure layer, the panel) can react.
        """
        from roboco.models.events import Event, EventType

        agent_slug: str | None = (
            getattr(agent, "slug", None) if agent is not None else None
        )

        provider: str = "unknown"
        affected_agents: list[str] = []

        orch = self.orchestrator
        if orch is not None and agent_slug is not None:
            with contextlib.suppress(Exception):
                prov = orch.get_provider_for_agent(agent_slug)
                if prov:
                    provider = prov
            with contextlib.suppress(Exception):
                affected_agents = list(
                    orch.get_active_agent_slugs_for_provider(provider)
                )
            for slug in affected_agents:
                with contextlib.suppress(Exception):
                    await orch.mark_waiting_long(
                        slug,
                        waiting_for="rate_limit_lifted",
                        task_id=str(task_id),
                        context={"provider": provider, "triggered_by": agent_slug},
                    )

        retry_after_seconds = self._parse_retry_after(what_needed)

        # Persist rate-limit state to Redis so downstream decide_spawn()
        # calls can gate new spawns for this provider.  Skipped when the
        # provider is "unknown" (orchestrator not wired or not tracking the
        # agent) to avoid polluting the tracker with meaningless keys.
        #
        # An activate() failure is logged loudly, NOT bare-suppressed: the
        # probe-resume loop is tracker-driven, so a silent failure strands
        # every parked agent waiting on a provider the tracker never learned.
        if provider != "unknown":
            try:
                from roboco.services.gateway.rate_limit_tracker import (
                    RateLimitStateTracker,
                )

                await RateLimitStateTracker(provider).activate(
                    retry_after=retry_after_seconds,
                    affected_agents=affected_agents,
                )
            except Exception as exc:
                logger.error(
                    "rate_limit_tracker.activate failed; parked agents rely on"
                    " the in-memory probe fallback to resume",
                    provider=provider,
                    affected_agents=affected_agents,
                    error=str(exc),
                )

        bus = self.stream_bus
        if bus is not None:
            event = Event(
                type=EventType.RATE_LIMIT_HIT,
                data={
                    "provider": provider,
                    "affectedAgents": affected_agents,
                    "retryAfterSeconds": retry_after_seconds,
                    "timestamp": datetime.now(UTC).isoformat(),
                },
                source_agent=str(agent_id),
            )
            with contextlib.suppress(Exception):
                await bus.publish(event)

        await self._touch(task_id)
        return Envelope.ok(
            status=str(t.status),
            task_id=str(task_id),
            next=(
                "agent parked waiting for rate_limit_lifted; "
                "will be respawned when the limit clears"
            ),
            context_briefing=briefing,
        ).with_introspection(task=t, role=role_str)

    async def i_am_blocked(
        self,
        agent_id: UUID,
        task_id: UUID,
        reason: str,
        blocker_type: str | None = None,
        what_needed: str | None = None,
    ) -> Envelope:
        """Escalate task_id and write a struggle journal entry; idle the agent.

        Atomic: ``spec.can_invoke_intent`` runs first and enforces role
        membership (developer/qa/documenter) and the source-status
        constraint of the composed ``block`` action (in_progress only).
        After the spec gate accepts, the journal:struggle entry is written
        from the verb body, then either:

        - ``reason == 'rate_limited'``: the task is **not** transitioned to
          ``blocked``; instead every active agent on the same provider is
          parked via ``mark_waiting_long(waiting_for='rate_limit_lifted')``
          and a ``RATE_LIMIT_HIT`` event is published.
        - any other reason: ``VerbRunner.run_intent("i_am_blocked", ...)``
          dispatches the ``(block,)`` atomic chain wrapped in a savepoint,
          transitioning the task to ``blocked``.
        """
        t = await self.task.get(task_id)
        if t is None:
            return await self._emit_rejection(
                Envelope.not_found(message=f"task {task_id} not found"),
                agent_id=agent_id,
                task_id=task_id,
                verb="i_am_blocked",
            )
        agent = await self.task.agent_for(agent_id)
        role_str = str(agent.role) if agent is not None else "developer"
        briefing = await self._briefing_for(agent_id, task_id, task=t)
        try:
            role = spec_module.Role(role_str)
        except ValueError:
            return await self._emit_rejection(
                Envelope.not_authorized(
                    message=f"unknown role '{role_str}'",
                    remediate="role is not declared in the lifecycle spec",
                    context_briefing=briefing,
                ).with_introspection(task=t, role=role_str),
                agent_id=agent_id,
                task_id=task_id,
                verb="i_am_blocked",
            )
        spec_ctx = spec_module.Context(
            actor_id=agent_id,
            actor_slug=getattr(agent, "slug", None) if agent is not None else None,
            agent_team=str(agent.team) if agent is not None and agent.team else None,
            original_developer_slug=_extract_original_developer(t),
            notes=reason,
        )
        soup = self._free_text_soup(checks=(("reason", reason, 8),))
        decision = spec_module.can_invoke_intent(role, "i_am_blocked", t, spec_ctx)
        if env := self._soup_or_decision_env(soup, decision, briefing):
            return await self._emit_rejection(
                env.with_introspection(task=t, role=role_str),
                agent_id=agent_id,
                task_id=task_id,
                verb="i_am_blocked",
            )
        # Journal:struggle is a side effect outside the lifecycle action; it
        # stays in the verb body, written before the runner dispatches `block`
        # so a later runner failure still leaves an audit trail.
        await self.journal.write_struggle(
            agent_id=agent_id,
            task_id=task_id,
            content=self._build_struggle_body(reason, blocker_type, what_needed),
        )

        # Rate-limited fast path: skip the block state transition and park
        # all affected agents instead.
        if reason.strip().lower() == "rate_limited":
            return await self._handle_rate_limited_parking(
                agent_id=agent_id,
                task_id=task_id,
                t=t,
                agent=agent,
                role_str=role_str,
                briefing=briefing,
                what_needed=what_needed,
            )

        t, rejection = await self._run_i_am_blocked_intent(
            agent_id, task_id, t, agent, spec_ctx, role_str, briefing
        )
        if rejection is not None:
            return rejection
        await self._touch(task_id)
        return Envelope.ok(
            status=str(t.status),
            task_id=str(task_id),
            next=spec_module._INTENT_VERBS["i_am_blocked"].next_hint(t),
            context_briefing=briefing,
        ).with_introspection(task=t, role=role_str)

    async def unclaim(self, agent_id: UUID, task_id: UUID) -> Envelope:
        """Voluntarily release a claimed/in_progress task back to pending.

        Audit J33 — ``_pending_assignment_guard`` already remediates with
        "or unclaim it first," but the verb didn't exist. This makes that
        promise true. The work-in-progress branch survives; only the claim
        is released so another agent (or the same one, fresh) can pick it
        up.

        Spec gate runs first (role membership only — unclaim's IntentSpec
        has ``composes=()``, so the spec does not enforce a source-status
        constraint). After the gate accepts, the reassignment-rejection
        branch (introduced in commit a5d358d) catches "task was reassigned
        out from under you by an upstream verb" — the spec doesn't model
        that case. Then the verb body owns dispatch via
        ``task.unclaim_for_agent`` because ``composes=()`` (no atomic action
        for the runner to run); the service-level None return surfaces as
        invalid_state when the status drifted between get and write.
        """
        t = await self.task.get(task_id)
        briefing = await self._briefing_for(agent_id, task_id, task=t)
        if t is None:
            return await self._emit_rejection(
                Envelope.not_found(message=f"task {task_id} not found"),
                agent_id=agent_id,
                task_id=task_id,
                verb="unclaim",
            )
        agent = await self.task.agent_for(agent_id)
        role_str = str(agent.role) if agent is not None else "developer"
        try:
            role = spec_module.Role(role_str)
        except ValueError:
            return await self._emit_rejection(
                Envelope.not_authorized(
                    message=f"unknown role '{role_str}'",
                    remediate="role is not declared in the lifecycle spec",
                    context_briefing=briefing,
                ).with_introspection(task=t, role=role_str),
                agent_id=agent_id,
                task_id=task_id,
                verb="unclaim",
            )
        actor_slug, agent_team = actor_context_fields(agent)
        spec_ctx = spec_module.Context(
            actor_id=agent_id,
            actor_slug=actor_slug,
            agent_team=agent_team,
            original_developer_slug=_extract_original_developer(t),
        )
        decision = spec_module.can_invoke_intent(role, "unclaim", t, spec_ctx)
        if not decision.allowed:
            return await self._emit_rejection(
                Envelope.from_decision(decision, briefing=briefing).with_introspection(
                    task=t, role=role_str
                ),
                agent_id=agent_id,
                task_id=task_id,
                verb="unclaim",
            )
        reassigned = self._reassigned_rejection(
            _ReassignedCtx(
                task=t,
                agent_id=agent_id,
                task_id=task_id,
                role_str=role_str,
                briefing=briefing,
                upstream_hint=(
                    "the task was reassigned by an upstream verb "
                    "(cell_pm_complete / main_pm_complete / unblock). "
                    "call give_me_work() to find your current work."
                ),
            )
        )
        if reassigned is not None:
            return await self._emit_rejection(
                reassigned, agent_id=agent_id, task_id=task_id, verb="unclaim"
            )
        # Verb body owns dispatch — unclaim's IntentSpec has composes=(),
        # so VerbRunner has no atomic action to run.
        after = await self.task.unclaim_for_agent(task_id, agent_id)
        if after is None:
            return await self._emit_rejection(
                Envelope.invalid_state(
                    message=f"cannot unclaim from status {t.status}",
                    remediate=(
                        "docs already complete? call i_documented(files=[...], "
                        "notes='verified existing docs') — that is the exit "
                        "from awaiting_documentation"
                        if str(t.status) == "awaiting_documentation"
                        else "only a task assigned to you in pending / claimed"
                        " / in_progress / verifying / needs_revision can be"
                        " unclaimed"
                    ),
                    context_briefing=briefing,
                ).with_introspection(task=t, role=role_str),
                agent_id=agent_id,
                task_id=task_id,
                verb="unclaim",
            )
        # Deliberately no _touch — unclaim clears assigned_to, so there is
        # no claimant heartbeat to refresh. (Asymmetric with `resume` by
        # design: resume keeps the same claimant active and does heartbeat.)
        await self._teardown_sandbox_best_effort(agent_id)
        return Envelope.ok(
            status=str(after.status),
            task_id=str(task_id),
            next=spec_module._INTENT_VERBS["unclaim"].next_hint(after),
            context_briefing=briefing,
        ).with_introspection(task=after, role=role_str)

    @staticmethod
    def _validate_reassign(
        t: Any, agent_id: UUID, new_assignee: str
    ) -> Envelope | None:
        """Intra-cell guard for ``reassign`` (verb body owns it — composes=()).

        The task must be claimed/in_progress and in the caller's own cell, and
        ``new_assignee`` must be a developer of that same cell. Returns a
        rejection envelope, or None when the hand-off is allowed.
        """
        from roboco.agents_config import get_agent_role, get_agent_team
        from roboco.seeds.initial_data import AGENT_UUIDS

        caller_team = get_agent_team(str(agent_id))
        task_team = getattr(t.team, "value", t.team)
        status = str(getattr(t.status, "value", t.status))
        if status not in ("claimed", "in_progress"):
            return Envelope.invalid_state(
                message=f"cannot reassign a task in status {status!r}",
                remediate=(
                    "reassign only a claimed or in_progress task; review/terminal"
                    " states are owned by their lifecycle role"
                ),
                context_briefing={},
            )
        if task_team is None or task_team != caller_team:
            return Envelope.not_authorized(
                message=f"task team {task_team!r} is not your cell ({caller_team!r})",
                remediate="you can only reassign tasks inside your own cell",
                context_briefing={},
            )
        if new_assignee not in AGENT_UUIDS:
            return Envelope.invalid_state(
                message=f"unknown agent slug {new_assignee!r}",
                remediate=(
                    "new_assignee must be a developer slug in your cell, e.g. be-dev-2"
                ),
                context_briefing={},
            )
        if get_agent_role(new_assignee) != "developer":
            return Envelope.not_authorized(
                message=f"{new_assignee!r} is not a developer",
                remediate=(
                    "reassign hands work to a developer in your cell;"
                    " only dev slugs are valid"
                ),
                context_briefing={},
            )
        if get_agent_team(new_assignee) != caller_team:
            return Envelope.not_authorized(
                message=f"{new_assignee!r} is not in your cell ({caller_team!r})",
                remediate="reassign only to a developer in your own cell",
                context_briefing={},
            )
        return None

    async def reassign(
        self, agent_id: UUID, task_id: UUID, new_assignee: str
    ) -> Envelope:
        """A cell PM hands a claimed/in_progress task to another dev in its cell.

        Intra-cell only (see ``_validate_reassign``). The branch is keyed to the
        task, not the agent, so it survives — the new developer continues the
        work-in-progress and is respawned by the orchestrator.
        """
        from roboco.seeds.initial_data import AGENT_UUIDS

        t = await self.task.get(task_id)
        briefing = await self._briefing_for(agent_id, task_id, task=t)
        if t is None:
            return await self._emit_rejection(
                Envelope.not_found(message=f"task {task_id} not found"),
                agent_id=agent_id,
                task_id=task_id,
                verb="reassign",
            )
        agent = await self.task.agent_for(agent_id)
        role_str = str(agent.role) if agent is not None else "cell_pm"
        try:
            role = spec_module.Role(role_str)
        except ValueError:
            return await self._emit_rejection(
                Envelope.not_authorized(
                    message=f"unknown role '{role_str}'",
                    remediate="role is not declared in the lifecycle spec",
                    context_briefing=briefing,
                ).with_introspection(task=t, role=role_str),
                agent_id=agent_id,
                task_id=task_id,
                verb="reassign",
            )
        spec_ctx = spec_module.Context(
            actor_id=agent_id,
            actor_slug=getattr(agent, "slug", None) if agent is not None else None,
            agent_team=str(agent.team) if agent is not None and agent.team else None,
            original_developer_slug=_extract_original_developer(t),
        )
        decision = spec_module.can_invoke_intent(role, "reassign", t, spec_ctx)
        if not decision.allowed:
            return await self._emit_rejection(
                Envelope.from_decision(decision, briefing=briefing).with_introspection(
                    task=t, role=role_str
                ),
                agent_id=agent_id,
                task_id=task_id,
                verb="reassign",
            )
        guard = self._validate_reassign(t, agent_id, new_assignee)
        if guard is not None:
            return await self._emit_rejection(
                guard.with_introspection(task=t, role=role_str),
                agent_id=agent_id,
                task_id=task_id,
                verb="reassign",
            )
        after = await self.task.reassign_active_claim(
            task_id, UUID(AGENT_UUIDS[new_assignee])
        )
        if after is None:
            return await self._emit_rejection(
                Envelope.invalid_state(
                    message=f"cannot reassign from status {t.status}",
                    remediate="only a claimed / in_progress task can be reassigned",
                    context_briefing=briefing,
                ).with_introspection(task=t, role=role_str),
                agent_id=agent_id,
                task_id=task_id,
                verb="reassign",
            )
        return Envelope.ok(
            status=str(after.status),
            task_id=str(task_id),
            next=spec_module._INTENT_VERBS["reassign"].next_hint(after),
            context_briefing=briefing,
        ).with_introspection(task=after, role=role_str)

    async def _declare_coverage_guard(
        self, pm_agent_id: UUID, child: Any, agent: Any, briefing: dict[str, Any]
    ) -> tuple[Envelope | None, Any]:
        """Role + parent-presence + ownership guard for ``declare_coverage``.

        Returns ``(rejection, parent)`` — ``parent`` is non-``None`` only
        when ``rejection`` is ``None``. Ownership mirrors submit_up (the PM
        assigned to the parent coordination task), with a fallback for any
        PM on the child's own team — the minimum bar the spec asks for.

        Root-owned self-declare: when the caller is the assignee of
        ``child`` itself, ``task_id`` names the PM's OWN root/coordination
        task, not a child — criteria only the root's own machinery can
        satisfy (PR-supersede, closing a contributor PR, a root-level
        merge) that must never be pushed into a cell's acceptance criteria.
        ``parent`` is then ``child`` itself (caller-identity is the
        signal — callers never target their own claimed task in the
        ordinary child-declare flow, so this cannot misfire on it).
        """
        if agent is None or agent.role not in ("cell_pm", "main_pm"):
            return (
                Envelope.not_authorized(
                    message="declare_coverage is reserved for PM roles",
                    remediate="only a cell PM or main PM may declare coverage",
                    context_briefing=briefing,
                ),
                None,
            )
        if child.assigned_to == pm_agent_id:
            return None, child
        if not child.parent_task_id:
            return (
                Envelope.invalid_state(
                    message="task has no parent; nothing to cover",
                    remediate=(
                        "declare_coverage only applies to a decomposition"
                        " child (a subtask with a parent coordination task)"
                    ),
                    context_briefing=briefing,
                ),
                None,
            )
        parent = await self.task.get(child.parent_task_id)
        if parent is None:
            return (
                Envelope.invalid_state(
                    message="parent task not found",
                    remediate="the parent task may have been deleted; escalate",
                    context_briefing=briefing,
                ),
                None,
            )
        child_team = getattr(child.team, "value", child.team)
        agent_team = getattr(agent, "team", None)
        agent_team_val = getattr(agent_team, "value", agent_team)
        owns_parent = parent.assigned_to == pm_agent_id
        on_child_team = child_team is not None and child_team == agent_team_val
        if not owns_parent and not on_child_team:
            return (
                Envelope.not_authorized(
                    message="not the parent's PM and not on the child's team",
                    remediate=(
                        "declare_coverage requires owning the parent"
                        " coordination task or being a PM on the child's team"
                    ),
                    context_briefing=briefing,
                ),
                None,
            )
        return None, parent

    async def declare_coverage(
        self, pm_agent_id: UUID, task_id: UUID, criteria: list[str]
    ) -> Envelope:
        """PM stamps parent acceptance criteria onto an existing child.

        Fixes the roll-up-gate deadlock where a replacement child —
        delegated without ``covers_parent_criteria`` — finishes a parent
        AC's real work but ``_parent_acs_covered_envelope`` still shows it
        uncovered, since only ``delegate`` writes ``parent_ac_refs`` today.
        ``criteria`` takes the same id-or-text representation
        ``covers_parent_criteria`` does (``TaskService._normalize_ac_refs``
        resolves it at read time), so a PM can copy straight out of the
        gate's own uncovered-criteria listing. ``task_id`` is the CHILD
        (any non-cancelled status — the live case is a completed child).

        Root-owned mode: ``task_id`` may instead be the PM's OWN root, for
        criteria only the root itself satisfies (never delegable to a
        cell — see ``_declare_coverage_guard``). The refs land in that
        task's own ``parent_ac_refs`` and are read back against its own
        criteria, satisfied unconditionally (no child/status to wait on).
        """
        child = await self.task.get(task_id)
        briefing = await self._briefing_for(pm_agent_id, task_id, task=child)
        if child is None:
            return await self._emit_rejection(
                Envelope.not_found(message=f"task {task_id} not found"),
                agent_id=pm_agent_id,
                task_id=task_id,
                verb="declare_coverage",
            )
        agent = await self.task.agent_for(pm_agent_id)
        role_str = str(agent.role) if agent is not None else "cell_pm"
        rejection, parent = await self._declare_coverage_guard(
            pm_agent_id, child, agent, briefing
        )
        if rejection is not None:
            return await self._emit_rejection(
                rejection.with_introspection(task=child, role=role_str),
                agent_id=pm_agent_id,
                task_id=task_id,
                verb="declare_coverage",
            )
        unknown = self.task.unknown_ac_refs(parent, criteria)
        if unknown:
            return await self._emit_rejection(
                Envelope.invalid_state(
                    message=f"unknown criteria: {'; '.join(unknown)}",
                    remediate=(
                        "criteria must match a parent acceptance criterion"
                        " (id or exact text): "
                        f"{'; '.join(parent.acceptance_criteria or [])}"
                    ),
                    context_briefing=briefing,
                ).with_introspection(task=child, role=role_str),
                agent_id=pm_agent_id,
                task_id=task_id,
                verb="declare_coverage",
            )
        updated = await self.task.add_parent_ac_refs(
            task_id, criteria, declared_by=pm_agent_id
        )
        # Self-declare targets its own uncovered-set (parent is child by
        # identity, see the guard); child-declare targets the real parent.
        uncovered_target = task_id if parent is child else child.parent_task_id
        uncovered = await self.task.uncovered_parent_acceptance_criteria(
            uncovered_target
        )
        next_hint = (
            "the roll-up gate will pass now"
            if not uncovered
            else f"{len(uncovered)} parent ACs still uncovered: {'; '.join(uncovered)}"
        )
        return Envelope.ok(
            status=str((updated or child).status),
            task_id=str(task_id),
            next=next_hint,
            evidence={"remaining_uncovered_parent_acs": uncovered},
            context_briefing=briefing,
        ).with_introspection(task=updated or child, role=role_str)

    async def resume(self, agent_id: UUID, task_id: UUID) -> Envelope:
        """Resume a paused task this agent owns; transitions paused → in_progress.

        Audit J33 — ``i_am_idle`` auto-pauses owned in_progress tasks (so
        the closure dispatcher can wake the agent when subtasks finish),
        and the lifecycle table allows ``paused → in_progress``, but no
        verb exposed that transition to agents. ``i_will_work_on`` is
        explicitly limited to needs_revision/pending/claimed; overloading
        it would muddy state-machine intent. ``resume`` keeps it explicit.

        Spec gate runs first and enforces role membership plus the
        composed ``resume`` action's source-status constraint (PAUSED
        only). After the gate accepts, the reassignment-rejection branch
        catches "task was reassigned by an upstream verb" — the spec
        doesn't model that case, so the existing envelope text (preserved
        from commit a5d358d) is the load-bearing hint. Then
        ``VerbRunner.run_intent("resume", ...)`` dispatches the (resume,)
        atomic chain wrapped in a savepoint.
        """
        t = await self.task.get(task_id)
        briefing = await self._briefing_for(agent_id, task_id, task=t, full=True)
        if t is None:
            return await self._emit_rejection(
                Envelope.not_found(message=f"task {task_id} not found"),
                agent_id=agent_id,
                task_id=task_id,
                verb="resume",
            )
        agent = await self.task.agent_for(agent_id)
        role_str = str(agent.role) if agent is not None else "developer"
        try:
            role = spec_module.Role(role_str)
        except ValueError:
            return await self._emit_rejection(
                Envelope.not_authorized(
                    message=f"unknown role '{role_str}'",
                    remediate="role is not declared in the lifecycle spec",
                    context_briefing=briefing,
                ).with_introspection(task=t, role=role_str),
                agent_id=agent_id,
                task_id=task_id,
                verb="resume",
            )
        actor_slug, agent_team = actor_context_fields(agent)
        spec_ctx = spec_module.Context(
            actor_id=agent_id,
            actor_slug=actor_slug,
            agent_team=agent_team,
            original_developer_slug=_extract_original_developer(t),
        )
        decision = spec_module.can_invoke_intent(role, "resume", t, spec_ctx)
        if not decision.allowed:
            return await self._emit_rejection(
                Envelope.from_decision(decision, briefing=briefing).with_introspection(
                    task=t, role=role_str
                ),
                agent_id=agent_id,
                task_id=task_id,
                verb="resume",
            )
        reassigned = self._reassigned_rejection(
            _ReassignedCtx(
                task=t,
                agent_id=agent_id,
                task_id=task_id,
                role_str=role_str,
                briefing=briefing,
                upstream_hint=(
                    "the task was reassigned by an upstream verb. "
                    "call give_me_work() to find your current work."
                ),
            )
        )
        if reassigned is not None:
            return await self._emit_rejection(
                reassigned, agent_id=agent_id, task_id=task_id, verb="resume"
            )
        runner = self._verb_runner()
        runner_failure_msg: str | None = None
        try:
            after = await runner.run_intent("resume", t, agent, spec_ctx)
        except Exception as exc:
            after = None
            runner_failure_msg = f"verb runner failed: {exc}"
        if after is None:
            # Either the runner raised (recorded above) or the service
            # returned None despite the spec gate accepting (race: status
            # drifted between get and write). Both surface as invalid_state.
            return await self._emit_rejection(
                Envelope.invalid_state(
                    message=runner_failure_msg
                    or f"cannot resume from status {t.status} (drift)",
                    remediate="only paused tasks can be resumed",
                    context_briefing=briefing,
                ).with_introspection(task=t, role=role_str),
                agent_id=agent_id,
                task_id=task_id,
                verb="resume",
            )
        # Heartbeat — agent is back to active work after the resume.
        await self._touch(task_id)
        return Envelope.ok(
            status=str(after.status),
            task_id=str(task_id),
            next=spec_module._INTENT_VERBS["resume"].next_hint(after),
            context_briefing=briefing,
        ).with_introspection(task=after, role=role_str)

    async def sync_branch(
        self, agent_id: UUID, task_id: UUID, stash: bool = False
    ) -> Envelope:
        """Rebase the caller's task branch onto its current base THROUGH the gate.

        Raw shell git is denied to agents (the ``Bash(git:*)`` base deny), so a
        developer whose branch fell behind its base — a sibling's PR merged
        into the parent branch while they worked — had no gate-level rebase,
        only the CEO/PM-only ``/rebase`` HTTP route. ``sync_branch`` is that
        gate verb: it resolves the task's base via
        ``merge_chain.resolve_parent_branch``, guards against rebasing into a
        protected branch, then rebases + force-pushes (with-lease) via
        ``GitService.sync_task_branch``. Git-only (no DB state change), like
        ``open_pr``; the spec gate checks role + ownership, then the handler
        guards branch + base, then runs the git op. Conflicts abort the rebase
        (no force-push) and return the conflicted files — resolve by hand,
        commit, then sync_branch again.

        ``stash=True`` auto-stashes uncommitted changes (tracked + untracked)
        before rebasing and pops them back after, instead of refusing
        DIRTY_WORKSPACE — the dev-facing dead end where the prescribed fix
        (stage/commit) required a raw ``git`` agents are denied.
        """
        t = await self.task.get(task_id)
        briefing = await self._briefing_for(agent_id, task_id, task=t)
        agent = await self.task.agent_for(agent_id)
        role_str = str(agent.role) if agent is not None else "developer"
        # Preflight: not_found / unknown-role / spec-gate / no-branch /
        # protected-base. Returns the rejection (None when all clear) AND the
        # resolved base_branch the git op needs (empty when rejected).
        rejection, base_branch = await self._sync_branch_preflight_rejection(
            agent_id, task_id, t, agent, role_str, briefing
        )
        if rejection is not None:
            return await self._emit_rejection(
                rejection, agent_id=agent_id, task_id=task_id, verb="sync_branch"
            )
        try:
            result = await self.git.sync_task_branch(
                t, base_branch=base_branch, actor_agent_id=agent_id, stash=stash
            )
        except Exception as exc:
            dirty = "DIRTY_WORKSPACE" in str(exc)
            remediate = (
                "your workspace has uncommitted changes; call"
                " sync_branch(stash=True) to auto-stash, rebase, and restore"
                " them, or commit(...) (omit files to stage everything) then"
                " sync_branch() again"
                if dirty
                else "the git rebase could not complete; escalate via"
                " i_am_blocked(reason='...') with the error"
            )
            return await self._emit_rejection(
                Envelope.invalid_state(
                    message=f"sync_branch failed: {exc}",
                    remediate=remediate,
                    context_briefing=briefing,
                ).with_introspection(task=t, role=role_str),
                agent_id=agent_id,
                task_id=task_id,
                verb="sync_branch",
            )
        # Heartbeat — the agent is actively working the task.
        await self._touch(task_id)
        evidence = {
            "rebase": result,
            "base_branch": base_branch,
            "head_branch": str(t.branch_name),
        }
        return Envelope.ok(
            status=str(t.status),
            task_id=str(task_id),
            next=self._sync_branch_next_hint(t, result),
            evidence=evidence,
            context_briefing=briefing,
        ).with_introspection(task=t, role=role_str)

    @staticmethod
    def _sync_branch_next_hint(t: Any, result: dict[str, Any]) -> str:
        """Compute the ``next`` hint for a completed ``sync_branch`` run.

        Three shapes: a rebase conflict (files listed, stash noted if one was
        taken), a clean rebase whose stash pop then conflicted (stash
        preserved, never dropped), or the plain spec default.
        """
        status = str(result.get("status", "unknown"))
        if status == "conflicts":
            hint = (
                f"sync_branch hit conflicts on {result.get('files', [])};"
                " resolve by hand, commit(message='...'), then sync_branch again"
            )
            if result.get("stash_preserved"):
                hint += (
                    " — your stashed changes were left untouched (git stash"
                    " list); pop them by hand once the conflict is resolved"
                )
            return hint
        if result.get("stash_pop_conflict"):
            return (
                "sync_branch rebased cleanly but restoring your stashed "
                "changes conflicted; the stash is preserved (not dropped) — "
                "resolve the conflict by hand, then commit(...) and continue"
            )
        return spec_module._INTENT_VERBS["sync_branch"].next_hint(t)

    async def _sync_branch_preflight_rejection(
        self,
        agent_id: UUID,
        task_id: UUID,
        t: Any,
        agent: Any,
        role_str: str,
        briefing: dict[str, Any],
    ) -> tuple[Envelope | None, str]:
        """Role + spec-gate + branch/base guards for ``sync_branch``.

        Returns ``(rejection_envelope, base_branch)``. When all guards pass the
        rejection is ``None`` and ``base_branch`` is the resolved merge target
        the handler hands to ``GitService.sync_task_branch``; on any guard
        failure the envelope is set and ``base_branch`` is empty. Extracted so
        ``sync_branch`` stays under the PLR0911 return budget (mirrors
        ``_open_pr_preflight_rejection``).
        """
        if t is None:
            return (
                Envelope.not_found(message=f"task {task_id} not found"),
                "",
            )
        try:
            role = spec_module.Role(role_str)
        except ValueError:
            return (
                Envelope.not_authorized(
                    message=f"unknown role '{role_str}'",
                    remediate="role is not declared in the lifecycle spec",
                    context_briefing=briefing,
                ).with_introspection(task=t, role=role_str),
                "",
            )
        spec_ctx = spec_module.Context(
            actor_id=agent_id,
            actor_slug=getattr(agent, "slug", None) if agent is not None else None,
            agent_team=str(agent.team) if agent is not None and agent.team else None,
        )
        decision = spec_module.can_invoke_intent(role, "sync_branch", t, spec_ctx)
        if not decision.allowed:
            return (
                Envelope.from_decision(decision, briefing=briefing).with_introspection(
                    task=t, role=role_str
                ),
                "",
            )
        # The task must carry a branch (claimed/in_progress) — there is nothing
        # to sync before the branch is cut, and branchless coordination roots
        # carry none.
        if not t.branch_name:
            return (
                Envelope.invalid_state(
                    message=f"task {task_id} has no branch_name to sync",
                    remediate="call i_will_work_on(task_id) first to cut a branch",
                    context_briefing=briefing,
                ).with_introspection(task=t, role=role_str),
                "",
            )
        base_branch = await resolve_parent_branch(t, self.task)
        if await self._sync_base_refused(base_branch, t):
            return (
                Envelope.invalid_state(
                    message=f"resolved base branch '{base_branch}' is protected",
                    remediate=(
                        "the task's base resolved to master/main even though it"
                        " has a parent that should own the base branch — the"
                        " hierarchy resolution looks wrong; escalate via"
                        " i_am_blocked(reason='...') instead of rebasing"
                    ),
                    context_briefing=briefing,
                ).with_introspection(task=t, role=role_str),
                "",
            )
        return None, base_branch

    async def _sync_base_refused(self, base_branch: str, t: Any) -> bool:
        """Whether ``sync_branch`` must refuse rebasing onto ``base_branch``.

        ``-``-prefixed refs are refused unconditionally (argument-injection
        guard). master/main is refused only when it cannot be the task's real
        merge target — mirroring ``resolve_parent_branch``: a parentless task
        and a child of a branchless coordination parent both legitimately
        merge into the project default branch, and the rebase only ever
        force-pushes the task branch (with lease), never the base. A
        branch-bearing parent (the base should have been that branch) or an
        unresolvable parent row means the resolution went wrong — refuse.
        """
        if base_branch.startswith("-"):
            return True
        if base_branch not in ("master", "main"):
            return False
        parent_id = getattr(t, "parent_task_id", None)
        if parent_id is None:
            return False
        try:
            pid = UUID(str(parent_id))
        except ValueError:
            return True
        parent = await self.task.get(pid)
        return parent is None or bool(parent.branch_name)

    async def i_am_idle(self, agent_id: UUID) -> Envelope:
        """Report no more work. Soft-block if there are unread A2As or @mentions.

        Before marking the agent idle:

        1. Bail with ``idle_with_unread`` when context_briefing has unread A2A
           or @mentions (must address those first).
        2. Refuse with INVALID_STATE if the agent has any pending tasks
           assigned but never claimed — they must call i_will_work_on (dev/qa/
           doc) or i_will_plan (pm) first. Board/advisory roles (product_owner,
           head_marketing, auditor) are exempt: they review without claiming.
           (Gate Set C, pre-gateway implicit via the orchestrator's
           auto-respawn.)
        3. Auto-pause every in_progress task this agent owns so the
           orchestrator's PM-closure dispatcher can wake them when subtasks
           finish, instead of leaving the parent stuck at ``in_progress``
           forever.
        """
        briefing = await self._briefing_for(agent_id, None)
        if briefing.get("unread_a2a") or briefing.get("unread_mentions"):
            return Envelope.ok(
                status="idle_with_unread",
                task_id=None,
                next=(
                    "clear your inbox, then retry i_am_idle(): read_messages()"
                    " for unread A2A, notify_ack() per @mention notification"
                ),
                context_briefing=briefing,
            )
        # Pre-idle guards, evaluated in order — the first that returns an
        # Envelope short-circuits to a rejection (kept as a loop so adding a
        # guard doesn't push this verb over the return-count bound).
        idle_guards = (
            self._pending_assignment_guard,
            self._pm_unfinished_review_guard,
            self._pm_uncovered_decomposition_guard,
            self._pm_uncovered_required_cells_guard,
            self._auditor_note_guard,
        )
        for guard_fn in idle_guards:
            if guard := await guard_fn(agent_id, briefing):
                return await self._emit_rejection(
                    guard, agent_id=agent_id, task_id=None, verb="i_am_idle"
                )
        paused_ids = await self._auto_pause_in_progress_tasks(agent_id)
        await self.task.mark_agent_idle(agent_id)
        if paused_ids:
            # Tell the agent how to come back to these tasks. Without this,
            # an agent respawned for a paused task has no signal that
            # `resume(task_id)` is the way back in.
            joined = ", ".join(f"resume(task_id='{tid}')" for tid in paused_ids)
            next_msg = (
                "container will shut down; on respawn, "
                f"call {joined} to continue paused work"
            )
        else:
            next_msg = "container will shut down"
        # The agent truly disengages here (container shuts down) — unlike
        # the idle_with_unread early return above, which sends it right
        # back to work, so no teardown fires there.
        await self._teardown_sandbox_best_effort(agent_id)
        return Envelope.ok(
            status="idle",
            task_id=None,
            next=next_msg,
            context_briefing=briefing,
        )

    async def _pending_assignment_guard(
        self, agent_id: UUID, briefing: dict[str, Any]
    ) -> Envelope | None:
        """Refuse i_am_idle when caller owns any pending (unclaimed) task.

        Pre-gateway: the orchestrator would respawn the agent after
        i_am_idle if they still owned pending work, leading to a tight
        respawn loop. Now an explicit refusal lets the agent fix it via
        i_will_work_on or i_will_plan before exiting.
        """
        assigned = await self.task.list_assigned_for_agent(agent_id)
        pending = [t for t in assigned if str(t.status) == "pending"]
        if not pending:
            return None
        agent = await self.task.agent_for(agent_id)
        pending = await self._pending_blocking_idle(agent, pending)
        if not pending:
            return None
        first = pending[0]
        verb = (
            "i_will_plan"
            if agent and agent.role in ("cell_pm", "main_pm")
            else ("i_will_work_on")
        )
        return Envelope.invalid_state(
            message=(
                f"You have task {first.id} assigned but never claimed; "
                "cannot idle until claimed or unclaimed."
            ),
            remediate=(
                f"call {verb}(task_id='{first.id}') to start work, or"
                " unclaim it first; then retry i_am_idle"
            ),
            context_briefing=briefing,
        )

    async def _pending_blocking_idle(self, agent: Any, pending: list[Any]) -> list[Any]:
        """Pending tasks that should block i_am_idle, after role exemptions.

        Board/advisory roles (product_owner, head_marketing, auditor) review and
        advise without ever claiming — they have no i_will_work_on / i_will_plan
        verb, and their one-shot dispatch is meant to leave the coordination task
        pending for the CEO to reassign. They must idle freely, so nothing blocks
        them. Developers own a whole per-dev code queue up front, so a leaf still
        waiting behind an earlier non-terminal sibling in its own lane must not
        pin them — the orchestrator spawns it when the lane clears (see
        ``_pending_not_lane_held``). Other roles: every pending task blocks.
        """
        if not agent:
            return pending
        if agent.role in ("product_owner", "head_marketing", "auditor"):
            return []
        if agent.role == "developer":
            return await self._pending_not_lane_held(pending)
        return pending

    async def _pending_not_lane_held(self, pending: list[Any]) -> list[Any]:
        """Drop a dev's pending code leaves that are waiting behind an earlier
        non-terminal sibling in the same dev's lane (per-dev sequenced queues).

        Those leaves are spawned by the orchestrator when the lane clears, so
        they must not pin the dev to idle. ``is not True`` keeps this inert under
        partial test mocks (an AsyncMock returns a truthy stub, not a real bool)
        — only a leaf the service positively confirms is lane-held is dropped.
        """
        live: list[Any] = []
        for t in pending:
            if await self.task.has_earlier_incomplete_code_sibling(t) is not True:
                live.append(t)
        return live

    async def _pm_unfinished_review_guard(
        self, agent_id: UUID, briefing: dict[str, Any]
    ) -> Envelope | None:
        """Refuse i_am_idle when a PM still owns a task awaiting its own review.

        A cell/main PM once tried to "send work back" by DMing the developer and
        going idle — but a DM changes no task state, so the task stayed
        awaiting_pm_review and the PM was just re-dispatched in a loop. A PM that
        owns an awaiting_pm_review task must act on it (complete to finish, or
        reassign/delegate to route it back) before it can idle.
        """
        agent = await self.task.agent_for(agent_id)
        if not agent or agent.role not in ("cell_pm", "main_pm"):
            return None
        assigned = await self.task.list_assigned_for_agent(agent_id)
        review = next(
            (t for t in assigned if str(t.status) == "awaiting_pm_review"), None
        )
        if review is None:
            return None
        return Envelope.invalid_state(
            message=(
                f"you still own task {review.id} awaiting your review; a DM does"
                " not route work, so idling just re-dispatches you."
            ),
            remediate=(
                "complete(task_id) to finish it, or reassign()/delegate() to"
                " send it back — then retry i_am_idle()."
            ),
            context_briefing=briefing,
        )

    # The auditor must have recorded an observation within this window before
    # it may go idle (session-scoped note obligation; see _auditor_note_guard).
    _AUDITOR_IDLE_NOTE_WINDOW_SECONDS: int = 3600

    async def _auditor_note_guard(
        self, agent_id: UUID, briefing: dict[str, Any]
    ) -> Envelope | None:
        """Refuse i_am_idle when the auditor has not recorded an observation.

        Every role with a dedicated note section is obligated to populate it,
        the same way journals are obligated. The auditor's section is
        ``auditor_notes``, but it owns no delivery task and has no delivery
        verb to hang the obligation on — so the obligation is session-scoped:
        before going idle the auditor must have recorded an observation within
        the last ``_AUDITOR_IDLE_NOTE_WINDOW_SECONDS`` (a reflect journal entry,
        or a note(scope='handoff') section write — either leaves a journal
        entry). Inert for every other role.
        """
        agent = await self.task.agent_for(agent_id)
        if agent is None or str(agent.role) != "auditor":
            return None
        if await self.journal.has_recent_entry(
            agent_id, self._AUDITOR_IDLE_NOTE_WINDOW_SECONDS
        ):
            return None
        return Envelope.invalid_state(
            message=(
                "as the auditor you must record an observation before going "
                "idle — your auditor_notes section is the artifact the company "
                "reviews, so it cannot be left empty."
            ),
            remediate=(
                "call note(scope='reflect', text='<what you observed and any "
                "concern>') for a journal observation, or "
                "note(scope='handoff', task_id='<task>', section={'summary': "
                "'<observation>', 'severity': 'info'|'watch'|'risk'}) to fill a "
                "task's auditor_notes — then retry i_am_idle()."
            ),
            context_briefing=briefing,
        )

    async def _pm_uncovered_decomposition_guard(
        self, agent_id: UUID, briefing: dict[str, Any]
    ) -> Envelope | None:
        """Refuse i_am_idle when a PM left a parent criterion unclaimed (Spec 2).

        The decomposition floor: a PM that has mapped *some* children to parent
        acceptance criteria (``covers_parent_criteria``) cannot idle while
        another parent criterion still has no subtask responsible for it — the
        "two leaves, half the ACs silently dropped" pattern from PR #175.
        Distinct from the roll-up gate (Spec 4), which fires at submit_up /
        complete / escalate and demands a *completed* child; this fires earlier,
        at PM exit, and asks only that every criterion be *claimed*.
        Safe-by-construction: ``unclaimed_parent_acceptance_criteria`` returns
        ``[]`` until the PM declares coverage on at least one child, so this is
        inert for legacy / not-yet-adopted decompositions and never blocks a PM
        who never touched coverage at all.
        """
        agent = await self.task.agent_for(agent_id)
        if not agent or agent.role not in ("cell_pm", "main_pm"):
            return None
        assigned = await self.task.list_assigned_for_agent(agent_id)
        for parent in assigned:
            if str(parent.status) in self._TERMINAL_STATUSES:
                continue
            unclaimed = await self.task.unclaimed_parent_acceptance_criteria(parent.id)
            # isinstance keeps the gate inert under partial test mocks (an
            # AsyncMock TaskService returns a truthy stub, not a concrete list).
            if not isinstance(unclaimed, list) or not unclaimed:
                continue
            listing = "; ".join(unclaimed)
            return Envelope.invalid_state(
                message=(
                    f"task {parent.id} has {len(unclaimed)} acceptance criteria with"
                    " no subtask responsible for them; cannot idle mid-decomposition."
                ),
                remediate=(
                    "delegate (or reassign) subtasks whose covers_parent_criteria"
                    f" include these criteria, then retry i_am_idle: {listing}"
                ),
                context_briefing=briefing,
            )
        return None

    async def _pm_uncovered_required_cells_guard(
        self, agent_id: UUID, briefing: dict[str, Any]
    ) -> Envelope | None:
        """Refuse i_am_idle when a PM left an explicitly-named cell undelegated.

        The brief / acceptance criteria / Board handoff can name specific cells
        (recorded as a ``required_cells:`` marker on the parent). The Main PM
        must create a subtask for each — its discretion covers only un-named
        scope; a genuinely-unnecessary named cell must be confirmed via
        escalate_up/dm, not silently dropped. This is the structured companion
        to the prompt rule (commit 60de3499), firing at PM exit. Inert when no
        parent carries the marker, so legacy decompositions are never blocked.
        """
        agent = await self.task.agent_for(agent_id)
        if not agent or agent.role not in ("cell_pm", "main_pm"):
            return None
        assigned = await self.task.list_assigned_for_agent(agent_id)
        for parent in assigned:
            if str(parent.status) in self._TERMINAL_STATUSES:
                continue
            uncovered = await self.task.uncovered_required_cells(parent.id)
            # isinstance keeps the gate inert under partial test mocks (an
            # AsyncMock TaskService returns a truthy stub, not a concrete list).
            if not isinstance(uncovered, list) or not uncovered:
                continue
            listing = ", ".join(uncovered)
            return Envelope.invalid_state(
                message=(
                    f"task {parent.id} names cells with no subtask: {listing}; "
                    "cannot idle while an explicitly-named cell is undelegated."
                ),
                remediate=(
                    f"delegate a subtask for each named cell ({listing}); if one is "
                    "genuinely unnecessary, confirm via escalate_up/dm rather than "
                    "dropping it, then retry i_am_idle"
                ),
                context_briefing=briefing,
            )
        return None

    async def _auto_pause_in_progress_tasks(self, agent_id: UUID) -> list[str]:
        """Pause every in_progress task assigned to this agent.

        Restores the pre-Phase-4 auto-pause behavior: a PM that called
        i_will_plan and is now idle leaves the parent at ``paused`` so the
        closure dispatcher knows to respawn it when subtasks complete.

        Returns the list of task IDs that were paused (as strings) so
        ``i_am_idle`` can tell the agent which ``resume(task_id)`` calls
        await it on the next respawn. Empty list when nothing was active.

        Pre-gateway parity: a synthetic checkpoint is
        written for each paused task so the panel's Checkpoints column reflects
        reality. Checkpoint failure is swallowed; it must never block the pause.
        """
        in_progress = await self.task.list_in_progress_for_agent(agent_id)
        # The lookup also returns blocked tasks (so the claim guard sees them);
        # i_am_idle only auto-pauses genuinely in_progress ones — a blocked task
        # waits on an external dep, not the agent, so it stays blocked.
        from roboco.models.base import TaskStatus

        paused_ids: list[str] = []
        for t in in_progress:
            if t.status != TaskStatus.IN_PROGRESS:
                continue
            await self.task.pause_for_agent(agent_id, t.id)
            paused_ids.append(str(t.id))
            await self._write_auto_pause_checkpoint(agent_id, t)
        return paused_ids

    async def _write_auto_pause_checkpoint(self, agent_id: UUID, task: Any) -> None:
        """Write a synthetic checkpoint for a task that was auto-paused on i_am_idle.

        Captures state-at-pause so the panel's
        Checkpoints column is never empty after an auto-pause. Agents that
        want an explicit checkpoint before idling can call note(scope='note',
        text='checkpoint: ...') first; this synthetic write covers the bare
        i_am_idle case which is what all current agents do.

        Failure is logged and swallowed — the pause already happened and the
        caller must not be affected by a checkpoint DB error.
        """
        try:
            commits = task.commits or []
            # commits may be hydrated as CommitRef objects or as plain dicts
            # (JSON column round-trip); the identifier field is `hash` (a stray
            # `sha` only ever appears on a gateway return value, never persisted).
            commit_refs = [
                (c.get("hash") or c.get("sha"))
                if isinstance(c, dict)
                else (getattr(c, "hash", None) or getattr(c, "sha", None))
                for c in commits[-3:]
            ]
            commit_refs = [ref for ref in commit_refs if ref]
            commit_count = len(commits)
            state_summary = f"auto-paused on i_am_idle (commits: {commit_count})"
            remaining_work = commit_refs if commit_refs else ["no commits yet"]
            await self.task.add_checkpoint(
                task_id=task.id,
                agent_id=agent_id,
                state_summary=state_summary,
                remaining_work=remaining_work,
            )
        except Exception:
            log = structlog.get_logger(__name__)
            log.warning(
                "auto_pause_checkpoint_failed",
                task_id=str(task.id),
                agent_id=str(agent_id),
            )

    # --- QA verbs moved to ``qa.py``. ---

    # --- Phase 3 (documenter + PM) verbs ---

    # claim_doc_task + i_documented moved to ``doc.py``.

    _RICH_PLAN_FIELDS: ClassVar[tuple[str, ...]] = (
        "approach",
        "sub_tasks",
        "technical_considerations",
        "risks",
        "open_questions",
    )

    @staticmethod
    def _resolve_effective_plan(
        plan: str, rich_plan: dict[str, Any] | None
    ) -> str | dict[str, Any]:
        """Use the panel-shaped dict when any rich field is populated.

        Requires `plan` (narrative paragraph) to be non-empty — the rich
        structure is layered on top, not a replacement. An empty paragraph
        falls through as ``""`` so the spec's plan-required precondition
        emits tracing_gap.
        """
        if not plan or not rich_plan:
            return plan
        if any(rich_plan.get(k) for k in Choreographer._RICH_PLAN_FIELDS):
            return _build_panel_shaped_plan(plan, rich_plan)
        return plan

    async def i_will_plan(
        self,
        pm_agent_id: UUID,
        task_id: UUID,
        plan: str,
        rich_plan: dict[str, Any] | None = None,
    ) -> Envelope:
        """PM mirror of i_will_work_on for parent tasks.

        Atomic: spec.can_invoke_intent runs before any state mutation;
        the composed (claim, set_plan, start) sequence is wrapped in a
        savepoint by the runner so a mid-sequence failure rolls back
        the DB.

        PM callers must supply ``approach`` (>= 20 chars) and a non-empty
        ``sub_tasks`` list inside ``rich_plan`` — these are enforced in
        ``_pm_sub_tasks_gate``. Developer callers may omit ``sub_tasks``
        (their plan is execution-shaped) but still need ``approach`` via
        the HTTP schema layer.

        Control flow: re-entry check → if not re-entry → sub_tasks gate
        → spec gate → claim+plan+start. The re-entry check must come first
        so a respawned PM calling with thin args ("resume", no sub_tasks)
        is short-circuited before the gate can reject them.
        """
        t = await self.task.get(task_id)
        if t is None:
            return await self._emit_rejection(
                Envelope.not_found(message=f"task {task_id} not found"),
                agent_id=pm_agent_id,
                task_id=task_id,
                verb="i_will_plan",
            )
        agent = await self.task.agent_for(pm_agent_id)
        role_str = str(agent.role) if agent is not None else "cell_pm"
        briefing = await self._briefing_for(
            pm_agent_id, task_id, task=t, include_ac_coverage=True, full=True
        )
        try:
            role = spec_module.Role(role_str)
        except ValueError:
            return await self._emit_rejection(
                Envelope.not_authorized(
                    message=f"unknown role '{role_str}'",
                    remediate="role is not declared in the lifecycle spec",
                    context_briefing=briefing,
                ).with_introspection(task=t, role=role_str),
                agent_id=pm_agent_id,
                task_id=task_id,
                verb="i_will_plan",
            )
        effective_plan = self._resolve_effective_plan(plan, rich_plan)
        # spec_ctx carries the resolved (possibly-dict) plan so the
        # verb runner's set_plan handler persists the panel-shaped rich shape.
        # Passing the raw `plan` string here was the bug that left the Plan tab
        # empty: the runner uses spec_ctx, not _ClaimPlanStartContext.
        spec_ctx = spec_module.Context(
            plan=effective_plan,
            actor_id=pm_agent_id,
            actor_slug=getattr(agent, "slug", None) if agent is not None else None,
            agent_team=str(agent.team) if agent is not None and agent.team else None,
            original_developer_slug=_extract_original_developer(t),
        )
        ctx = _ClaimPlanStartContext(
            agent_id=pm_agent_id,
            task_id=task_id,
            task=t,
            role_str=role_str,
            briefing=briefing,
            plan=effective_plan,
            verb_name="i_will_plan",
        )
        # Re-entry check runs first — a respawned PM with thin args ("resume",
        # no sub_tasks) must short-circuit here before any gate.
        if reentry := await self._handle_pm_reentry(
            ctx, t, pm_agent_id, task_id, role_str, briefing
        ):
            return reentry
        # Lifecycle spec gate runs BEFORE the sub_tasks gate so wrong-state
        # cases (e.g., task in backlog/claimed/completed) return invalid_state
        # — the lifecycle's verdict — instead of being masked by the
        # PM-decomposition check. Parity test
        # `test_lifecycle_consumer_parity.py::test_i_will_plan_matches_spec`
        # asserts this order.
        if rejection := await self._claim_plan_start_gate(ctx, role, spec_ctx):
            return rejection
        # Spec gate passed; now enforce the verb-specific PM-decomposition
        # contract. PMs decompose; their plan MUST include approach + at
        # least one sub_task. Devs execute; sub_tasks may be empty.
        if rejection := await self._pm_sub_tasks_gate(
            role_str=role_str,
            rich_plan=rich_plan,
            task=t,
            agent_id=pm_agent_id,
            task_id=task_id,
            briefing=briefing,
        ):
            return rejection
        envelope = await self._claim_plan_start_run(ctx, agent, spec_ctx)
        return await self._post_claim_journal_gate(
            "i_will_plan", pm_agent_id, task_id, envelope
        )

    async def delegate(
        self,
        pm_agent_id: UUID,
        parent_task_id: UUID,
        inputs: DelegateInputs,
    ) -> Envelope:
        """Create a subtask under parent_task_id with delegation-chain validation.

        Atomic: spec.can_invoke_intent runs first for the role+state gate.
        Delegate-specific gates the spec doesn't model (chain validation,
        assignee-vs-task_type, enum coercion, parent-ownership, subtask
        cap) run after the spec gate. Main PM may delegate to a Cell PM
        slug; a Cell PM may delegate to its own team's developers. The
        atomic ``create_subtask`` action is special — its handler raises
        NotImplementedError because it requires DelegateInputs — so the
        verb body owns the dispatch to ``_create_subtask_from_inputs``.
        """
        parent = await self.task.get(parent_task_id)
        if parent is None:
            return await self._emit_rejection(
                Envelope.not_found(message=f"task {parent_task_id} not found"),
                agent_id=pm_agent_id,
                task_id=parent_task_id,
                verb="delegate",
            )
        # Serialize same-parent delegates across the sibling-dedup read ->
        # create_subtask write critical section. The dedup guard reads the
        # parent's existing subtasks via an unlocked SELECT, then the verb
        # body creates the subtask — with no DB serialization two concurrent
        # same-parent delegates each read a duplicate-free set and each
        # create a subtask (the duplicate the guard exists to prevent). The
        # per-parent transaction-scoped advisory lock is held until the outer
        # request commits, so the second same-parent delegate blocks until
        # the first commits and its dedup read then sees the committed
        # sibling. Acquired before the first get_subtasks read (the briefing
        # context read AND the dedup sibling read) so it spans the whole
        # section. Per-parent (not per-agent) so a coordinator PM's parallel
        # root planning is not serialized — only same-parent delegates are.
        await self.task.acquire_delegate_parent_lock(parent_task_id)
        agent = await self.task.agent_for(pm_agent_id)
        role_str = str(agent.role) if agent is not None else "cell_pm"
        briefing = await self._briefing_for(pm_agent_id, parent_task_id, task=parent)
        try:
            role = spec_module.Role(role_str)
        except ValueError:
            return await self._emit_rejection(
                Envelope.not_authorized(
                    message=f"unknown role '{role_str}'",
                    remediate="role is not declared in the lifecycle spec",
                    context_briefing=briefing,
                ).with_introspection(task=parent, role=role_str),
                agent_id=pm_agent_id,
                task_id=parent_task_id,
                verb="delegate",
            )
        spec_ctx = spec_module.Context(
            actor_id=pm_agent_id,
            actor_slug=getattr(agent, "slug", None) if agent is not None else None,
            agent_team=str(agent.team) if agent is not None and agent.team else None,
            original_developer_slug=_extract_original_developer(parent),
        )
        soup = self._free_text_soup(
            checks=(
                ("title", inputs.title, 5),
                ("description", inputs.description, 10),
            )
        )
        decision = spec_module.can_invoke_intent(role, "delegate", parent, spec_ctx)
        if env := self._soup_or_decision_env(soup, decision, briefing):
            return await self._emit_rejection(
                env.with_introspection(task=parent, role=role_str),
                agent_id=pm_agent_id,
                task_id=parent_task_id,
                verb="delegate",
            )
        # The foundation/policy/task_completeness gate runs BEFORE the
        # static/lifecycle guards. Auto-fill helpers patch unambiguous fields
        # (team-from-slug, priority-from-parent), then `check(TASK_AT_CREATE,
        # ...)` rejects under-filled payloads with `Envelope.incomplete_input`
        # — the spec §5.2.1 interrogation pattern. Defense-in-depth: the
        # service-layer raise still catches non-gateway callers.
        completeness_env = self._delegate_completeness_check(
            inputs, parent, briefing, role_str
        )
        if completeness_env is not None:
            return await self._emit_rejection(
                completeness_env,
                agent_id=pm_agent_id,
                task_id=parent_task_id,
                verb="delegate",
            )
        # Write-then-gate: the delegated subtask's title + description (the
        # PM's own articulation of the work) is recorded as the
        # journal:decision the tracing guard below requires.
        await self._ensure_pm_decision(
            pm_agent_id,
            parent_task_id,
            f"Delegating subtask '{inputs.title}': {inputs.description}",
        )
        # Spec gate passed. Run delegate-specific guards the spec doesn't
        # model: tracing (journal:decision), chain validation, enum
        # coercion + assignee-vs-task_type, and parent-ownership/subtask-cap.
        guard = await self._delegate_extra_guards(
            pm_agent_id, parent_task_id, parent, role_str, inputs
        )
        if guard is not None:
            return await self._emit_rejection(
                guard.with_introspection(task=parent, role=role_str),
                agent_id=pm_agent_id,
                task_id=parent_task_id,
                verb="delegate",
            )
        return await self._create_subtask_and_envelope(
            pm_agent_id, parent, inputs, briefing, role_str
        )

    # Gate Set B subtask cap (pre-gateway implicit, made explicit here).
    # Soft warn at 8, hard block at 13. Cap enforced by ``_subtask_cap_guard``.
    _SUBTASK_HARD_CAP: int = 12

    async def _delegate_extra_guards(
        self,
        pm_agent_id: UUID,
        parent_task_id: UUID,
        parent: Any,
        role_str: str,
        inputs: DelegateInputs,
    ) -> Envelope | None:
        """Delegate-specific guards the spec doesn't model.

        Order: tracing (journal:decision per VERB_REQUIREMENTS) ->
        chain validation -> static (project_id, enum coercion,
        assignee-vs-task_type) -> lifecycle (parent ownership + subtask
        cap). Each returns an Envelope rejection or None to allow.

        The role-only check is no longer here — ``spec.can_invoke_intent``
        handles role+state in the verb body before this is called.
        """
        # Pre-gateway PM.md required journal:decision before each delegate.
        if env := await self._check_pm_decision_required(
            "delegate", pm_agent_id, parent_task_id, parent
        ):
            return env
        chain_error = self._validate_delegation_chain(role_str, inputs.assigned_to)
        if chain_error is not None:
            return Envelope.not_authorized(
                message=chain_error,
                remediate=(
                    "Main PM delegates to be-pm/fe-pm/ux-pm. "
                    "Cell PM delegates to its own team's devs (e.g. backend "
                    "PM -> be-dev-1/be-dev-2)."
                ),
                context_briefing=await self._briefing_for(pm_agent_id, parent_task_id),
            )
        if guard := await self._delegate_static_guards(
            pm_agent_id, parent_task_id, parent, inputs
        ):
            return guard
        # Sibling dedup: catch the PM-decomposition bug where the same
        # parent gets two subtasks for the same role + task_type
        # (observed on smoke run 2026-05-11: Main PM created two planning
        # tasks for be-pm; Cell PM created two code tasks for be-dev-1).
        if guard := await self._delegate_sibling_dedup_guard(parent_task_id, inputs):
            return guard
        # Split-before-claim: reject an egregiously-bundled code leaf so the PM
        # splits it before any dev can claim it.
        if guard := self._delegate_sizing_guard(inputs):
            return guard
        # Gate Set B: PARENT_NOT_CLAIMED + SUBTASK_CAP
        return await self._delegate_lifecycle_guards(
            pm_agent_id, parent_task_id, parent
        )

    _TERMINAL_STATUSES: ClassVar[frozenset[str]] = frozenset({"completed", "cancelled"})
    # Per-parent concurrency cap by spine task_type. `planning` and
    # `documentation` stay sequential (one non-terminal at a time). `code` has
    # NO per-parent cap: a PM delegates a full per-dev queue up front (Spec 3
    # per-dev sequenced queues) — both cell devs build in parallel, each works
    # its own queue in sequence order, and the orchestrator's per-lane dispatch
    # barrier (`_blocked_by_earlier_lane_sibling`) keeps a dev to one live task
    # at a time. Total fan-out is still bounded by `_SUBTASK_HARD_CAP`.
    _SPINE_TYPE_CAPS: ClassVar[dict[str, int]] = {
        "planning": 1,
        "documentation": 1,
    }
    # Split-before-claim sizing thresholds for a `code` leaf (by acceptance-
    # criteria count). Above the nudge count we flag a possible split in the
    # success envelope; above the hard count we reject so the PM splits the
    # bundle before any dev can claim it.
    _SIZING_NUDGE_AC_COUNT: ClassVar[int] = 5
    _SIZING_HARD_AC_COUNT: ClassVar[int] = 8

    @classmethod
    def _code_leaf_ac_count(cls, inputs: DelegateInputs) -> int | None:
        """Acceptance-criteria count for a ``code`` subtask, else None.

        Only ``code`` leaves are sized this way — ``planning`` briefs (main_pm
        → cell_pm) legitimately carry many criteria and are exempt.
        """
        if str(inputs.task_type or "") != "code":
            return None
        return len(inputs.acceptance_criteria or [])

    @classmethod
    def _delegate_sizing_guard(cls, inputs: DelegateInputs) -> Envelope | None:
        """Hard-block an egregiously-bundled code leaf (split-before-claim).

        A ``code`` subtask carrying more than ``_SIZING_HARD_AC_COUNT``
        acceptance criteria bundles too many independent concerns into one leaf:
        QA can't pass a partial, the dev re-touches unrelated parts, and criteria
        get dropped. Reject it so the PM splits the bundle before a dev ever
        claims it. Moderate bundling is allowed but flagged (see
        :meth:`_sizing_hint`).
        """
        ac_count = cls._code_leaf_ac_count(inputs)
        if ac_count is None or ac_count <= cls._SIZING_HARD_AC_COUNT:
            return None
        return Envelope.invalid_state(
            message=(
                f"code subtask bundles {ac_count} acceptance criteria — too many "
                f"independent concerns for one leaf "
                f"(cap {cls._SIZING_HARD_AC_COUNT})."
            ),
            remediate=(
                "Split this into smaller code subtasks before delegating — one "
                "concern each, 2-4 acceptance criteria per subtask. Hand "
                "independent concerns to both cell devs in parallel; sequence "
                "dependent ones. A bundled leaf drives multi-round QA failures "
                "and drops criteria."
            ),
            context_briefing={},
        )

    @classmethod
    def _sizing_hint(cls, inputs: DelegateInputs) -> str | None:
        """Soft nudge for a code leaf in the moderate sizing band.

        Fires above the nudge count and below the hard cap (the hard cap rejects
        outright). Surfaced in the delegate success envelope, never blocking.
        """
        ac_count = cls._code_leaf_ac_count(inputs)
        if ac_count is None or ac_count <= cls._SIZING_NUDGE_AC_COUNT:
            return None
        return (
            f"This code subtask carries {ac_count} acceptance criteria. If they "
            "span more than one independent concern, consider splitting it so "
            "each concern is its own subtask — and hand independents to both "
            "devs in parallel. (Allowed, just flagged.)"
        )

    @staticmethod
    def _spine_type_dup_envelope(
        new_type: str, sibling: Any, sib_assignee: str, cap: int = 1
    ) -> Envelope:
        """Rule-1 rejection: a sequential spine (planning/documentation) is full.

        Only ``planning`` and ``documentation`` are cap-limited now (1 at a
        time); ``code`` has no per-parent cap (Spec 3 per-dev queues), so this
        only ever fires for the sequential spines. ``cap`` is kept for the
        message but is 1 in every live call.
        """
        return Envelope.invalid_state(
            message=(
                f"parent already has {cap} non-terminal "
                f"task_type={new_type!r} subtask(s) "
                f"(e.g. {sibling.id}, assigned_to={sib_assignee!r}, "
                f"status={sibling.status}). The {new_type!r} spine is "
                "sequential — only one non-terminal at a time."
            ),
            remediate=(
                "Drive an existing sibling to completion / cancel it before "
                "delegating another of the same type. If the work is genuinely "
                "parallel, split this parent into two sibling parents instead.\n\n"
                "**DO NOT work around this by delegating again with a "
                "different task_type** (e.g. 'documentation' or "
                "'research' as a 'verification' subtask). The lifecycle "
                "handles QA, documentation, and PM-review automatically "
                "after the code subtask finishes — you do not create "
                "auxiliary subtasks for those roles. Call i_am_idle() "
                "now and wait for an existing child to come back."
            ),
            context_briefing={},
        )

    @staticmethod
    def _same_assignee_dup_envelope(
        new_type: str, new_assignee: str, sibling: Any
    ) -> Envelope:
        """Rule-2 rejection: same assignee already owns same task_type."""
        return Envelope.invalid_state(
            message=(
                f"sibling subtask already assigned to "
                f"{new_assignee!r} with task_type={new_type!r}: "
                f"id={sibling.id} status={sibling.status}"
            ),
            remediate=(
                "Either drive the existing sibling to completion / "
                "cancel it, or split this work into a subtask of "
                "the existing sibling rather than a new sibling."
            ),
            context_briefing={},
        )

    @staticmethod
    def _is_cross_team_planning(new_type: str, new_team: str, sib_team: str) -> bool:
        """Planning subtasks on different teams are NOT
        over-decomposition — main_pm fans planning out to per-cell PMs.
        Both teams must be non-empty so an empty-team escape hatch can't
        bypass the cap defensively.
        """
        return (
            new_type == "planning"
            and bool(new_team)
            and bool(sib_team)
            and new_team != sib_team
        )

    @classmethod
    def _sibling_cap_envelope(
        cls,
        siblings: list[Any],
        new_type: str,
        new_team: str,
        new_assignee: str,
        new_title: str = "",
    ) -> Envelope | None:
        """Apply Rule-2 (same-assignee) then Rule-1 (spine concurrency cap).

        Rule 2: prevent accidental duplicate delegation to one agent — for
        ``planning`` / ``documentation`` no two same-type subtasks per parent;
        for ``code`` only an exact same-title repeat (a dev may own a sequenced
        queue otherwise).

        Rule 1: a parent may hold at most ``_SPINE_TYPE_CAPS[type]`` non-terminal
        subtasks of a spine type. ``code`` has no per-parent cap (a full per-dev
        queue is delegated up front; the per-lane dispatch barrier and
        ``_SUBTASK_HARD_CAP`` bound it); ``planning`` and ``documentation`` stay
        at 1. ``planning`` on a different team does not count toward the cap —
        that's main_pm's legitimate cross-cell fanout (see
        :meth:`_is_cross_team_planning`).
        """
        live = [
            s
            for s in siblings
            if str(getattr(s, "status", "")) not in cls._TERMINAL_STATUSES
        ]
        return cls._same_assignee_rejection(
            live, new_type, new_assignee, new_title
        ) or cls._spine_cap_rejection(live, new_type, new_team)

    @staticmethod
    def _norm_title(title: str) -> str:
        """Lower-case, whitespace-collapsed title for duplicate comparison."""
        return " ".join((title or "").lower().split())

    @classmethod
    def _same_assignee_rejection(
        cls, live: list[Any], new_type: str, new_assignee: str, new_title: str = ""
    ) -> Envelope | None:
        """Rule 2: prevent accidental duplicate delegation to one agent.

        For ``planning`` / ``documentation`` (sequential spines) a PM never
        gives one agent two non-terminal subtasks of the same type under one
        parent. For ``code`` a dev legitimately owns a *queue* of sequenced
        subtasks (Spec 3 per-dev parallelism), so same-type alone is allowed —
        only an exact same-title duplicate (the accidental re-delegation bug,
        e.g. "Cell PM created two code tasks for be-dev-1") is rejected.
        """
        if not new_assignee:
            return None
        norm_new = cls._norm_title(new_title)
        for sibling in live:
            if str(getattr(sibling, "assigned_to", "") or "") != new_assignee:
                continue
            if str(getattr(sibling, "task_type", "")) != new_type:
                continue
            if new_type == "code":
                # A distinct queue item is fine; an exact title repeat is not.
                sib_title = cls._norm_title(str(getattr(sibling, "title", "") or ""))
                if norm_new and sib_title == norm_new:
                    return cls._same_assignee_dup_envelope(
                        new_type, new_assignee, sibling
                    )
                continue
            return cls._same_assignee_dup_envelope(new_type, new_assignee, sibling)
        return None

    @classmethod
    def _spine_cap_rejection(
        cls, live: list[Any], new_type: str, new_team: str
    ) -> Envelope | None:
        """Rule 1: at most ``_SPINE_TYPE_CAPS[type]`` non-terminal same-type."""
        cap = cls._SPINE_TYPE_CAPS.get(new_type)
        if cap is None:
            return None
        same_spine = [
            s
            for s in live
            if str(getattr(s, "task_type", "")) == new_type
            and not cls._is_cross_team_planning(
                new_type, new_team, str(getattr(s, "team", "") or "")
            )
        ]
        if len(same_spine) < cap:
            return None
        blocker = same_spine[0]
        return cls._spine_type_dup_envelope(
            new_type, blocker, str(getattr(blocker, "assigned_to", "") or ""), cap=cap
        )

    async def _delegate_sibling_dedup_guard(
        self,
        parent_task_id: UUID,
        inputs: DelegateInputs,
    ) -> Envelope | None:
        """Block PM-decomposition over-spread (the smoke-run runaway pattern).

        Two rules:

        1. **Same-type concurrency cap**: a parent may hold at most
           ``_SPINE_TYPE_CAPS[type]`` non-terminal subtasks of a spine type —
           ``planning`` and ``documentation`` stay at 1. ``code`` has no
           per-parent cap (a full per-dev sequenced queue is delegated up front;
           concurrency is bounded by the per-lane dispatch barrier and
           ``_SUBTASK_HARD_CAP``). Exception: ``planning`` on a different team
           does not count — that's main_pm's legitimate cross-cell fanout.
        2. **Same-assignee duplicate** (fallback): a PM never delegates two
           ``planning``/``documentation`` subtasks of the same type to one
           agent under one parent; for ``code`` a dev may own a queue, so only
           an exact same-title repeat is blocked (accidental re-delegation).

        Both rules surface an existing sibling id so the PM can finish
        or cancel it instead of guessing.
        """
        siblings = await self.task.get_subtasks(parent_task_id)
        return self._sibling_cap_envelope(
            list(siblings),
            str(inputs.task_type or ""),
            str(inputs.team or ""),
            str(inputs.assigned_to or ""),
            str(inputs.title or ""),
        )

    async def _delegate_static_guards(
        self,
        pm_agent_id: UUID,
        parent_task_id: UUID,
        parent: Any,
        inputs: DelegateInputs,
    ) -> Envelope | None:
        """project_id / enum guards. Pure data-shape checks.

        The slug-validity check used to live here, but
        `_validate_delegation_chain` runs first (in `_delegate_extra_guards`)
        and rejects any slug outside the allowed delegation targets —
        which is a strict subset of `AGENT_UUIDS` — so any AGENT_UUIDS
        check here was unreachable.
        """
        if (
            parent.project_id is None
            and getattr(parent, "product_id", None) is None
            and not getattr(parent, "cell_projects", None)
        ):
            return Envelope.invalid_state(
                message="parent task has neither a project_id nor a product_id",
                remediate=(
                    "the parent must have a project (single repo) or a product "
                    "(cell->project map) so subtasks can resolve a repo"
                ),
                context_briefing=await self._briefing_for(pm_agent_id, parent_task_id),
            )
        try:
            self._resolve_delegate_enums(inputs)
        except ValueError as exc:
            return Envelope.invalid_state(
                message=f"invalid enum value: {exc}",
                remediate="check team/task_type/estimated_complexity",
                context_briefing=await self._briefing_for(pm_agent_id, parent_task_id),
            )
        if type_error := self._validate_assignee_task_type(
            inputs.assigned_to, inputs.task_type
        ):
            return Envelope.invalid_state(
                message=type_error,
                remediate=self._assignee_task_type_remediate(inputs.assigned_to),
                context_briefing=await self._briefing_for(pm_agent_id, parent_task_id),
            )
        if str(inputs.task_type) == "documentation":
            # The lifecycle auto-handles documentation. After a
            # `code` subtask passes QA it transitions to
            # awaiting_documentation and a *documenter* is spawned
            # automatically. A PM-created `documentation` subtask assigned
            # to a developer can never be spawned (dev-dispatch rejects
            # role/task_type mismatch) — it becomes a permanent orphan
            # that deadlocks submit_up (all subtasks must be terminal).
            # The spine-cap is per-type so this slips past the
            # code-vs-code dedup; reject it explicitly here.
            return Envelope.invalid_state(
                message=(
                    "task_type='documentation' subtasks are not PM-"
                    "delegatable: the lifecycle creates the documentation "
                    "phase automatically after the code subtask passes QA."
                ),
                remediate=(
                    "Delegate ONLY the code subtask (task_type='code'). "
                    "Once it passes QA the gateway transitions it to "
                    "awaiting_documentation and spawns a documenter for "
                    "you — do NOT create a separate documentation subtask "
                    "or assign docs to a developer. Re-issue delegate(...) "
                    "with task_type='code' and i_am_idle when done."
                ),
                context_briefing=await self._briefing_for(pm_agent_id, parent_task_id),
            )
        return None

    @staticmethod
    def _validate_assignee_task_type(assigned_to: str, task_type: str) -> str | None:
        """Reject role-vs-type misclassifications.

        Rules:
        - delegating to a PM (Cell PM OR Main PM) requires
          ``task_type='planning'``. PMs decompose/coordinate; they don't
          execute. A freshly delegated subtask is never in ``needs_revision``,
          so the issue-resolution carve-out (a PM taking a code task to resolve
          review issues) does not apply here — that lives at the claim gate
          (``lifecycle._check_claim_rules_narrow`` via ``pm_cannot_own_code``).
          Main PM was previously omitted (only the cell-PM slug set was
          checked), leaving a delegate-to-main-pm-as-code hole.
        - (2026-05-11 smoke): delegating to a Developer requires
          ``task_type in {'code', 'documentation', 'research'}``. Devs
          implement. Planning/design/administrative belong to PMs/board.
          The 'research' allowance covers genuine spike work (try a
          library, prototype an approach) — NOT coordination/handoff,
          which is PM work.
        - (#7): the UX/UI cell's developers ARE its designers — for a
          DEVELOPER on ``Team.UX_UI`` ``task_type='design'`` is legitimate
          cell work (mockups, specs, design assets committed to the repo),
          so ux-dev-1/ux-dev-2 also accept ``design``. Backend/frontend
          devs still cannot be handed ``design`` — that routing belongs to
          the UX cell. The orchestrator already dispatches a developer for a
          ``design`` task (``_dev_dispatch_role_matches`` returns True), so
          this does not create the orphan that blocks ``documentation``.
        - Delegating to a QA requires ``task_type='code'`` (their work is
          to review PRs of code changes).
        - Delegating to a Documenter requires ``task_type='documentation'``.
        """
        pm_err = Choreographer._pm_task_type_error(assigned_to, task_type)
        if pm_err is not None:
            return pm_err
        from roboco.foundation.identity import AGENTS, Role, Team

        agent = AGENTS.get(assigned_to)
        if agent is None:
            return None
        if agent.role is Role.DEVELOPER:
            dev_err = Choreographer._developer_task_type_error(
                assigned_to, agent.team is Team.UX_UI, task_type
            )
            if dev_err is not None:
                return dev_err
        if agent.role is Role.QA and task_type != "code":
            return (
                f"task_type={task_type!r} is invalid for assignee {assigned_to!r}: "
                f"QA reviews code PRs — task_type must be 'code'."
            )
        if agent.role is Role.DOCUMENTER and task_type != "documentation":
            return (
                f"task_type={task_type!r} is invalid for assignee {assigned_to!r}: "
                f"Documenters write documentation — task_type must be "
                f"'documentation'."
            )
        return None

    @staticmethod
    def _pm_task_type_error(assigned_to: str, task_type: str) -> str | None:
        """Reject a code/non-planning task_type delegated to a PM (cell or main).

        Extracted from ``_validate_assignee_task_type`` so the compound PM guard
        doesn't inflate the dispatcher's complexity. A freshly delegated subtask
        is never in ``needs_revision``, so the issue-resolution carve-out does
        not apply here (it lives at the claim gate).
        """
        from roboco.foundation.identity import AGENTS, Role

        agent = AGENTS.get(assigned_to)
        if agent is None or agent.role not in (Role.CELL_PM, Role.MAIN_PM):
            return None
        if task_type == "planning":
            return None
        return (
            f"task_type={task_type!r} is invalid for assignee"
            f" {assigned_to!r}: PMs own planning tasks, not"
            f" code/documentation/etc."
        )

    @staticmethod
    def _developer_task_type_error(
        assigned_to: str, is_ux_dev: bool, task_type: str
    ) -> str | None:
        """Reject a developer's task_type. UX-cell developers additionally
        accept 'design' (mockups/specs/design assets are their cell work)."""
        allowed = {"code", "documentation", "research"}
        owned = "code/documentation/research"
        design_clause = "; design belongs to the UX cell."
        if is_ux_dev:
            allowed.add("design")
            owned = "code/documentation/research/design"
            design_clause = "."
        if task_type in allowed:
            return None
        return (
            f"task_type={task_type!r} is invalid for assignee "
            f"{assigned_to!r}: Developers own {owned}. Coordination, "
            f"planning, and administrative work belong to PMs{design_clause}"
        )

    @staticmethod
    def _assignee_task_type_remediate(assigned_to: str) -> str:
        """Per-assignee-class remediation for an assignee-vs-task_type reject.

        The reject `message` is already case-specific; this gives the PM the
        right *next call* for the kind of assignee it just mis-typed instead
        of a one-size-fits-all 'pass planning to a Cell PM' hint.
        """
        from roboco.foundation.identity import AGENTS, Role, Team

        agent = AGENTS.get(assigned_to)
        if agent is not None and agent.role is Role.MAIN_PM:
            return (
                "Main PMs own PLANNING tasks — they coordinate across cells and "
                "delegate execution. Pass task_type='planning' when delegating to "
                "the Main PM; route code work to a developer via the cell PM."
            )
        if agent is not None and agent.role is Role.CELL_PM:
            return (
                "Cell PMs (be-pm/fe-pm/ux-pm) own PLANNING tasks — they "
                "decompose the slice and delegate code work to devs. Pass "
                "task_type='planning' when delegating to a Cell PM."
            )
        if agent is not None and agent.role is Role.DEVELOPER:
            if agent.team is Team.UX_UI:
                return (
                    "UX developers take task_type in "
                    "{'code','documentation','research','design'}. Use "
                    "'design' for mockups/specs/design-asset work, 'code' "
                    "for implementation."
                )
            return (
                "Developers take task_type in "
                "{'code','documentation','research'}. Use 'code' for "
                "implementation; planning/administrative work stays with you "
                "(the PM), and design work is routed to the UX cell."
            )
        return (
            "Match task_type to the assignee's role: 'code' for devs/QA, "
            "'documentation' for documenters, 'planning' for Cell PMs."
        )

    async def _delegate_lifecycle_guards(
        self,
        pm_agent_id: UUID,
        parent_task_id: UUID,
        parent: Any,
    ) -> Envelope | None:
        """Gate Set B: PARENT_NOT_CLAIMED + SUBTASK_CAP.

        Pre-gateway, the orchestrator enforced both implicitly: a PM only
        ever called task_create after the orchestrator spawned them
        post-claim, and naturally never created more than a handful of
        subtasks in one spawn cycle.

        With the gateway exposing ``delegate`` as a first-class verb,
        these gates must be explicit.
        """
        if str(parent.status) != "in_progress":
            return Envelope.invalid_state(
                message=(
                    f"parent task {parent_task_id} is in {parent.status}; "
                    "must be in_progress to accept subtasks"
                ),
                remediate=(
                    f"call i_will_plan(parent_task_id='{parent_task_id}',"
                    " plan='...') before delegating subtasks"
                ),
                context_briefing=await self._briefing_for(pm_agent_id, parent_task_id),
            )
        if parent.assigned_to != pm_agent_id:
            return Envelope.not_authorized(
                message=(
                    f"parent task {parent_task_id} is assigned to "
                    f"{parent.assigned_to}, not you"
                ),
                remediate=(
                    f"call i_will_plan(parent_task_id='{parent_task_id}',"
                    " plan='...') to claim before delegating subtasks"
                ),
                context_briefing=await self._briefing_for(pm_agent_id, parent_task_id),
            )
        existing = await self.task.get_subtasks(parent_task_id)
        if len(existing) >= self._SUBTASK_HARD_CAP:
            return Envelope.invalid_state(
                message=(
                    f"parent already has {len(existing)} subtasks; "
                    f"cap is {self._SUBTASK_HARD_CAP}"
                ),
                remediate=(
                    "consolidate or split into a separate parent task before"
                    " adding more subtasks"
                ),
                context_briefing=await self._briefing_for(pm_agent_id, parent_task_id),
            )
        return None

    @staticmethod
    def _resolve_delegate_enums(inputs: DelegateInputs) -> tuple[Any, Any, Any]:
        """Convert string inputs to Team/TaskType/Complexity enums.

        Raises ValueError if any string is not a member of its enum.
        """
        from roboco.models.base import Complexity, TaskType, Team

        return (
            Team(inputs.team),
            TaskType(inputs.task_type),
            Complexity(inputs.estimated_complexity),
        )

    def _delegate_completeness_check(
        self,
        inputs: DelegateInputs,
        parent: Any,
        briefing: dict[str, Any],
        role_str: str,
    ) -> Envelope | None:
        """The foundation/policy/task_completeness gate for delegate.

        Auto-fills unambiguous fields (team-from-slug, priority-from-parent)
        without overwriting explicit values, then runs `check(TASK_AT_CREATE,
        ...)` against the payload. Returns:

        - ``None`` when every TASK_AT_CREATE requirement is satisfied (the
          verb continues into the static/lifecycle guards).
        - ``Envelope.incomplete_input`` (with `with_introspection` applied)
          when any field is missing — the agent gets a structured
          field-by-field guide (spec §5.2.1 interrogation pattern).

        The `acceptance_criteria=inputs.acceptance_criteria or []` collapse
        at `_create_subtask_from_inputs` was removed alongside this gate,
        so under-filled payloads now hit the service-layer raise
        instead of being silently substituted. This method is the
        gateway-side defense; the service raise is defense-in-depth for
        non-gateway callers.
        """
        from types import SimpleNamespace

        from roboco.foundation.policy import task_completeness as tc

        payload: dict[str, Any] = {
            "title": inputs.title,
            "description": inputs.description,
            "assigned_to": inputs.assigned_to,
            "team": inputs.team,
            "task_type": inputs.task_type,
            "nature": inputs.nature,
            "estimated_complexity": inputs.estimated_complexity,
            "acceptance_criteria": inputs.acceptance_criteria,
            # Collision surface — required for code subtasks (TASK_AT_DELEGATE):
            # the sibling collision DAG can only order what is declared, and a
            # no-surface code sibling silently runs parallel to everything.
            "intends_to_touch": inputs.intends_to_touch,
        }
        # Auto-fill (spec §5.2.1 (a)) — never overwrites explicit values.
        # team-from-slug is harmless when the caller already supplied team;
        # priority-from-parent records `__priority_inherited=True` for
        # post-create journal:note (best-effort observability).
        payload = tc.fill_team_from_assignee(payload)
        payload = tc.fill_priority_from_parent(payload, parent)
        completeness_input = SimpleNamespace(
            **{k: v for k, v in payload.items() if not k.startswith("__")}
        )
        result = tc.check(tc.TASK_AT_DELEGATE, completeness_input)
        if result.passed:
            return None
        return Envelope.incomplete_input(
            missing=result.missing,
            field_hints=result.field_hints,
            remediate=(
                "re-issue delegate(...) with these fields filled: "
                f"{', '.join(result.missing)}. Each field's required shape "
                "is in `field_hints`."
            ),
            context_briefing=briefing,
        ).with_introspection(task=parent, role=role_str)

    async def _create_subtask_and_envelope(
        self,
        pm_agent_id: UUID,
        parent: Any,
        inputs: DelegateInputs,
        briefing: dict[str, Any],
        role_str: str,
    ) -> Envelope:
        """Run subtask creation and translate completeness raises into envelopes.

        The defensive raises inside `_create_subtask_from_inputs`
        catch under-filled payloads that slipped past the gateway gate. Without
        this translator they surface as Starlette 500s — which means the agent
        never sees `field_hints`, retries indefinitely, and looks like a
        runaway. Converting to `Envelope.incomplete_input` here closes that
        loop so the agent gets the same interrogation-pattern reply it would
        have gotten from the upfront completeness check.
        """
        from roboco.foundation.policy.task_completeness import TaskCompletenessError
        from roboco.services.base import ValidationError

        parent_task_id = parent.id
        try:
            new_task = await self._create_subtask_from_inputs(
                pm_agent_id, parent_task_id, parent, inputs
            )
        except TaskCompletenessError as exc:
            return await self._emit_rejection(
                Envelope.incomplete_input(
                    missing=exc.missing,
                    field_hints=exc.field_hints,
                    remediate=(
                        "re-issue delegate(...) with corrected fields: "
                        f"{', '.join(exc.missing)}. Each field's required "
                        "shape is in `field_hints`."
                    ),
                    context_briefing=briefing,
                ).with_introspection(task=parent, role=role_str),
                agent_id=pm_agent_id,
                task_id=parent_task_id,
                verb="delegate",
            )
        except ValidationError as exc:
            # A user-input error from task creation (e.g. delegating past
            # MAX_TASK_DEPTH — ``_validate_parent_depth`` raises this with a
            # remediation message telling the PM to create a sibling instead of
            # a further nested subtask). Without this translator it escaped
            # uncaught as a 500 ExceptionGroup; the agent never saw the fix.
            # ``invalid_state`` carries the message as the remediation so the
            # PM gets a clean, actionable rejection it can act on.
            return await self._emit_rejection(
                Envelope.invalid_state(
                    message=exc.message,
                    remediate=exc.message,
                    context_briefing=briefing,
                ).with_introspection(task=parent, role=role_str),
                agent_id=pm_agent_id,
                task_id=parent_task_id,
                verb="delegate",
            )
        await self._wire_ux_frontend_dependency(new_task, parent)
        hint = self._sizing_hint(inputs)
        if hint is not None:
            briefing = {**briefing, "sizing_hint": hint}
        # Recompute coverage AFTER creation so the PM sees this child counted and
        # exactly which parent criteria are still unclaimed before the next
        # delegate / idle. The pre-create briefing built in delegate() is stale.
        coverage = await self.task.parent_ac_coverage(parent_task_id)
        if coverage:
            briefing = {
                **briefing,
                "parent_ac_coverage": coverage,
                "unclaimed_parent_acs": [c["id"] for c in coverage if not c["claimed"]],
            }
        return Envelope.ok(
            status="created",
            task_id=str(new_task.id),
            next=spec_module._INTENT_VERBS["delegate"].next_hint(new_task),
            context_briefing=briefing,
        ).with_introspection(task=new_task, role=role_str)

    @staticmethod
    def _team_value(value: Any) -> str:
        return value.value if hasattr(value, "value") else str(value)

    async def _wire_ux_frontend_dependency(self, new_task: Any, parent: Any) -> None:
        """Cross-cell sequencing: in a multi-cell fan-out the implementation cells
        (FRONTEND and BACKEND) depend on the UX/UI cell task — UX design defines
        the screens and API contracts both cells build against, so it is upstream
        of implementation. Wires the dependency in either delegation order. A
        dev/code subtask delegated under a cell task that is itself still waiting
        on that dependency inherits it, so the developer is held until UX is done
        instead of coding ahead of the design. Best-effort: never breaks delegate.

        Fires for a product fan-out (``product_id`` set) AND a MegaTask
        root-subtask (a batch item that fans out to its own cells via
        ``cell_projects`` and carries no ``product_id``) — without the latter the
        cross-cell edges were never wired for MegaTask cells, so they ran fully
        in parallel and the sequence was ignored (divergent branches).
        """
        if parent is None:
            return
        from roboco.foundation.identity import Team
        from roboco.foundation.policy.batch import is_batch_root_subtask

        is_fanout = getattr(
            parent, "product_id", None
        ) is not None or is_batch_root_subtask(
            batch_id=getattr(parent, "batch_id", None),
            parent_task_id=getattr(parent, "parent_task_id", None),
        )
        if not is_fanout:
            return

        nt_team = self._team_value(new_task.team)
        try:
            # A subtask of a cell task that is waiting on another cell must wait
            # too — propagate the parent's unmet cross-cell dependencies down.
            await self.task.inherit_unmet_dependencies(new_task.id, parent.id)
            if nt_team == Team.FRONTEND.value:
                await self._depend_frontend_on_ux(new_task, parent.id)
            elif nt_team == Team.BACKEND.value:
                await self._depend_backend_on_ux(new_task, parent.id)
            elif nt_team == Team.UX_UI.value:
                await self._depend_pending_frontends_on_ux(new_task, parent.id)
                await self._depend_pending_backends_on_ux(new_task, parent.id)
        except Exception as exc:
            logger.warning(
                "cross-cell UX->implementation sequencing wiring failed",
                error=str(exc),
                parent_task_id=str(getattr(parent, "id", None)),
            )

    async def _depend_frontend_on_ux(self, fe_task: Any, parent_id: Any) -> None:
        """Make a new FRONTEND cell task wait on its non-terminal UX/UI sibling.

        The wire is followed by a wave restamp (not a hand-rolled ``ux+1``
        write) so the sequence reflects EVERY same-parent dependency the task
        carries — a relative write could undercut a collision-derived stamp
        and invert the claim gate's order against a wired edge.
        """
        from roboco.foundation.identity import Team
        from roboco.models.base import TaskStatus

        terminal = {TaskStatus.COMPLETED, TaskStatus.CANCELLED}
        siblings = await self.task.get_subtasks(parent_id)
        ux = next(
            (
                s
                for s in siblings
                if self._team_value(s.team) == Team.UX_UI.value
                and s.id != fe_task.id
                and s.status not in terminal
            ),
            None,
        )
        if ux is not None:
            await self.task.add_dependency(fe_task.id, ux.id)
            await self.task.stamp_wave_sequence(fe_task.id)

    async def _depend_backend_on_ux(self, be_task: Any, parent_id: Any) -> None:
        """Make a new BACKEND cell task wait on its non-terminal UX/UI sibling.

        Wire + wave restamp, mirroring :meth:`_depend_frontend_on_ux`.
        """
        from roboco.foundation.identity import Team
        from roboco.models.base import TaskStatus

        terminal = {TaskStatus.COMPLETED, TaskStatus.CANCELLED}
        siblings = await self.task.get_subtasks(parent_id)
        ux = next(
            (
                s
                for s in siblings
                if self._team_value(s.team) == Team.UX_UI.value
                and s.id != be_task.id
                and s.status not in terminal
            ),
            None,
        )
        if ux is not None:
            await self.task.add_dependency(be_task.id, ux.id)
            await self.task.stamp_wave_sequence(be_task.id)

    async def _depend_pending_frontends_on_ux(
        self, ux_task: Any, parent_id: Any
    ) -> None:
        """Retro-wire not-yet-started FRONTEND siblings onto a new UX/UI task.

        Each retro-wired sibling is wave-restamped so it lands above the UX
        task's OWN stamped wave (the new task is stamped before this runs) —
        a relative ``ux+1`` write here read the pre-stamp sequence and could
        invert the order.
        """
        from roboco.foundation.identity import Team
        from roboco.models.base import TaskStatus

        not_started = {TaskStatus.BACKLOG, TaskStatus.PENDING}
        siblings = await self.task.get_subtasks(parent_id)
        for fe in siblings:
            if (
                self._team_value(fe.team) == Team.FRONTEND.value
                and fe.id != ux_task.id
                and fe.status in not_started
            ):
                await self.task.add_dependency(fe.id, ux_task.id)
                await self.task.stamp_wave_sequence(fe.id)

    async def _depend_pending_backends_on_ux(
        self, ux_task: Any, parent_id: Any
    ) -> None:
        """Retro-wire not-yet-started BACKEND siblings onto a new UX/UI task.

        Wire + wave restamp per sibling, mirroring
        :meth:`_depend_pending_frontends_on_ux`.
        """
        from roboco.foundation.identity import Team
        from roboco.models.base import TaskStatus

        not_started = {TaskStatus.BACKLOG, TaskStatus.PENDING}
        siblings = await self.task.get_subtasks(parent_id)
        for be in siblings:
            if (
                self._team_value(be.team) == Team.BACKEND.value
                and be.id != ux_task.id
                and be.status in not_started
            ):
                await self.task.add_dependency(be.id, ux_task.id)
                await self.task.stamp_wave_sequence(be.id)

    async def _resolve_subtask_project(
        self, parent: Any, inputs: DelegateInputs
    ) -> UUID:
        """Resolve the project a delegated subtask lands in.

        Priority: explicit inputs.project_id -> the parent's ad-hoc cell_projects
        map for this cell -> the parent's Product map for this cell -> the
        parent's own project. Raises TaskCompletenessError only for a fan-out
        parent (product or cell map, no own project) whose map has no mapping for
        this cell — i.e. the subtask would have no repo to land in.
        """
        if inputs.project_id is not None:
            return inputs.project_id
        # Ad-hoc per-cell map (a multi-cell MegaTask root-subtask): mirror the
        # product.project_for lookup but read the map off the parent task itself.
        parent_cell_map = getattr(parent, "cell_projects", None)
        if parent_cell_map:
            for mapping in parent_cell_map:
                if mapping.team == inputs.team:
                    return UUID(str(mapping.project_id))
        parent_product_id = getattr(parent, "product_id", None)
        if self.product is not None and parent_product_id is not None:
            mapped = await self.product.project_for(parent_product_id, inputs.team)
            if mapped is not None:
                return UUID(str(mapped))
        if parent.project_id is not None:
            return UUID(str(parent.project_id))
        from roboco.foundation.policy.task_completeness import TaskCompletenessError

        raise TaskCompletenessError(
            missing=["project_id"],
            field_hints={
                "project_id": (
                    f"no project for team {inputs.team!r}: add a "
                    f"{inputs.team}->project mapping to the parent's cell map or "
                    "product, or pass an explicit project_id on delegate"
                )
            },
            message=f"cannot resolve a project for the {inputs.team} subtask",
        )

    @staticmethod
    def _require_subtask_completeness(
        inputs: DelegateInputs,
    ) -> tuple[TaskNature, list[str]]:
        """Defensive completeness check (callers run ``_delegate_completeness_check``
        first); returns the validated ``TaskNature`` and the non-empty
        ``acceptance_criteria``. Raises ``TaskCompletenessError`` with field hints
        if a non-gateway caller bypassed the gateway check — never silently
        substitutes an empty acceptance list.
        """
        from roboco.foundation.policy.task_completeness import TaskCompletenessError
        from roboco.models.base import TaskNature

        if not inputs.acceptance_criteria:
            raise TaskCompletenessError(
                missing=["acceptance_criteria"],
                field_hints={
                    "acceptance_criteria": (
                        "non-empty list[str]; each item describes a verifiable outcome"
                    )
                },
                message=(
                    "_create_subtask_from_inputs called with empty "
                    "acceptance_criteria — completeness check must run first"
                ),
            )
        if inputs.nature is None:
            raise TaskCompletenessError(
                missing=["nature"],
                field_hints={"nature": "one of: technical | non_technical"},
                message=(
                    "_create_subtask_from_inputs called with no nature — "
                    "completeness check must run first"
                ),
            )
        try:
            nature_enum = TaskNature(inputs.nature)
        except ValueError as exc:
            raise TaskCompletenessError(
                missing=["nature"],
                field_hints={"nature": "one of: technical | non_technical"},
                message=f"invalid nature {inputs.nature!r}: {exc}",
            ) from exc
        return nature_enum, inputs.acceptance_criteria

    async def _create_subtask_from_inputs(
        self,
        pm_agent_id: UUID,
        parent_task_id: UUID,
        parent: Any,
        inputs: DelegateInputs,
    ) -> Any:
        """Resolve enums + AGENT_UUIDS slug and call TaskService.create_subtask.

        By contract, callers (the `delegate` verb body) MUST run
        `_delegate_completeness_check` first, so `inputs.acceptance_criteria`
        and `inputs.nature` are guaranteed non-None / non-empty here. The
        defensive `TaskCompletenessError` raises preserve correctness if
        a future caller bypasses the gateway path — defense-in-depth in
        line with the service-layer raise.
        """
        from roboco.models.task import TaskCreateRequest
        from roboco.seeds.initial_data import AGENT_UUIDS

        team_enum, type_enum, complexity_enum = self._resolve_delegate_enums(inputs)
        assignee_id = UUID(AGENT_UUIDS[inputs.assigned_to])
        # The gateway runs `_delegate_completeness_check` BEFORE this helper, so
        # empty/None acceptance_criteria or nature here means a non-gateway caller
        # bypassed the check — _require_subtask_completeness raises with field
        # hints rather than silently substituting.
        nature_enum, acceptance_criteria = self._require_subtask_completeness(inputs)
        resolved_project_id = await self._resolve_subtask_project(parent, inputs)
        req = TaskCreateRequest(
            title=inputs.title,
            description=inputs.description,
            acceptance_criteria=acceptance_criteria,
            parent_ac_refs=inputs.covers_parent_criteria or [],
            team=team_enum,
            created_by=pm_agent_id,
            project_id=resolved_project_id,
            product_id=getattr(parent, "product_id", None),
            parent_task_id=parent_task_id,
            assigned_to=assignee_id,
            task_type=type_enum,
            nature=nature_enum,
            estimated_complexity=complexity_enum,
            # Dev-task collision surface (multi-level sequencing — edge kind 3)
            # + explicit dependency override. Forwarded so create_subtask can
            # persist them (Phase S2 runs SequencingService over the surfaced
            # siblings and wires the collision DAG via add_dependency).
            intends_to_touch=inputs.intends_to_touch,
            adds_migration=inputs.adds_migration,
            touches_shared=inputs.touches_shared,
            dependency_ids=list(inputs.depends_on) if inputs.depends_on else [],
        )
        new_task = await self.task.create_subtask(req)
        # Wire the dev-task collision DAG (multi-level sequencing edge kind 3):
        # run the deterministic analyzer over the parent's surfaced siblings
        # and add_dependency each collision edge so a later dev task stays
        # PENDING until the sibling it collides with completes (PR merged) —
        # the cross-dev ordering the assignee-keyed spawn barrier could not
        # guarantee (live 2026-06-27 out-of-order break). Incremental +
        # idempotent: re-run after every delegate; add_dependency dedupes, and
        # dev_task_collision_edges orders by (priority, sequence) so re-runs
        # only add edges (never flip an existing pair's order into a cycle).
        await self.task.wire_sibling_collision_dag(parent_task_id)
        # Multi-level sequencing — edge kinds 2 + 4: the cell-task wave chain
        # and the by-osmosis dev-task edge. Dispatch on the parent's team: a
        # MAIN_PM parent is a root-subtask, so the new task is a CELL-TASK →
        # wire kind 2 (it depends on the previous wave's cell-tasks); a cell
        # team parent is a cell-task, so the new task is a DEV TASK → wire kind
        # 4 (its first dev task carries the previous wave's merged tail). kind 3
        # (wire_sibling_collision_dag, above) runs for both. Idempotent +
        # best-effort: missing predecessors / cell-tasks contribute no edge.
        from roboco.foundation.identity import Team

        parent_team = getattr(parent, "team", None)
        if parent_team == Team.MAIN_PM.value:
            await self.task.wire_cell_task_wave_chain(new_task.id)
        elif parent_team in (
            Team.BACKEND.value,
            Team.FRONTEND.value,
            Team.UX_UI.value,
        ):
            await self.task.wire_by_osmosis_edge(new_task.id)
        # Post-wiring wave stamp: sequence = 1 + max(same-parent dependency
        # sequences), 0 when independent — so independent siblings tie (and
        # run in parallel under the sequence claim gate) while colliding /
        # ordered ones ascend. Replaces the raw per-sibling ordinal, which
        # gave independent siblings distinct sequences and serialized ALL
        # delegated work fleet-wide. The cross-cell UX wiring (in the caller)
        # restamps any task it re-wires, so every sequence write in the
        # delegate flow is this one wave computation; merge order for wave
        # ties falls back to created_at in the merge/lane dispatch barriers.
        await self.task.stamp_wave_sequence(new_task.id)
        return new_task

    @staticmethod
    def _validate_delegation_chain(pm_role: str, target_slug: str) -> str | None:
        """Return error string if delegation chain is invalid; else None."""
        cell_pm_targets = {
            "cell_pm": {
                "backend": {"be-dev-1", "be-dev-2"},
                "frontend": {"fe-dev-1", "fe-dev-2"},
                "ux_ui": {"ux-dev-1", "ux-dev-2"},
            },
        }
        main_pm_targets = {"be-pm", "fe-pm", "ux-pm"}

        if pm_role == "main_pm":
            if target_slug not in main_pm_targets:
                return (
                    f"main_pm cannot delegate to {target_slug!r}; allowed: "
                    f"{sorted(main_pm_targets)}"
                )
            return None
        if pm_role == "cell_pm":
            allowed_for_any_team: set[str] = set()
            for team_targets in cell_pm_targets["cell_pm"].values():
                allowed_for_any_team |= team_targets
            if target_slug not in allowed_for_any_team:
                return (
                    f"cell_pm cannot delegate to {target_slug!r}; allowed: "
                    f"{sorted(allowed_for_any_team)}"
                )
            return None
        return f"role {pm_role!r} cannot delegate"

    async def submit_up(
        self,
        pm_agent_id: UUID,
        task_id: UUID,
        notes: str,
        resolved_findings: list[dict[str, Any]] | None = None,
    ) -> Envelope:
        """Cell PM bubbles a finished cell-scope task up to the Main PM.

        Spec gate runs first and enforces role membership (cell_pm only)
        plus the composed ``submit_pm_review`` action's source-status
        constraint (IN_PROGRESS only). After the gate accepts, the
        verb-specific ``_submit_up_guard`` runs the rest of the
        preflight checks the spec doesn't model: ownership, notes
        length, journal:decision presence, subtasks-terminal, branch
        present, and — via FINDINGS_ADDRESSED — every open pr_gate/pm/
        ceo-origin ledger finding on this root. ``resolved_findings``
        ({finding_id, commit?, note?} entries) marks ledger rows
        addressed BEFORE that gate runs (mirrors ``i_am_done``). Then
        ``VerbRunner.run_intent("submit_up", ...)`` dispatches the
        (submit_pm_review,) atomic chain plus the (create_pr,) side
        effect inside a savepoint. After the runner returns, the task
        is handed off to the Main PM (reassign + a2a).
        """
        t = await self.task.get(task_id)
        briefing = await self._briefing_for(
            pm_agent_id, task_id, task=t, include_ac_coverage=True
        )
        if t is None:
            return await self._emit_rejection(
                Envelope.not_found(message=f"task {task_id} not found"),
                agent_id=pm_agent_id,
                task_id=task_id,
                verb="submit_up",
            )
        agent = await self.task.agent_for(pm_agent_id)
        role_str = str(agent.role) if agent is not None else "cell_pm"
        try:
            role = spec_module.Role(role_str)
        except ValueError:
            return await self._emit_rejection(
                Envelope.not_authorized(
                    message=f"unknown role '{role_str}'",
                    remediate="role is not declared in the lifecycle spec",
                    context_briefing=briefing,
                ).with_introspection(task=t, role=role_str),
                agent_id=pm_agent_id,
                task_id=task_id,
                verb="submit_up",
            )
        actor_slug, agent_team = actor_context_fields(agent)
        spec_ctx = spec_module.Context(
            actor_id=pm_agent_id,
            actor_slug=actor_slug,
            agent_team=agent_team,
            original_developer_slug=_extract_original_developer(t),
            notes=notes,
        )
        soup = self._free_text_soup(checks=(("notes", notes, 10),))
        decision = spec_module.can_invoke_intent(role, "submit_up", t, spec_ctx)
        if env := self._soup_or_decision_env(soup, decision, briefing):
            return await self._emit_rejection(
                env.with_introspection(task=t, role=role_str),
                agent_id=pm_agent_id,
                task_id=task_id,
                verb="submit_up",
            )

        # Write-then-gate: resolve findings before the guard's
        # FINDINGS_ADDRESSED check re-reads open_finding_ids (mirrors
        # i_am_done's _apply_resolved_findings). Owner-gated: a stale
        # non-owner PM's resolutions must not mutate the ledger — the
        # ownership guard below rejects it, but the session still commits.
        if t.assigned_to == pm_agent_id:
            await self._apply_resolved_findings_for(task_id, resolved_findings)
        # Verb-specific preflight: ownership + notes-length + journal:decision
        # + subtasks-terminal + branch-present. None of these are modelled by
        # the spec yet — keep them in the verb body.
        guard = await self._submit_up_guard(pm_agent_id, task_id, t, notes)
        # Cell-level unchanged-PR loop-stopper. Consulted only after the state
        # guard above passes; a prior preflight reject short-circuits before the
        # head-sha comparison runs (mirroring submit_root).
        if guard is None:
            guard = await self._submit_up_unchanged_pr_guard(t, briefing)
        if guard is None:
            # Assembly integrity (#11) + behind-base auto-sync (B2).
            guard = await self._assembled_submit_guards(t, task_id, "submit_up")
        if guard is not None:
            guard.with_introspection(task=t, role=role_str)
            return await self._emit_rejection(
                guard,
                agent_id=pm_agent_id,
                task_id=task_id,
                verb="submit_up",
            )

        outcome = await self._submit_up_run_intent(
            t, agent, spec_ctx, briefing, role_str
        )
        if isinstance(outcome, Envelope):
            return await self._emit_rejection(
                outcome,
                agent_id=pm_agent_id,
                task_id=task_id,
                verb="submit_up",
            )
        t = outcome
        # Do NOT hand the cell task to Main PM. The cell PM owns cell
        # completion — it stays assigned to the cell PM, which is respawned to
        # `complete` the task (merging the cell→root PR). Main PM only
        # completes the ROOT (root→master + escalate-to-CEO).
        # `_maybe_advance_parent_to_pm_review` already keeps it on the cell PM.
        return Envelope.ok(
            status=str(t.status),
            task_id=str(task_id),
            next=spec_module._INTENT_VERBS["submit_up"].next_hint(t),
            context_briefing=briefing,
        ).with_introspection(task=t, role=role_str)

    async def _submit_up_run_intent(
        self,
        t: Any,
        agent: Any,
        spec_ctx: spec_module.Context,
        briefing: dict[str, Any],
        role_str: str,
    ) -> Any:
        """Dispatch the submit_up composition through VerbRunner.

        Returns the post-composition task on success, or an
        ``invalid_state`` Envelope when the runner raises or the
        underlying ``submit_pm_review`` returns ``None``.
        """
        runner = self._verb_runner()
        try:
            after = await runner.run_intent("submit_up", t, agent, spec_ctx)
        except Exception as exc:
            return Envelope.invalid_state(
                message=f"verb runner failed: {exc}",
                remediate="check workspace + retry; if persistent, escalate",
                context_briefing=briefing,
            ).with_introspection(task=t, role=role_str)
        if after is None:
            # The create_pr pre-side-effect already opened the cell→root PR
            # BEFORE submit_for_review ran (its pr_created gate requires it —
            # see lifecycle.py), so a None here means the task raced out of
            # in_progress AFTER the PR was opened: an orphaned PR sits on
            # GitHub whose task is not in awaiting_pr_review. Name the open
            # PR in the remediate so the PM can reconcile it — create_pr is
            # idempotent, so re-issuing once the task is back in_progress
            # re-attaches to the same PR rather than opening a duplicate.
            # Mirrors submit_root's F016 None-envelope remediate.
            return Envelope.invalid_state(
                message=(
                    "submit_up did not transition the task — the cell→root "
                    "PR was already opened or the task is no longer "
                    "in_progress."
                ),
                remediate=(
                    "the cell→root PR is already open (create_pr ran before "
                    "the transition). Re-fetch with evidence(task_id); if it "
                    "is awaiting_pr_review wait for the reviewer, otherwise "
                    "re-delegate the fixes and retry submit_up — create_pr "
                    "is idempotent and re-attaches to the existing PR."
                ),
                context_briefing=briefing,
            )
        return after

    async def _submit_up_guard(
        self, pm_agent_id: UUID, task_id: UUID, t: Any, notes: str
    ) -> Envelope | None:
        """Return a rejection Envelope if any submit_up precondition fails."""
        ownership = await self._submit_up_ownership_guard(
            pm_agent_id, task_id, t, notes
        )
        if ownership is not None:
            return ownership
        return await self._submit_up_state_guard(pm_agent_id, task_id, t, notes)

    async def _submit_up_ownership_guard(
        self, pm_agent_id: UUID, task_id: UUID, t: Any, notes: str
    ) -> Envelope | None:
        """Role + assignment + notes-length guards shared by submit_up + submit_root.

        Both callers run the spec gate (``can_invoke_intent``) first, which
        already restricts submit_up→cell_pm and submit_root→main_pm, so this
        guard must accept either PM role — a hardcoded cell_pm-only check would
        reject the Main PM's submit_root and deadlock root closure (submit_root
        and complete then point at each other). The check stays as a
        defense-in-depth reject of any non-PM actor that reaches here.
        """
        from roboco.config import settings as roboco_settings

        agent = await self.task.agent_for(pm_agent_id)
        if agent is None or agent.role not in ("cell_pm", "main_pm"):
            return Envelope.not_authorized(
                message="submit_up / submit_root are reserved for PM roles",
                remediate=(
                    "only a cell PM (submit_up) or main PM (submit_root)"
                    " may bubble work up"
                ),
                context_briefing=await self._briefing_for(pm_agent_id, task_id),
            )
        if t.assigned_to != pm_agent_id:
            return Envelope.not_authorized(
                message="not assigned to you",
                remediate="claim the task or wait for assignment",
                context_briefing=await self._briefing_for(pm_agent_id, task_id),
            )
        if not notes or len(notes) < roboco_settings.docs_notes_min_chars:
            return Envelope.tracing_gap(
                missing=["notes>=min"],
                remediate=(
                    "submit_up requires substantive notes describing the"
                    " cell's contribution (>= 20 chars)."
                ),
                context_briefing=await self._briefing_for(pm_agent_id, task_id),
            )
        return None

    async def _submit_up_state_guard(
        self, pm_agent_id: UUID, task_id: UUID, t: Any, notes: str
    ) -> Envelope | None:
        """Journal + subtask-closure + branch guards for submit_up.

        Tracing gates (journal:decision, journal:reflect, notes>=min,
        subtasks_terminal) are evaluated by ``_check_submit_up_gates``
        which consumes ``VERB_REQUIREMENTS["submit_up"]``. The
        ``_subtasks_not_terminal_envelope`` call is kept as a fallback
        because its remediation enumerates the non-terminal subtask ids
        — strictly richer than the foundation hint.
        """
        # Write-then-gate: the bubble-up rationale (notes, already validated
        # >= min by the ownership guard) becomes the journal:decision.
        await self._ensure_pm_decision(pm_agent_id, task_id, notes)
        if env := await self._check_submit_up_gates(pm_agent_id, task_id, notes):
            return env
        if env := await self._subtasks_not_terminal_envelope(
            pm_agent_id, task_id, context_phrase="bubbling up"
        ):
            return env
        if env := await self._parent_acs_covered_envelope(
            pm_agent_id, task_id, context_phrase="bubbling up"
        ):
            return env
        if not t.branch_name:
            return Envelope.invalid_state(
                message="task has no branch; cannot open cell-level PR",
                remediate="cell PMs must claim+plan their parent task first",
                context_briefing=await self._briefing_for(pm_agent_id, task_id),
            )
        return None

    async def pm_give_me_work(self, pm_agent_id: UUID) -> Envelope:
        """Return the PM's first assigned task in any active status, or idle.

        Mirrors the developer's give_me_work but does not filter to dev-only
        statuses — PMs care about all assigned tasks (planning, paused, in
        progress, awaiting_pm_review).

        Pre-assigned pending tasks are checked first.
        Smoke run 3 showed Main PM getting idle even though c7935d2c was
        pending and assigned_to=main-pm because list_assigned_for_agent
        ordered by priority/updated_at and could rank a pre-assigned pending
        task below other active rows; the pre-assigned pending check now
        wins unconditionally.
        """
        # Pre-assigned pending tasks take priority over everything else.
        pre_assigned = await self.task.list_pending_for_agent(pm_agent_id)
        if pre_assigned:
            t = pre_assigned[0]
            await self._touch(t.id)
            return Envelope.ok(
                status=str(t.status),
                task_id=str(t.id),
                next=self._pm_next_hint(str(t.status), t.id),
                context_briefing=await self._briefing_for(
                    pm_agent_id, t.id, task=t, full=True
                ),
            )
        assigned = await self.task.list_assigned_for_agent(pm_agent_id)
        if assigned:
            t = assigned[0]
            await self._touch(t.id)
            return Envelope.ok(
                status=str(t.status),
                task_id=str(t.id),
                next=self._pm_next_hint(str(t.status), t.id),
                context_briefing=await self._briefing_for(
                    pm_agent_id, t.id, task=t, full=True
                ),
            )
        return Envelope.ok(
            status="idle",
            task_id=None,
            next="no assigned work — call triage() or i_am_idle()",
            context_briefing=await self._briefing_for(pm_agent_id, None),
        )

    @staticmethod
    def _pm_next_hint(status: str, task_id: Any) -> str:
        """Compose a status-aware next hint for PMs."""
        if status == "pending":
            return f"call i_will_plan(task_id='{task_id}', plan='...') to start"
        if status == "paused":
            return (
                f"check subtasks; when terminal, call complete(task_id='{task_id}')"
                " or submit_up()"
            )
        if status == "blocked":
            return f"investigate then unblock(task_id='{task_id}')"
        if status == "awaiting_pm_review":
            return f"review and complete(task_id='{task_id}')"
        return f"act on task {task_id} — current status: {status}"

    async def triage(self, pm_agent_id: UUID) -> Envelope:
        """Cell PM triage: blocked > awaiting_pm_review > idle."""
        pm = await self.task.agent_for(pm_agent_id)
        blocked = await self.task.list_blocked_for_team(pm.team)
        if blocked:
            t = blocked[0]
            return Envelope.ok(
                status=str(t.status),
                task_id=str(t.id),
                next=f"investigate the block, then unblock(task_id='{t.id}')",
                context_briefing=await self._briefing_for(
                    pm_agent_id, t.id, task=t, full=True
                ),
            )
        awaiting = await self.task.list_awaiting_pm_review_for_team(pm.team)
        if awaiting:
            t = awaiting[0]
            return Envelope.ok(
                status=str(t.status),
                task_id=str(t.id),
                next=f"review and complete(task_id='{t.id}')",
                context_briefing=await self._briefing_for(
                    pm_agent_id, t.id, task=t, full=True
                ),
            )
        return Envelope.ok(
            status="idle",
            task_id=None,
            next="no PM work — call i_am_idle",
            context_briefing=await self._briefing_for(pm_agent_id, None),
        )

    async def triage_all(self, pm_agent_id: UUID) -> Envelope:
        """Main PM triage: across all teams. blocked > awaiting_pm_review > idle."""
        blocked = await self.task.list_blocked_all_teams()
        if blocked:
            t = blocked[0]
            return Envelope.ok(
                status=str(t.status),
                task_id=str(t.id),
                next=(
                    f"escalation/cross-cell help required: investigate, then "
                    f"unblock(task_id='{t.id}') or escalate_up()"
                ),
                context_briefing=await self._briefing_for(
                    pm_agent_id, t.id, task=t, full=True
                ),
            )
        awaiting = await self.task.list_awaiting_main_pm_all()
        if awaiting:
            t = awaiting[0]
            return Envelope.ok(
                status=str(t.status),
                task_id=str(t.id),
                next=f"complete(task_id='{t.id}') opens master PR + escalates to CEO",
                context_briefing=await self._briefing_for(
                    pm_agent_id, t.id, task=t, full=True
                ),
            )
        return Envelope.ok(
            status="idle",
            task_id=None,
            next="no Main PM work",
            context_briefing=await self._briefing_for(pm_agent_id, None),
        )

    async def unblock(
        self, pm_agent_id: UUID, task_id: UUID, reason: str, *, restore: bool = True
    ) -> Envelope:
        """PM unblocks task; ``reason`` documents why the block is cleared and
        is recorded as the journal:decision the gate requires. restore=True
        (default) returns to pre_block_state."""
        t = await self.task.get(task_id)
        if t is None:
            return await self._emit_rejection(
                Envelope.not_found(message=f"task {task_id} not found"),
                agent_id=pm_agent_id,
                task_id=task_id,
                verb="unblock",
            )
        agent = await self.task.agent_for(pm_agent_id)
        role = str(agent.role) if agent is not None else "cell_pm"
        if str(t.status) != "blocked":
            return await self._emit_rejection(
                Envelope.invalid_state(
                    message=f"task {task_id} is in {t.status}, expected blocked",
                    remediate=(
                        "this task is not blocked; call triage() to find blocked tasks"
                    ),
                    context_briefing=await self._briefing_for(pm_agent_id, task_id),
                ).with_introspection(task=t, role=role),
                agent_id=pm_agent_id,
                task_id=task_id,
                verb="unblock",
            )

        # A dependency block must not be cleared by hand. It auto-clears via
        # _unblock_dependents the moment its last dependency reaches a terminal
        # state; forcing it now would let the dependent proceed without the
        # upstream's work (e.g. a frontend task built before its UX design lands).
        dep_ids = list(t.dependency_ids or [])
        unmet = await self.task.unmet_dependency_ids(dep_ids) if dep_ids else []
        if unmet:
            return await self._emit_rejection(
                Envelope.invalid_state(
                    message=(
                        f"task {task_id} still depends on {len(unmet)} "
                        "unfinished task(s); a dependency block clears on its "
                        "own once the upstream work completes"
                    ),
                    remediate=(
                        "don't force this — let the dependency finish; the task "
                        "auto-unblocks the moment its last dependency reaches "
                        "completed/cancelled"
                    ),
                    context_briefing=await self._briefing_for(pm_agent_id, task_id),
                ).with_introspection(task=t, role=role),
                agent_id=pm_agent_id,
                task_id=task_id,
                verb="unblock",
            )

        # Write-then-gate: the PM's unblock reason is recorded as the
        # journal:decision the gate below requires, so a PM that didn't
        # pre-call note(scope='decision') doesn't stall in a tracing_gap
        # respawn loop.
        await self._ensure_pm_decision(pm_agent_id, task_id, reason)
        if env := await self._check_pm_decision_required(
            "unblock", pm_agent_id, task_id, t
        ):
            return await self._emit_rejection(
                env.with_introspection(task=t, role=role),
                agent_id=pm_agent_id,
                task_id=task_id,
                verb="unblock",
            )

        t = await self.task.unblock_with_restore(pm_agent_id, task_id, restore=restore)
        next_msg = (
            "task restored to its pre-block state — original assignee will resume"
            if restore
            else "task back to in_progress; you'll need to re-engage the workflow"
        )
        return Envelope.ok(
            status=str(t.status),
            task_id=str(task_id),
            next=next_msg,
            context_briefing=await self._briefing_for(pm_agent_id, task_id),
        ).with_introspection(task=t, role=role)

    async def _own_review_hint(self, pm_agent_id: UUID, exclude_task_id: UUID) -> str:
        """Remediate suffix naming the PM's OWN task ready to complete.

        A PM looped firing complete/unblock at the
        wrong (parent) task_id while its own leaf sat at
        ``awaiting_pm_review``, never named in any rejection — minimax
        never found the one correct call. Surface it explicitly.
        Best-effort: never raises into the rejection path.
        """
        try:
            owned = await self.task.list_by_assignee(pm_agent_id)
        except Exception:
            return ""
        ready = [
            str(o.id)
            for o in owned
            if str(o.status) == "awaiting_pm_review" and o.id != exclude_task_id
        ]
        if not ready:
            return ""
        tid = ready[0]
        return (
            f" You OWN task {tid} which is awaiting_pm_review and ready to "
            f"finish — call complete(task_id='{tid}', notes='...') on THAT "
            "task, not this one."
        )

    async def _cell_pm_complete_guard(
        self, pm_agent_id: UUID, task_id: UUID, t: Any, notes: str
    ) -> Envelope | None:
        """Return a rejection Envelope if pre-merge guards fail; else None.

        Tracing gates (journal:decision, journal:reflect, notes>=min) are
        evaluated by ``_check_complete_gates`` which consumes
        ``VERB_REQUIREMENTS["complete"]``. The
        ``_subtasks_not_terminal_envelope`` call is kept inline because
        its remediation enumerates the non-terminal subtask ids — strictly
        richer than the foundation hint.
        """
        if t.assigned_to != pm_agent_id:
            return Envelope.not_authorized(
                message="not assigned to you",
                remediate=(
                    "claim the task or wait for it to be assigned."
                    + await self._own_review_hint(pm_agent_id, task_id)
                ),
                context_briefing=await self._briefing_for(pm_agent_id, task_id),
            )
        if str(t.status) != "awaiting_pm_review":
            # An in_progress cell task must enter the in-path gate first:
            # submit_up opens the cell→root PR and moves it to
            # awaiting_pr_review, then the cell reviewer pr_passes it to
            # awaiting_pm_review where complete merges. Name the verb so the
            # cell PM isn't left guessing (the parallel of the main PM's
            # submit_root steer).
            gate_hint = (
                "open the cell→root PR and enter review first:"
                " submit_up(task_id, notes='...'). After the cell reviewer"
                " pr_passes it, complete merges the cell→root PR."
                if str(t.status) == "in_progress"
                else "this task is not ready for completion."
            )
            return Envelope.invalid_state(
                message=(
                    f"task {task_id} is in {t.status}, expected awaiting_pm_review"
                ),
                remediate=(
                    gate_hint + await self._own_review_hint(pm_agent_id, task_id)
                ),
                context_briefing=await self._briefing_for(pm_agent_id, task_id),
            )
        # Write-then-gate: the PM's merge rationale (notes) becomes the
        # journal:decision the gate requires — no separate note() call needed.
        await self._ensure_pm_decision(pm_agent_id, task_id, notes)
        if env := await self._check_complete_gates(pm_agent_id, task_id, notes):
            return env
        if env := (
            await self._subtasks_not_terminal_envelope(
                pm_agent_id, task_id, context_phrase="completing parent"
            )
            or await self._parent_acs_covered_envelope(
                pm_agent_id, task_id, context_phrase="completing parent"
            )
        ):
            return env
        if t.pr_number is None:
            return Envelope.invalid_state(
                message="task has no PR; cannot merge",
                remediate=(
                    "this state should not occur post-Phase-1;"
                    " investigate dev's i_am_done path"
                ),
                context_briefing=await self._briefing_for(pm_agent_id, task_id),
            )
        return None

    async def _stamp_pm_findings_verified_or_rejection(
        self, t: Any, *, agent_id: UUID, task_id: UUID, role_str: str, verb: str
    ) -> Envelope | None:
        """PM merge's same-transaction verified-stamp for pm-origin addressed
        findings — parity with ``QAMixin.pass_review`` / ``PRGateMixin.pr_pass``:
        the merge IS the confirmation that a PM's own ``request_changes``
        findings on this root were resolved. Not best-effort — a repo error
        here fails the merge cleanly (returned as a rejection) rather than
        landing a completed task against a stale ledger.
        """
        try:
            await findings_lib.stamp_addressed_verified(
                self.task.session, t.id, origin="pm"
            )
        except Exception as exc:
            return await self._emit_rejection(
                Envelope.invalid_state(
                    message=f"verified-stamp failed: {exc}",
                    remediate="retry complete; if persistent, escalate to the CEO",
                    context_briefing=await self._briefing_for(agent_id, task_id),
                ).with_introspection(task=t, role=role_str),
                agent_id=agent_id,
                task_id=task_id,
                verb=verb,
            )
        return None

    async def cell_pm_complete(
        self, pm_agent_id: UUID, task_id: UUID, notes: str
    ) -> Envelope:
        """Cell PM completes a task — auto-merges leaf PR into parent branch.

        Idempotent: if the PR is already merged to the target on GitHub,
        skip the merge call and proceed straight to the transition. This
        closes the H7 respawn loop where a re-issue after a None-complete
        re-called pr_merge on an already-merged PR (405) and the remediate
        hint told the agent to "re-issue pr_merge" which could never
        succeed.
        """
        t = await self.task.get(task_id)
        if t is None:
            return await self._emit_rejection(
                Envelope.not_found(message=f"task {task_id} not found"),
                agent_id=pm_agent_id,
                task_id=task_id,
                verb="cell_pm_complete",
            )
        guard = await self._cell_pm_complete_guard(pm_agent_id, task_id, t, notes)
        if guard is not None:
            guard.with_introspection(task=t, role="cell_pm")
            return await self._emit_rejection(
                guard,
                agent_id=pm_agent_id,
                task_id=task_id,
                verb="cell_pm_complete",
            )
        if stamp_rejection := await self._stamp_pm_findings_verified_or_rejection(
            t,
            agent_id=pm_agent_id,
            task_id=task_id,
            role_str="cell_pm",
            verb="cell_pm_complete",
        ):
            return stamp_rejection
        # Resolve the merge target from the PARENT task's real
        # branch_name. For a leaf this is the cell branch (same team — no
        # change); for a cell task it is the root branch (feature/main_pm/…),
        # which parent_branch_for would have mis-derived as feature/<cellteam>/…
        target = await resolve_parent_branch(t, self.task)
        # H7: idempotent pre-check. If the PR is already merged to the
        # target on GitHub, skip the GitHub merge call — a re-issue after
        # a None-complete would otherwise 405 on the already-merged PR and
        # respawn the PM forever against a remediate hint that can't succeed.
        merge_commit: str | None = None
        if t.pr_number:
            already_merged = await self.git.is_pr_merged_for_task(task_id)
            if not already_merged:
                try:
                    merge_result = await self.git.pr_merge(
                        t.pr_number,
                        target=target,
                        project_id=cast("UUID", t.project_id),
                        actor_agent_id=pm_agent_id,
                    )
                    merge_commit = merge_result.get("merge_commit_sha")
                except MergeConflictError as exc:
                    # A sibling landed overlapping work first, so this PR can't
                    # merge. Resolve it (rebase / close-superseded / escalate)
                    # instead of letting the failure re-block the task and
                    # respawn the PM forever.
                    return await self._resolve_merge_conflict_on_complete(
                        pm_agent_id, task_id, t, target, notes, exc
                    )
        return await self._finalize_cell_complete(
            pm_agent_id, task_id, t, notes, merge_commit
        )

    async def _finalize_cell_complete(
        self,
        pm_agent_id: UUID,
        task_id: UUID,
        t: Any,
        notes: str,
        merge_commit: str | None,
    ) -> Envelope:
        """Mark the leaf completed and propagate the completion to its parent."""
        leaf_parent_id = t.parent_task_id
        leaf_team = t.team
        t = await self.task.cell_pm_complete(
            pm_agent_id,
            task_id,
            notes,
            merge_commit=merge_commit,
        )
        # `complete()` returns None when its prerequisites fail — most
        # notably the PR-merged guard (`work_session.pr_status == "merged"`),
        # which is exactly what breaks when the merge recorded against the
        # WRONG task's work session (the cross-repo pr_number collision that
        # `pr_merge`'s project_id scoping now prevents). Fail closed into a
        # clean invalid_state envelope instead of dereferencing None and
        # 500-ing, which left the be-pm thrashing escalate<->blocked.
        if t is None:
            return Envelope.invalid_state(
                message=(
                    "task could not be completed: its PR is not recorded as "
                    "merged against this task's work session, or it has "
                    "incomplete subtasks / an invalid completion status"
                ),
                remediate=(
                    "re-issue pr_merge for this task's PR, then complete; if "
                    "the PR is genuinely merged on GitHub but the work session "
                    "still shows open, escalate to the CEO to reconcile the "
                    "session and complete manually"
                ),
                context_briefing=await self._briefing_for(pm_agent_id, task_id),
            )
        # Now that the leaf is completed, propagate the completion up to the
        # parent task: if the parent's subtasks are all terminal, hand the
        # parent off to the cell_pm for that team so it gets respawned for
        # the next stage (cell-PM PR merge or main-PM hand-off).
        warning: str | None = None
        if leaf_parent_id is not None:
            try:
                await self._maybe_advance_parent_to_pm_review(leaf_parent_id, leaf_team)
            except Exception as exc:
                logger.warning(
                    "cell_pm_complete side-effect failed - leaf completed, "
                    "parent advance did not fire",
                    task_id=str(task_id),
                    parent_task_id=str(leaf_parent_id),
                    error=str(exc),
                )
                warning = (
                    f"Leaf completed but advancing the parent to PM review "
                    f"failed ({exc}). The parent will be re-advanced on the "
                    f"next dispatch tick."
                )
        env = Envelope.ok(
            status=str(t.status),
            task_id=str(task_id),
            next=spec_module._INTENT_VERBS["complete"].next_hint(t),
            context_briefing=await self._briefing_for(pm_agent_id, task_id),
        ).with_introspection(task=t, role="cell_pm")
        if warning:
            env.warning = warning
        return env

    async def _resolve_merge_conflict_on_complete(
        self,
        pm_agent_id: UUID,
        task_id: UUID,
        t: Any,
        target: str,
        notes: str,
        exc: MergeConflictError,
    ) -> Envelope:
        """Resolve a leaf PR that couldn't merge because a sibling landed first.

        Rebase the branch onto the current base and act on the outcome rather
        than failing (which re-blocks the task and respawns the PM forever):

        - ``rebased``     — the branch now integrates cleanly; retry the merge.
        - ``superseded``  — every change is already in the base via the sibling;
          close the dead PR and complete the task without a redundant merge.
        - ``conflicts`` / ``unknown`` — a human must resolve; escalate to the
          CEO (``awaiting_ceo_approval``) so the task leaves the agent loop.
        """
        rebase = await self.git.rebase_pr_for_task(
            t.pr_number,
            project_id=cast("UUID", t.project_id),
            actor_agent_id=pm_agent_id,
        )
        status = rebase.get("status")
        if status == "rebased":
            merge_result = await self.git.pr_merge(
                t.pr_number,
                target=target,
                project_id=cast("UUID", t.project_id),
                actor_agent_id=pm_agent_id,
            )
            return await self._finalize_cell_complete(
                pm_agent_id, task_id, t, notes, merge_result.get("merge_commit_sha")
            )
        if status == "superseded":
            await self.git.close_pull_request(
                t.pr_number,
                comment=(
                    "Closed as superseded: every change on this branch is "
                    "already present in the base via a sibling PR that merged "
                    "first. Completing the task without a redundant merge."
                ),
                # Preserve the superseded branch (audit / reference), matching
                # the orchestrator supersede path. close_pull_request defaults
                # to non-destructive now; explicit here so the two paths agree.
                delete_branch=False,
                actor_agent_id=pm_agent_id,
                project_id=cast("UUID", t.project_id),
            )
            return await self._finalize_cell_complete(
                pm_agent_id, task_id, t, notes, None
            )
        return await self._escalate_merge_conflict_to_ceo(
            pm_agent_id, task_id, t, rebase, exc
        )

    async def _escalate_merge_conflict_to_ceo(
        self,
        pm_agent_id: UUID,
        task_id: UUID,
        t: Any,
        rebase: dict[str, Any],
        exc: MergeConflictError,
    ) -> Envelope:
        """Route an unresolvable PR conflict to the CEO; never loop on it.

        Moves the task to ``awaiting_ceo_approval`` (admin override — the leaf
        has no in-band edge there) so it leaves agent dispatch, and best-effort
        alerts the CEO with the conflicting files.
        """
        from roboco.models.base import TaskStatus

        files = rebase.get("files") or []
        logger.info(
            "merge conflict escalated to CEO",
            task_id=str(task_id),
            conflicting_files=len(files),
            rebase_status=rebase.get("status"),
            merge_error=str(exc),
        )
        await self.task.admin_set_status(
            task_id,
            TaskStatus.AWAITING_CEO_APPROVAL,
            actor_id=pm_agent_id,
            actor_role="cell_pm",
        )
        await self._notify_ceo_merge_conflict(task_id, files)
        t = await self.task.get(task_id)
        detail = f" ({len(files)} conflicting file(s))" if files else ""
        return Envelope.ok(
            status=str(t.status),
            task_id=str(task_id),
            next=(
                "the PR has merge conflicts that could not be resolved "
                f"automatically{detail}; escalated to the CEO. A developer can "
                "rebase the branch, or the CEO can close the PR if superseded."
            ),
            context_briefing=await self._briefing_for(pm_agent_id, task_id),
        ).with_introspection(task=t, role="cell_pm")

    async def _notify_ceo_merge_conflict(self, task_id: UUID, files: list[str]) -> None:
        """Best-effort CEO alert for a wedged merge conflict; never raises."""
        from roboco.services.notification import NotificationService

        try:
            await NotificationService().send_stuck_agent_notification(
                task_id=str(task_id),
                agent_slug="cell_pm",
                task_status="awaiting_ceo_approval",
                to_agent="ceo",
            )
        except Exception:
            logger.warning(
                "failed to send CEO merge-conflict notification",
                task_id=str(task_id),
                conflicting_files=len(files),
            )

    async def _maybe_advance_parent_to_pm_review(
        self, parent_task_id: UUID, leaf_team: Any
    ) -> None:
        """Promote a parent task to awaiting_pm_review once all subtasks finish.

        Walks up from the just-completed leaf. If every direct subtask of
        the parent is terminal, set the parent's ``assigned_to`` to the cell
        PM for the parent's team so the orchestrator spawns the right PM
        for the next stage (review/merge/escalate). Status itself is left
        untouched here — the cell PM transitions it via ``complete``.
        """
        parent = await self.task.get(parent_task_id)
        if parent is None:
            return
        all_terminal = await self.task.all_subtasks_terminal(parent_task_id)
        if not all_terminal:
            return
        team = parent.team or leaf_team
        if team is None:
            return
        pm_agent = await self.task.cell_pm_for_team(team)
        if pm_agent is None:
            return
        await self.task.reassign(parent_task_id, pm_agent.id)

    def _submit_root_preflight(
        self, t: Any, role_str: str, briefing: dict[str, Any]
    ) -> Envelope | None:
        """Reject a submit_root that can never assemble a root→master PR.

        Two early refusals collapse here to keep ``submit_root`` within the
        return-count budget. A MegaTask umbrella assembles no PR of its own — it
        spans many projects (no single master) and each root-subtask opens its
        own PR — so it must never enter the in-path review gate; the Main PM
        completes the umbrella directly once every root-subtask is terminal. And
        an actor whose role is not in the lifecycle spec cannot drive the verb.
        Returns the rejection Envelope (caller adds introspection + emits) or
        None when neither refusal applies.
        """
        if is_batch_umbrella(batch_id=t.batch_id, parent_task_id=t.parent_task_id):
            return Envelope.invalid_state(
                message="a MegaTask umbrella assembles no PR of its own",
                remediate=(
                    "the umbrella spans many projects — each root-subtask opens"
                    " its own PR and is reviewed on its own diff. Do not"
                    " submit_root the umbrella; once every root-subtask is"
                    " terminal, call complete(task_id, notes='...') to escalate"
                    " the MegaTask to the CEO."
                ),
                context_briefing=briefing,
            )
        try:
            spec_module.Role(role_str)
        except ValueError:
            return Envelope.not_authorized(
                message=f"unknown role '{role_str}'",
                remediate="role is not declared in the lifecycle spec",
                context_briefing=briefing,
            )
        return None

    async def _submit_root_unchanged_pr_guard(
        self, t: Any, briefing: dict[str, Any]
    ) -> Envelope | None:
        """Refuse to re-submit a root PR whose diff is unchanged since the last fail.

        The hard loop-stopper for the 2026-06-27 ``pr_fail`` re-submit loop. A
        prior ``pr_fail`` stamped the assembled PR's head SHA into
        ``notes_structured.pr_review.head_sha`` (see ``_capture_pr_head_sha`` /
        ``_record_gate_verdict``). On the next ``submit_root`` this looks up the
        PR's CURRENT head SHA and compares. Equal ⇒ no new cell work landed on
        the root branch since the fail ⇒ the diff the reviewer would see is
        byte-identical to the one just rejected ⇒ refuse, so no model can loop
        itself back into ``awaiting_pr_review``. Different ⇒ the branch advanced
        ⇒ allow. Returns ``None`` (proceed) on every ambiguous case so the gate
        FAILS OPEN: no prior ``pr_fail`` verdict, no recorded ``head_sha`` (e.g.
        a verdict written before this field existed), no ``pr_number``, no
        resolvable project, or a git/closed-PR lookup that returns ``None``.
        Only the exact-unchanged case is hard-blocked; the rest fall through to
        the reviewer, who can still ``pr_fail`` if the diff is bad.
        """
        pr_review = (getattr(t, "notes_structured", None) or {}).get("pr_review") or {}
        if pr_review.get("verdict") != "failed":
            return None
        recorded = pr_review.get("head_sha")
        if not recorded:
            return None
        current = await self._current_pr_head_sha(t)
        if current is None or current != recorded:
            return None
        return Envelope.invalid_state(
            message=(
                "the assembled root PR is unchanged since the last pr_fail"
                f" (head {current[:7]}). No new cell work has landed on the"
                " root branch, so re-submitting would re-open the exact diff"
                " the reviewer just rejected and loop straight back to"
                " awaiting_pr_review."
            ),
            remediate=(
                "re-delegate the fixes to the owning cell PM(s) via"
                " delegate(...) and wait for the cell subtasks to complete"
                " and the root branch to be re-assembled. Do NOT call"
                " submit_root again until new cell work has advanced the"
                " root branch HEAD."
            ),
            context_briefing=briefing,
        )

    async def _current_pr_head_sha(self, t: Any) -> str | None:
        """Best-effort current head SHA of the task's assembled PR (fail-open).

        The lookup both unchanged-PR gates (submit_root + submit_up)
        compare against — ``pr_fail`` stamps the head SHA for cell AND root
        gate tasks alike (the capture is gate-verb-level, not root-level), so
        one resolver serves both. Returns ``None`` on every ambiguous case (no
        ``pr_number``, no resolvable project slug, a git error, or a
        closed/missing PR) so the gate fails open rather than wedging the PM
        — only the exact-unchanged case is hard-blocked.
        """
        pr_number = getattr(t, "pr_number", None)
        if not pr_number:
            return None
        try:
            # ``_LegacyChoreographer`` doesn't inherit ``ChoreographerHelpers``,
            # so cast to the typed view the mixins use to reach the shared
            # ``_project_slug_for`` resolver (it delegates to the module-level
            # ``resolve_task_project_slug``). Same resolver the pr_fail capture
            # path uses, so one stub controls both gate paths.
            slug = await cast("ChoreographerHelpers", self)._project_slug_for(t)
            if not slug:
                return None
            sha = await self.git.get_pr_head_sha(slug, int(pr_number))
            return sha if isinstance(sha, str) else None
        except Exception as exc:
            # Fail-open (never wedge the PM on a lookup error), but log so a
            # regression in the slug resolver / git helper doesn't silently
            # turn the pr_fail re-submit loop-stopper into a no-op (#5).
            logger.warning(
                "unchanged_pr_guard head_sha lookup failed (fail-open)",
                pr_number=pr_number,
                error=str(exc),
            )
            return None

    async def _submit_up_unchanged_pr_guard(
        self, t: Any, briefing: dict[str, Any]
    ) -> Envelope | None:
        """Cell-level analogue of ``_submit_root_unchanged_pr_guard``.

        Refuses re-submit when the cell PR's current head SHA equals the SHA
        ``pr_fail`` recorded in ``notes_structured.pr_review.head_sha`` (no new
        dev work landed ⇒ byte-identical diff). Ambiguous cases FAIL OPEN via
        ``_current_pr_head_sha``; only the exact-unchanged case is hard-blocked.
        """
        pr_review = (getattr(t, "notes_structured", None) or {}).get("pr_review") or {}
        if pr_review.get("verdict") != "failed":
            return None
        recorded = pr_review.get("head_sha")
        if not recorded:
            return None
        current = await self._current_pr_head_sha(t)
        if current is None or current != recorded:
            return None
        return Envelope.invalid_state(
            message=(
                "the assembled cell PR is unchanged since the last pr_fail"
                f" (head {current[:7]}). No new dev work has landed on the"
                " cell branch, so re-submitting would re-open the exact diff"
                " the reviewer just rejected and loop straight back to"
                " awaiting_pr_review."
            ),
            remediate=(
                "re-delegate the fixes to the owning developer(s) via"
                " delegate(...) and wait for the dev subtasks to complete and"
                " the cell branch to be re-assembled. Do NOT call submit_up"
                " again until new dev work has advanced the cell branch HEAD."
            ),
            context_briefing=briefing,
        )

    async def submit_root(
        self,
        main_pm_agent_id: UUID,
        task_id: UUID,
        notes: str,
        resolved_findings: list[dict[str, Any]] | None = None,
    ) -> Envelope:
        """Main PM opens the root→master PR and enters the in-path review gate.

        The root analogue of the cell PM's submit_up: the spec gate (main_pm +
        in_progress) runs first, then the same preflight guard (ownership, notes
        length, journal:decision, subtasks-terminal, branch present, and every
        open pr_gate/pm/ceo-origin ledger finding via FINDINGS_ADDRESSED).
        ``resolved_findings`` ({finding_id, commit?, note?} entries) marks
        ledger rows addressed BEFORE that gate runs (mirrors ``i_am_done`` /
        ``submit_up``). Then ``VerbRunner.run_intent("submit_root", ...)``
        opens the root→master PR (the ``create_root_pr`` pre-side-effect) and
        transitions in_progress → awaiting_pr_review. The main reviewer then
        reviews the assembled root→master diff; after pr_pass, ``complete``
        escalates to the CEO.
        """
        t = await self.task.get(task_id)
        briefing = await self._briefing_for(
            main_pm_agent_id, task_id, task=t, include_ac_coverage=True
        )
        if t is None:
            return await self._emit_rejection(
                Envelope.not_found(message=f"task {task_id} not found"),
                agent_id=main_pm_agent_id,
                task_id=task_id,
                verb="submit_root",
            )
        agent = await self.task.agent_for(main_pm_agent_id)
        role_str = str(agent.role) if agent is not None else "main_pm"
        # Hard-reject an umbrella (no PR of its own) or an unknown role before the
        # spec gate; the preflight collapses both into one Envelope-or-None.
        if env := self._submit_root_preflight(t, role_str, briefing):
            return await self._emit_rejection(
                env.with_introspection(task=t, role=role_str),
                agent_id=main_pm_agent_id,
                task_id=task_id,
                verb="submit_root",
            )
        role = spec_module.Role(role_str)
        actor_slug, agent_team = actor_context_fields(agent)
        spec_ctx = spec_module.Context(
            actor_id=main_pm_agent_id,
            actor_slug=actor_slug,
            agent_team=agent_team,
            notes=notes,
        )
        soup = self._free_text_soup(checks=(("notes", notes, 10),))
        decision = spec_module.can_invoke_intent(role, "submit_root", t, spec_ctx)
        if env := self._soup_or_decision_env(soup, decision, briefing):
            return await self._emit_rejection(
                env.with_introspection(task=t, role=role_str),
                agent_id=main_pm_agent_id,
                task_id=task_id,
                verb="submit_root",
            )
        # Write-then-gate: resolve findings before the guard's
        # FINDINGS_ADDRESSED check re-reads open_finding_ids. Owner-gated:
        # a stale non-owner PM's resolutions must not mutate the ledger —
        # the ownership guard below rejects it, but the session still commits.
        if t.assigned_to == main_pm_agent_id:
            await self._apply_resolved_findings_for(task_id, resolved_findings)
        # Same preflight as submit_up — ownership + notes-length +
        # journal:decision + subtasks-terminal + branch-present. submit_root's
        # tracing requirements mirror submit_up's, so the shared guard applies.
        guard = await self._submit_up_guard(main_pm_agent_id, task_id, t, notes)
        # Hard unchanged-PR gate (the 2026-06-27 pr_fail re-submit loop-stopper).
        # A hint (the pr_fail a2a / next-hint steer "do NOT re-submit") is ignored
        # by a weak coordinator (minimax-m3 re-submitted PR #139 byte-identical),
        # so this refuses the re-submit STRUCTURALLY: if the last pr_fail stamped
        # the assembled PR's head SHA and the current PR head SHA is the same, no
        # new cell work has landed on the root branch since the fail — re-running
        # submit_root would re-open / re-push the exact diff the reviewer just
        # rejected, looping straight back to awaiting_pr_review → pr_fail. Force
        # the PM to re-delegate the fixes and wait for re-assembly instead.
        # Fail-open on ANY ambiguity (no prior fail, no recorded sha, no
        # resolvable project, git error, closed/missing PR) — only the
        # exact-unchanged case is hard-blocked; everything else proceeds and
        # relies on the reviewer to re-fail if the diff is still bad.
        if guard is None:
            guard = await self._submit_root_unchanged_pr_guard(t, briefing)
        if guard is None:
            # Assembly integrity (#11) + behind-base auto-sync (B2).
            guard = await self._assembled_submit_guards(t, task_id, "submit_root")
        if guard is not None:
            guard.with_introspection(task=t, role=role_str)
            return await self._emit_rejection(
                guard,
                agent_id=main_pm_agent_id,
                task_id=task_id,
                verb="submit_root",
            )
        runner = self._verb_runner()
        try:
            t = await runner.run_intent("submit_root", t, agent, spec_ctx)
        except Exception as exc:
            return await self._emit_rejection(
                Envelope.invalid_state(
                    message=f"verb runner failed: {exc}",
                    remediate=(
                        "ensure all subtasks are terminal + the branch exists,"
                        " then retry submit_root"
                    ),
                    context_briefing=briefing,
                ).with_introspection(task=t, role=role_str),
                agent_id=main_pm_agent_id,
                task_id=task_id,
                verb="submit_root",
            )
        # submit_for_review returns None when the root->master PR was already
        # opened (race out of in_progress / prior call). The PR exists but the
        # transition did not — guard the None deref with an invalid_state so
        # the PM re-fetches and reconciles instead of crashing.
        return await self._submit_root_finalize(
            main_pm_agent_id, task_id, t, role_str, briefing
        )

    async def _submit_root_finalize(
        self,
        main_pm_agent_id: UUID,
        task_id: UUID,
        t: Any,
        role_str: str,
        briefing: dict[str, Any],
    ) -> Envelope:
        """Build the submit_root result envelope after the verb runner returns.

        ``None`` → invalid_state rejection (the root->master PR was
        already opened / the task raced out of in_progress); otherwise the
        success envelope keyed off the post-transition status.
        """
        if t is None:
            return await self._emit_rejection(
                Envelope.invalid_state(
                    message=(
                        "submit_root did not transition the task — the "
                        "root->master PR was already opened or the task is no "
                        "longer in_progress."
                    ),
                    remediate=(
                        "re-fetch with evidence(task_id); if it is "
                        "awaiting_pr_review the PR is already open — wait for "
                        "the reviewer; otherwise re-delegate the fixes and "
                        "retry submit_root."
                    ),
                    context_briefing=briefing,
                ),
                agent_id=main_pm_agent_id,
                task_id=task_id,
                verb="submit_root",
            )
        return Envelope.ok(
            status=str(t.status),
            task_id=str(task_id),
            next=spec_module._INTENT_VERBS["submit_root"].next_hint(t),
            context_briefing=briefing,
        ).with_introspection(task=t, role=role_str)

    async def _main_pm_complete_guard(
        self, main_pm_agent_id: UUID, root_task_id: UUID, t: Any, notes: str
    ) -> Envelope | None:
        """Return a rejection Envelope if pre-escalation guards fail; else None.

        Tracing gates (journal:decision, journal:reflect, notes>=min) are
        evaluated by ``_check_complete_gates`` which consumes
        ``VERB_REQUIREMENTS["complete"]``. The
        ``_subtasks_not_terminal_envelope`` call is kept inline because
        its remediation enumerates the non-terminal subtask ids — strictly
        richer than the foundation hint.
        """
        if t.assigned_to != main_pm_agent_id:
            return Envelope.not_authorized(
                message="not assigned to you",
                remediate=(
                    "wait for assignment or claim."
                    + await self._own_review_hint(main_pm_agent_id, root_task_id)
                ),
                context_briefing=await self._briefing_for(
                    main_pm_agent_id, root_task_id
                ),
            )
        # A branch-bearing root must pass the in-path PR-review gate first:
        # submit_root opens the root→master PR and moves it in_progress →
        # awaiting_pr_review, then the main reviewer pr_passes it to
        # awaiting_pm_review. So complete accepts only awaiting_pm_review for a
        # branch-bearing root (a Main-PM root-subtask is planning-typed, never
        # code, but it still assembles the cells' merged work into a real PR).
        # A branchless coordination root (product fan-out, no repo/PR) skips the
        # gate, so it may still be walked from in_progress here. The split is
        # branch-keyed, not task_type-keyed.
        root_is_branchless = not bool(t.branch_name)
        allowed_statuses = (
            ("awaiting_pm_review", "in_progress")
            if root_is_branchless
            else ("awaiting_pm_review",)
        )
        if str(t.status) not in allowed_statuses:
            gate_hint = (
                "this task is not ready for main-PM completion."
                if root_is_branchless
                else (
                    "open the root→master PR and enter review first:"
                    " submit_root(task_id, notes='...'). After the main reviewer"
                    " pr_passes it, complete escalates to the CEO."
                )
            )
            return Envelope.invalid_state(
                message=(
                    f"task {root_task_id} is in {t.status}, expected"
                    f" {' or '.join(allowed_statuses)}"
                ),
                remediate=(
                    gate_hint
                    + await self._own_review_hint(main_pm_agent_id, root_task_id)
                ),
                context_briefing=await self._briefing_for(
                    main_pm_agent_id, root_task_id
                ),
            )
        # A MegaTask root-subtask IS parented (the umbrella) yet carries its
        # own project/branch/PR and behaves as a root for git/CEO purposes
        # (is_batch_root_subtask) — only a genuine non-root subtask is refused
        # here; the umbrella's exemption elsewhere (is_batch_umbrella) is a
        # different, non-overlapping shape (batch_id set, no parent).
        if t.parent_task_id is not None and not is_batch_root_subtask(
            batch_id=t.batch_id, parent_task_id=t.parent_task_id
        ):
            return Envelope.invalid_state(
                message=(
                    "main_pm complete only operates on root tasks (no parent_task_id)"
                ),
                remediate=(
                    "cell PM should complete this task; main PM only"
                    " completes root tasks."
                    + await self._own_review_hint(main_pm_agent_id, root_task_id)
                ),
                context_briefing=await self._briefing_for(
                    main_pm_agent_id, root_task_id
                ),
            )
        # Write-then-gate: the Main PM's root-close rationale (notes) becomes
        # the journal:decision the gate requires.
        await self._ensure_pm_decision(main_pm_agent_id, root_task_id, notes)
        if env := await self._check_complete_gates(
            main_pm_agent_id, root_task_id, notes
        ):
            return env
        if env := (
            await self._subtasks_not_terminal_envelope(
                main_pm_agent_id, root_task_id, context_phrase="escalating to CEO"
            )
            or await self._parent_acs_covered_envelope(
                main_pm_agent_id, root_task_id, context_phrase="escalating to CEO"
            )
        ):
            return env
        return None

    async def main_pm_complete(
        self, main_pm_agent_id: UUID, root_task_id: UUID, notes: str
    ) -> Envelope:
        """Main PM completes a root task; opens master PR + escalates to CEO."""
        t = await self.task.get(root_task_id)
        if t is None:
            return await self._emit_rejection(
                Envelope.not_found(message=f"task {root_task_id} not found"),
                agent_id=main_pm_agent_id,
                task_id=root_task_id,
                verb="main_pm_complete",
            )
        guard = await self._main_pm_complete_guard(
            main_pm_agent_id, root_task_id, t, notes
        )
        if guard is not None:
            guard.with_introspection(task=t, role="main_pm")
            return await self._emit_rejection(
                guard,
                agent_id=main_pm_agent_id,
                task_id=root_task_id,
                verb="main_pm_complete",
            )
        if stamp_rejection := await self._stamp_pm_findings_verified_or_rejection(
            t,
            agent_id=main_pm_agent_id,
            task_id=root_task_id,
            role_str="main_pm",
            verb="main_pm_complete",
        ):
            return stamp_rejection

        # A branchless coordination root (product fan-out, no repo/PR) skips the
        # in-path gate: it never went through submit_root / pr_pass, so walk it
        # in_progress → awaiting_pm_review here, exactly as before. A code root
        # reaches this point already in awaiting_pm_review — submit_root opened
        # the root→master PR and the main reviewer pr_passed it — so no PR
        # creation or status walk is needed.
        if not bool(t.branch_name):
            refreshed = await self.task.get(root_task_id)
            if refreshed is not None and str(refreshed.status) == "in_progress":
                advanced = await self.task.submit_pm_review(
                    main_pm_agent_id, root_task_id, notes
                )
                if advanced is None:
                    return await self._emit_rejection(
                        Envelope.invalid_state(
                            message=(
                                "could not move coordination root to"
                                " awaiting_pm_review for CEO escalation"
                            ),
                            remediate=(
                                "ensure all subtasks are terminal, then retry complete"
                            ),
                            context_briefing=await self._briefing_for(
                                main_pm_agent_id, root_task_id
                            ),
                        ).with_introspection(task=refreshed, role="main_pm"),
                        agent_id=main_pm_agent_id,
                        task_id=root_task_id,
                        verb="main_pm_complete",
                    )

        # Use kwargs — service signature is (task_id, agent_role="cell_pm",
        # notes=None). Positional was passing agent_id as task_id and the
        # actual task_id as agent_role. Forward the main PM's UUID so the
        # awaiting_ceo_approval audit row attributes to the specific PM.
        t = await self.task.escalate_to_ceo(
            task_id=root_task_id,
            agent_role="main_pm",
            notes=notes,
            actor_agent_id=main_pm_agent_id,
        )
        # Defense-in-depth: escalate_to_ceo returns None when it refuses (e.g. a
        # transition guard rejects). Surface that as a clean rejection instead of
        # dereferencing None below.
        if t is None:
            return await self._emit_rejection(
                Envelope.invalid_state(
                    message="escalate_to_ceo did not apply",
                    remediate=(
                        "ensure the root is in awaiting_pm_review with all"
                        " subtasks terminal, then retry complete"
                    ),
                    context_briefing=await self._briefing_for(
                        main_pm_agent_id, root_task_id
                    ),
                ),
                agent_id=main_pm_agent_id,
                task_id=root_task_id,
                verb="main_pm_complete",
            )
        # CEO acts via the UI, not as an agent the orchestrator spawns. Clear
        # ``assigned_to`` so no agent gets respawned to chase this task while
        # it sits in awaiting_ceo_approval.
        await self.task.reassign(root_task_id, None)
        return Envelope.ok(
            status=str(t.status),
            task_id=str(root_task_id),
            next=spec_module._INTENT_VERBS["complete"].next_hint(t),
            context_briefing=await self._briefing_for(main_pm_agent_id, root_task_id),
        ).with_introspection(task=t, role="main_pm")

    @staticmethod
    def _is_umbrella_in_progress(t: Any, role_str: str) -> bool:
        """A MegaTask umbrella sits in_progress branchless (no submit_root/pr_pass),
        so the complete spec gate (AWAITING_PM_REVIEW only) would reject it before
        main_pm_complete's branchless-aware guard runs — this lets it fall through
        to main_pm_complete (CEO merges the root PR; no agent touches master)."""
        return (
            role_str == "main_pm"
            and str(t.status) == "in_progress"
            and is_batch_umbrella(batch_id=t.batch_id, parent_task_id=t.parent_task_id)
        )

    async def complete(self, agent_id: UUID, task_id: UUID, notes: str) -> Envelope:
        """Dispatch to cell_pm_complete or main_pm_complete based on agent role.

        Spec gate runs first (role membership + composed ``complete``
        action's source-status constraint, AWAITING_PM_REVIEW only).
        Both rejections (role not in spec._PM_ROLES, status not awaiting_pm_review)
        flow through ``spec.can_invoke_intent`` and surface as the
        spec-supplied rejection_kind. After the gate accepts, the
        verb body owns dispatch — ``complete`` has two divergent
        runtime paths (Cell PM merges leaf into parent branch; Main PM
        opens master PR + escalates to CEO) that can't be expressed as
        a single VerbRunner composition. Each lower-level method keeps
        its own pre-flight guards (``_cell_pm_complete_guard`` /
        ``_main_pm_complete_guard``) — those model journal:decision
        presence, subtasks-terminal, and PR-mergeability checks the
        spec doesn't model yet.
        """
        t = await self.task.get(task_id)
        briefing = await self._briefing_for(agent_id, task_id, task=t)
        if t is None:
            return await self._emit_rejection(
                Envelope.not_found(message=f"task {task_id} not found"),
                agent_id=agent_id,
                task_id=task_id,
                verb="complete",
            )
        agent = await self.task.agent_for(agent_id)
        role_str = str(agent.role) if agent is not None else "developer"
        try:
            role = spec_module.Role(role_str)
        except ValueError:
            return await self._emit_rejection(
                Envelope.not_authorized(
                    message=f"unknown role '{role_str}'",
                    remediate="role is not declared in the lifecycle spec",
                    context_briefing=briefing,
                ).with_introspection(task=t, role=role_str),
                agent_id=agent_id,
                task_id=task_id,
                verb="complete",
            )
        actor_slug, agent_team = actor_context_fields(agent)
        spec_ctx = spec_module.Context(
            actor_id=agent_id,
            actor_slug=actor_slug,
            agent_team=agent_team,
            original_developer_slug=_extract_original_developer(t),
        )
        if soup := await self._guard_free_text(
            checks=(("notes", notes, 10),),
            task=t,
            agent_id=agent_id,
            role_str=role_str,
            verb="complete",
        ):
            return soup
        # An in_progress batch umbrella is branchless and skips the complete spec
        # gate (AWAITING_PM_REVIEW only) to fall through to main_pm_complete —
        # see _is_umbrella_in_progress. Role membership is preserved; main_pm_complete
        # re-checks assignment, subtasks-terminal, and the journal:decision gate.
        umbrella_in_progress = self._is_umbrella_in_progress(t, role_str)
        if not umbrella_in_progress:
            decision = spec_module.can_invoke_intent(role, "complete", t, spec_ctx)
            if not decision.allowed:
                return await self._emit_rejection(
                    Envelope.from_decision(
                        decision, briefing=briefing
                    ).with_introspection(task=t, role=role_str),
                    agent_id=agent_id,
                    task_id=task_id,
                    verb="complete",
                )
        # Spec gate passed (or waived for an in_progress batch umbrella) —
        # role is CELL_PM or MAIN_PM. Verb body owns dispatch from here.
        if role_str == "cell_pm":
            return await self.cell_pm_complete(agent_id, task_id, notes)
        # role_str == "main_pm" — spec._PM_ROLES has only these two members.
        return await self.main_pm_complete(agent_id, task_id, notes)

    @staticmethod
    def _validate_request_changes_findings(
        t: Any, issues: list[str] | None, findings: list[dict[str, Any]] | None
    ) -> tuple[list[Any], Envelope | None]:
        """Normalize + validate request_changes's findings — mirrors QA's
        fail_review / the in-path gate's pr_fail helpers.

        Returns ``(validated, rejection)``; the caller attaches
        ``context_briefing``/introspection (this helper has no ``self``).
        """
        raw = findings_lib.merge_findings_and_issues(findings, issues)
        if not raw:
            return [], Envelope.invalid_state(
                message="request_changes requires at least one finding",
                remediate=(
                    "pass findings=[{file, severity, expected, actual}, ...] "
                    "(issues=['...'] is still accepted this release, deprecated)"
                ),
            )
        if cap := findings_lib.findings_count_guard(raw):
            return [], cap
        try:
            validated = validate_findings(raw)
        except ContentValidationError as exc:
            return [], Envelope.invalid_state(
                message=f"malformed finding: {exc.field} — {exc.reason}",
                remediate=(
                    "each finding needs expected + actual (file/line/severity/"
                    "criterion/fix/evidence optional)"
                ),
            )
        if unknown := findings_lib.unknown_finding_criteria(t, validated):
            return [], findings_lib.criterion_mismatch_rejection(t, unknown)
        return validated, None

    async def _attach_request_changes_findings(
        self, t: Any, agent: Any, role_str: str, validated: list[Any]
    ) -> str:
        """Insert request_changes's findings into the ledger, write the
        ``PmReviewContent`` note (``pm_notes``), and return the id-prefixed
        rendering used for the a2a body to the new owner."""
        # GatewayAgentView carries no slug field — falls back to the role
        # string (mirrors _post_gate_review's reviewer_slug fallback).
        actor_slug = (
            getattr(agent, "slug", None) or role_str if agent is not None else role_str
        )
        _, summary = await findings_lib.insert_and_render(
            self.task.session,
            task_id=t.id,
            origin="pm",
            round=findings_lib.next_round(t),
            author_slug=actor_slug,
            findings=validated,
        )
        try:
            apply_structured_note(
                t,
                "pm_review",
                {
                    "summary": summary,
                    "findings": [f.model_dump(mode="json") for f in validated],
                },
            )
        except ContentValidationError:
            logger.warning(
                "request_changes pm_review note skipped (invalid content)",
                task_id=str(t.id),
            )
        return summary

    async def request_changes(
        self,
        pm_agent_id: UUID,
        task_id: UUID,
        issues: list[str] | None = None,
        findings: list[dict[str, Any]] | None = None,
    ) -> Envelope:
        """PM rejects the merge review with concrete findings; → needs_revision.

        The merge-level reject at awaiting_pm_review (the PM's only other
        verbs there are complete/escalate — an AC violation caught at merge
        review used to loop block→escalate). ``findings`` is the structured
        revision-findings ledger entry (wire Pattern A); ``issues`` (free
        text) is still accepted for one release and shimmed into file-less
        findings (deprecated). Spec gate enforces role + source status; the
        findings are inserted into the ledger and written as a structured
        ``PmReviewContent`` note (``pm_notes``) BEFORE the composed
        ``request_changes`` atomic routes the revision like a QA fail, then
        the verb body a2a-delivers the same rendering to the new owner so
        the reject reason is never stranded.
        """
        t = await self.task.get(task_id)
        briefing = await self._briefing_for(pm_agent_id, task_id, task=t)
        if t is None:
            return await self._emit_rejection(
                Envelope.not_found(message=f"task {task_id} not found"),
                agent_id=pm_agent_id,
                task_id=task_id,
                verb="request_changes",
            )
        agent = await self.task.agent_for(pm_agent_id)
        role_str = str(agent.role) if agent is not None else "cell_pm"
        validated, bad = self._validate_request_changes_findings(t, issues, findings)
        if bad is not None:
            bad.context_briefing = briefing
            return await self._emit_rejection(
                bad.with_introspection(task=t, role=role_str),
                agent_id=pm_agent_id,
                task_id=task_id,
                verb="request_changes",
            )
        # Placeholder (id-less) rendering drives the spec gate's free-text
        # check — the ledger ids don't exist until after insert, below.
        notes = findings_lib.render_findings_summary([(None, f) for f in validated])
        rejection, spec_gate = await self._request_changes_spec_gate(
            pm_agent_id, task_id, t, agent, role_str, notes, []
        )
        if rejection is not None:
            return rejection
        _role, spec_ctx = spec_gate
        summary = await self._attach_request_changes_findings(
            t, agent, role_str, validated
        )
        runner = self._verb_runner()
        try:
            t = await runner.run_intent("request_changes", t, agent, spec_ctx)
        except Exception as exc:
            return await self._emit_rejection(
                Envelope.invalid_state(
                    message=f"verb runner failed: {exc}",
                    remediate="re-fetch the task and retry; if persistent, escalate",
                    context_briefing=briefing,
                ).with_introspection(task=t, role=role_str),
                agent_id=pm_agent_id,
                task_id=task_id,
                verb="request_changes",
            )
        # Close the signal loop — the reject reason must reach whoever owns
        # the revision (mirrors fail_review / the pr_fail loop-closer).
        if t.assigned_to is not None and t.assigned_to != pm_agent_id:
            await self.a2a.send(
                from_agent=pm_agent_id,
                to_agent=t.assigned_to,
                skill="code_review",
                task_id=task_id,
                body=f"PM merge review needs changes.\n{summary}",
            )
        env = Envelope.ok(
            status=str(t.status),
            task_id=str(task_id),
            next=spec_module._INTENT_VERBS["request_changes"].next_hint(t),
            context_briefing=briefing,
        ).with_introspection(task=t, role=role_str)
        if hint := findings_lib.findings_count_hint(validated):
            env.warning = hint
        return env

    async def _request_changes_spec_gate(
        self,
        pm_agent_id: UUID,
        task_id: UUID,
        t: Any,
        agent: Any,
        role_str: str,
        notes: str,
        issues: list[str],
    ) -> tuple[Envelope | None, Any]:
        """Role + spec + free-text gates for request_changes.

        Returns ``(rejection, None)`` or ``(None, (role, spec_ctx))``.
        """
        try:
            role = spec_module.Role(role_str)
        except ValueError:
            return (
                await self._emit_rejection(
                    Envelope.not_authorized(
                        message=f"unknown role '{role_str}'",
                        remediate="role is not declared in the lifecycle spec",
                        context_briefing=await self._briefing_for(pm_agent_id, task_id),
                    ).with_introspection(task=t, role=role_str),
                    agent_id=pm_agent_id,
                    task_id=task_id,
                    verb="request_changes",
                ),
                None,
            )
        spec_ctx = spec_module.Context(
            actor_id=pm_agent_id,
            actor_slug=getattr(agent, "slug", None) if agent is not None else None,
            agent_team=str(agent.team) if agent is not None and agent.team else None,
            original_developer_slug=_extract_original_developer(t),
            notes=notes,
            issues=tuple(issues),
        )
        decision = spec_module.can_invoke_intent(role, "request_changes", t, spec_ctx)
        if not decision.allowed:
            briefing = await self._briefing_for(pm_agent_id, task_id, task=t)
            return (
                await self._emit_rejection(
                    Envelope.from_decision(
                        decision, briefing=briefing
                    ).with_introspection(task=t, role=role_str),
                    agent_id=pm_agent_id,
                    task_id=task_id,
                    verb="request_changes",
                ),
                None,
            )
        if soup := await self._guard_free_text(
            checks=(("issues", issues, 8),),
            task=t,
            agent_id=pm_agent_id,
            role_str=role_str,
            verb="request_changes",
        ):
            return (soup, None)
        return (None, (role, spec_ctx))

    async def escalate_up(
        self, pm_agent_id: UUID, task_id: UUID, reason: str
    ) -> Envelope:
        """Escalate a task to the agent's escalation_target role.

        Spec gate runs first and enforces role membership (cell_pm or
        main_pm only — escalate_up's IntentSpec has ``composes=()``, so
        the spec does not enforce a source-status constraint). After the
        gate accepts, the verb-specific preflight guards stay:
        ``journal:decision`` presence (the spec doesn't model journal
        side effects) and ``escalation_target`` configuration on the
        actor's agent record (also out of the spec's scope). Then the
        verb body owns dispatch via ``task.escalate(...)`` because
        ``composes=()`` (no atomic action for the runner to run).
        """
        t = await self.task.get(task_id)
        briefing = await self._briefing_for(pm_agent_id, task_id, task=t)
        if t is None:
            return await self._emit_rejection(
                Envelope.not_found(message=f"task {task_id} not found"),
                agent_id=pm_agent_id,
                task_id=task_id,
                verb="escalate_up",
            )
        me = await self.task.agent_for(pm_agent_id)
        role_str = str(me.role) if me is not None else "cell_pm"
        try:
            role = spec_module.Role(role_str)
        except ValueError:
            return await self._emit_rejection(
                Envelope.not_authorized(
                    message=f"unknown role '{role_str}'",
                    remediate="role is not declared in the lifecycle spec",
                    context_briefing=briefing,
                ).with_introspection(task=t, role=role_str),
                agent_id=pm_agent_id,
                task_id=task_id,
                verb="escalate_up",
            )
        actor_slug, agent_team = actor_context_fields(me)
        spec_ctx = spec_module.Context(
            actor_id=pm_agent_id,
            actor_slug=actor_slug,
            agent_team=agent_team,
            original_developer_slug=_extract_original_developer(t),
            notes=reason,
        )
        soup = self._free_text_soup(checks=(("reason", reason, 10),))
        decision = spec_module.can_invoke_intent(role, "escalate_up", t, spec_ctx)
        if env := self._soup_or_decision_env(soup, decision, briefing):
            return await self._emit_rejection(
                env.with_introspection(task=t, role=role_str),
                agent_id=pm_agent_id,
                task_id=task_id,
                verb="escalate_up",
            )

        # Write-then-gate: the escalation reason becomes the journal:decision
        # the preflight gate requires.
        await self._ensure_pm_decision(pm_agent_id, task_id, reason)
        preflight = await self._escalate_up_preflight(
            pm_agent_id, t, me, briefing, role_str
        )
        if preflight is not None:
            return await self._emit_rejection(
                preflight,
                agent_id=pm_agent_id,
                task_id=task_id,
                verb="escalate_up",
            )

        # Verb body owns dispatch — escalate_up's IntentSpec has
        # composes=(), so VerbRunner has no atomic action to run.
        target_slug = me.escalation_target if me else None
        t = await self.task.escalate(pm_agent_id, task_id, reason)
        if t is None:
            return await self._emit_rejection(
                Envelope.invalid_state(
                    message=(
                        f"could not escalate task {task_id} to {target_slug}: "
                        "target agent not found or task missing"
                    ),
                    remediate=(
                        f"verify {target_slug} exists in agents table and that the "
                        "task is still present"
                    ),
                    context_briefing=briefing,
                ),
                agent_id=pm_agent_id,
                task_id=task_id,
                verb="escalate_up",
            )
        return Envelope.ok(
            status=str(t.status),
            task_id=str(task_id),
            next=f"escalated to {target_slug}; idle until they respond",
            context_briefing=briefing,
        ).with_introspection(task=t, role=role_str)

    async def _escalate_up_preflight(
        self,
        pm_agent_id: UUID,
        t: Any,
        me: Any,
        briefing: dict[str, Any],
        role_str: str,
    ) -> Envelope | None:
        """Verb-specific preflight gates for escalate_up.

        Returns a rejection envelope when the gate fires; ``None`` to
        proceed. The spec doesn't model journal side effects or agent
        metadata (escalation_target slug), so these gates stay in the
        verb body. The journal-decision check is delegated to
        ``_check_pm_decision_required`` which consumes
        ``VERB_REQUIREMENTS["escalate_up"]``.
        """
        if env := await self._check_pm_decision_required(
            "escalate_up", pm_agent_id, t.id, t
        ):
            return env.with_introspection(task=t, role=role_str)
        target_slug = me.escalation_target if me else None
        if not target_slug:
            return Envelope.invalid_state(
                message="no escalation target configured for your role",
                remediate="check agents_config.py ESCALATION_CHAIN for your slug",
                context_briefing=briefing,
            ).with_introspection(task=t, role=role_str)
        return None

    # --- Phase 4 (board) verbs ---

    async def _escalate_did_not_apply(
        self,
        *,
        runner_error: str | None,
        task: Any,
        role_str: str,
        briefing: dict[str, Any],
        agent_id: UUID,
        task_id: UUID,
    ) -> Envelope:
        """Rejection for an escalate_to_ceo the runner did not apply.

        ``runner_error`` set → the run_intent savepoint raised; otherwise the
        service declined (task not in awaiting_pm_review). Either way a clean
        invalid_state, never the unhandled ``None.status`` 500.
        """
        if runner_error is not None:
            message = f"verb runner failed: {runner_error}"
            remediate = "check workspace + retry; if persistent, escalate"
        else:
            message = (
                "escalate_to_ceo did not apply — the task is not in a state"
                " that escalates to the CEO (needs awaiting_pm_review)"
            )
            remediate = (
                "resolve or re-route the task; only awaiting_pm_review tasks"
                " escalate to the CEO"
            )
        return await self._emit_rejection(
            Envelope.invalid_state(
                message=message,
                remediate=remediate,
                context_briefing=briefing,
            ).with_introspection(task=task, role=role_str),
            agent_id=agent_id,
            task_id=task_id,
            verb="escalate_to_ceo",
        )

    async def escalate_to_ceo(
        self, agent_id: UUID, task_id: UUID, reason: str
    ) -> Envelope:
        """Board/Main PM escalates task_id to CEO with reason.

        Spec gate runs first and enforces role membership (main_pm,
        product_owner, head_marketing) plus the composed
        ``escalate_to_ceo`` action's source-status constraint
        (AWAITING_PM_REVIEW only). After the gate accepts, the
        verb-specific preflight guard stays: ``journal:decision``
        presence (the spec doesn't model journal side effects). Then
        ``VerbRunner.run_intent("escalate_to_ceo", ...)`` dispatches the
        (escalate_to_ceo,) atomic chain wrapped in a savepoint. After
        the runner returns, the task is reassigned to None — the CEO
        acts via the UI, not as a spawnable agent (mirrors
        main_pm_complete).
        """
        t = await self.task.get(task_id)
        briefing = await self._briefing_for(agent_id, task_id, task=t)
        if t is None:
            return await self._emit_rejection(
                Envelope.not_found(message=f"task {task_id} not found"),
                agent_id=agent_id,
                task_id=task_id,
                verb="escalate_to_ceo",
            )
        me = await self.task.agent_for(agent_id)
        role_str = str(me.role) if me is not None else "main_pm"
        try:
            role = spec_module.Role(role_str)
        except ValueError:
            return await self._emit_rejection(
                Envelope.not_authorized(
                    message=f"unknown role '{role_str}'",
                    remediate="role is not declared in the lifecycle spec",
                    context_briefing=briefing,
                ).with_introspection(task=t, role=role_str),
                agent_id=agent_id,
                task_id=task_id,
                verb="escalate_to_ceo",
            )
        actor_slug, agent_team = actor_context_fields(me)
        spec_ctx = spec_module.Context(
            actor_id=agent_id,
            actor_slug=actor_slug,
            agent_team=agent_team,
            original_developer_slug=_extract_original_developer(t),
            notes=reason,
        )
        soup = self._free_text_soup(checks=(("reason", reason, 10),))
        decision = spec_module.can_invoke_intent(role, "escalate_to_ceo", t, spec_ctx)
        if env := self._soup_or_decision_env(soup, decision, briefing):
            return await self._emit_rejection(
                env.with_introspection(task=t, role=role_str),
                agent_id=agent_id,
                task_id=task_id,
                verb="escalate_to_ceo",
            )

        # Verb-specific preflight: journal:decision presence (out of spec scope).
        # Delegates to _check_pm_decision_required which consumes
        # VERB_REQUIREMENTS["escalate_to_ceo"].
        # Verb-body preflight: journal:decision presence, then the parent-AC
        # backstop (a root may not escalate to the CEO with parent ACs that no
        # completed subtask covered — inert until coverage is declared).
        # Write-then-gate: the escalation reason becomes the journal:decision
        # the gate below requires.
        await self._ensure_pm_decision(agent_id, task_id, reason)
        env = await self._check_pm_decision_required(
            "escalate_to_ceo", agent_id, task_id, t
        ) or await self._parent_acs_covered_envelope(
            agent_id, task_id, context_phrase="escalating to CEO"
        )
        if env:
            return await self._emit_rejection(
                env.with_introspection(task=t, role=role_str),
                agent_id=agent_id,
                task_id=task_id,
                verb="escalate_to_ceo",
            )

        runner = self._verb_runner()
        runner_error: str | None = None
        try:
            updated = await runner.run_intent("escalate_to_ceo", t, me, spec_ctx)
        except Exception as exc:
            updated, runner_error = None, str(exc)
        # run_intent returns None when the service declines the escalation (e.g. a
        # board agent escalating a task not in awaiting_pm_review). Without this
        # guard the OK path below dereferenced ``None.status`` — an unhandled 500
        # (the board's escalate-from-blocked crash loop).
        if updated is None:
            return await self._escalate_did_not_apply(
                runner_error=runner_error,
                task=t,
                role_str=role_str,
                briefing=briefing,
                agent_id=agent_id,
                task_id=task_id,
            )
        t = updated
        # Same as main_pm_complete: CEO acts via UI, not as a spawnable agent.
        await self.task.reassign(task_id, None)
        return Envelope.ok(
            status=str(t.status),
            task_id=str(task_id),
            next=spec_module._INTENT_VERBS["escalate_to_ceo"].next_hint(t),
            context_briefing=briefing,
        ).with_introspection(task=t, role=role_str)

    # board_triage + auditor_triage moved to ``board.py`` as the first
    # per-role mixin extraction. The Choreographer class is
    # composed in ``__init__.py`` from BoardMixin + the rest of this
    # _impl. Methods now resolve via Python's MRO.
