"""Pre-task role/session router (#206). Classifies a declared task type against the active MCP profile/session and returns a route result before any downstream mutation tools run. """ from __future__ import annotations ROUTE_ALLOWED = "allowed_current_session" ROUTE_WRONG_ROLE = "wrong_role_stop" ROUTE_TO_AUTHOR = "route_to_author_session" ROUTE_TO_REVIEWER = "route_to_reviewer_session" ROUTE_AMBIGUOUS = "ambiguous_task_stop" REVIEWER_TASKS = frozenset({ "review_pr", "merge_pr", "blind_pr_queue_review", "request_changes_pr", "approve_pr", }) AUTHOR_TASKS = frozenset({ "create_issue", "comment_issue", "close_issue", "claim_issue", "create_branch", "push_branch", "create_pr", "comment_pr", "address_pr_change_requests", "delete_branch", }) TASK_REQUIRED_ROLE = { "create_issue": "author", "comment_issue": "author", "close_issue": "author", "claim_issue": "author", "create_branch": "author", "push_branch": "author", "create_pr": "author", "comment_pr": "author", "address_pr_change_requests": "author", "delete_branch": "author", "review_pr": "reviewer", "merge_pr": "reviewer", "blind_pr_queue_review": "reviewer", "request_changes_pr": "reviewer", "approve_pr": "reviewer", } WRONG_ROLE_REVIEWER_MSG = ( "Wrong role/session for reviewer task. Launch reviewer MCP namespace." ) _session_last_route: dict | None = None def required_role_for_task(task_type: str) -> str | None: return TASK_REQUIRED_ROLE.get((task_type or "").strip()) def route_task_session( task_type: str, *, active_profile: str, active_role_kind: str, allowed_in_current_session: bool, runtime_switching_supported: bool = False, ) -> dict: """Return routing verdict for *task_type* under the active session.""" task_type = (task_type or "").strip() required_role = required_role_for_task(task_type) if required_role is None: result = { "task_type": task_type, "required_role": None, "active_role": active_role_kind, "active_profile": active_profile, "route_result": ROUTE_AMBIGUOUS, "downstream_allowed": False, "reasons": [ f"unknown task type '{task_type}'; cannot route session " "(fail closed)" ], "message": ( "Ambiguous task type; relaunch with an explicit task before " "any tool use." ), } _record_route(result) return result if allowed_in_current_session: result = { "task_type": task_type, "required_role": required_role, "active_role": active_role_kind, "active_profile": active_profile, "route_result": ROUTE_ALLOWED, "downstream_allowed": True, "reasons": [], "message": "Task role matches active session; proceed.", } _record_route(result) return result if required_role == "reviewer": result = { "task_type": task_type, "required_role": required_role, "active_role": active_role_kind, "active_profile": active_profile, "route_result": ROUTE_WRONG_ROLE, "downstream_allowed": False, "reasons": [ WRONG_ROLE_REVIEWER_MSG, "Reviewer tasks cannot run in author-bound sessions.", "Static-profile mode does not permit in-place role switching.", ], "message": WRONG_ROLE_REVIEWER_MSG, "runtime_switching_supported": runtime_switching_supported, "profile_switch_blocked": not runtime_switching_supported, } _record_route(result) return result if required_role == "author": route = ROUTE_TO_AUTHOR message = ( "Wrong role/session for author task. Launch author MCP namespace." ) result = { "task_type": task_type, "required_role": required_role, "active_role": active_role_kind, "active_profile": active_profile, "route_result": route, "downstream_allowed": False, "reasons": [message], "message": message, "runtime_switching_supported": runtime_switching_supported, "profile_switch_blocked": not runtime_switching_supported, } _record_route(result) return result result = { "task_type": task_type, "required_role": required_role, "active_role": active_role_kind, "active_profile": active_profile, "route_result": ROUTE_AMBIGUOUS, "downstream_allowed": False, "reasons": ["unable to classify task role (fail closed)"], "message": "Ambiguous task type; stop before any mutation.", } _record_route(result) return result def last_route() -> dict | None: return _session_last_route def clear_route_state(): global _session_last_route _session_last_route = None def _record_route(result: dict): global _session_last_route _session_last_route = dict(result) def check_author_mutation_after_reviewer_stop(mutation_task: str) -> tuple[bool, list[str]]: """Block author-side fallback after a reviewer wrong_role_stop (#206).""" last = _session_last_route if not last: return True, [] if last.get("route_result") != ROUTE_WRONG_ROLE: return True, [] if last.get("required_role") != "reviewer": return True, [] if mutation_task in AUTHOR_TASKS: return False, [ WRONG_ROLE_REVIEWER_MSG, "Author-side mutations are blocked after a reviewer-task " "wrong_role_stop unless the operator explicitly changes the " "task and relaunches an author MCP session.", f"Attempted fallback mutation: {mutation_task}", ] return True, []