From da91ef4dd90fc195c3bde9cacbda8d34397465fd Mon Sep 17 00:00:00 2001 From: Backend Developer 1 Date: Mon, 28 Sep 2026 20:11:26 +0000 Subject: [PATCH 1/2] [e7d5fb64] feat(lifecycle): refuse assignments whose role cannot act on the task's status (delegate, reassign, admin set) --- roboco/api/routes/tasks.py | 15 ++- roboco/api/utils/tasks.py | 6 + roboco/foundation/policy/lifecycle.py | 42 +++++++ roboco/services/base.py | 19 +++ .../services/gateway/choreographer/_impl.py | 55 +++++++-- roboco/services/task.py | 48 ++++++++ .../policy/test_assignee_capability.py | 108 ++++++++++++++++++ .../test_assignee_capability_guard.py | 78 +++++++++++++ 8 files changed, 358 insertions(+), 13 deletions(-) create mode 100644 tests/unit/foundation/policy/test_assignee_capability.py create mode 100644 tests/unit/services/test_assignee_capability_guard.py diff --git a/roboco/api/routes/tasks.py b/roboco/api/routes/tasks.py index d5269602e..535d99df8 100644 --- a/roboco/api/routes/tasks.py +++ b/roboco/api/routes/tasks.py @@ -82,7 +82,11 @@ render_attestation_markdown, ) from roboco.services.audit import get_audit_service -from roboco.services.base import ServiceError +from roboco.services.base import ( + AssigneeCapabilityError, + ServiceError, + ValidationError, +) from roboco.services.gateway.choreographer.collision import build_collision_context from roboco.services.governance import get_governance_service from roboco.services.journal import get_journal_service @@ -808,7 +812,14 @@ async def update_task( ), ) - task = await service.update(task_id, **updates) + try: + task = await service.update(task_id, **updates) + except AssigneeCapabilityError as e: + # Capability-naming refusal: the PATCH would plant an assignee whose + # role cannot act on the task's current status (not_authorized). + raise _translate_error(e) from e + except ValidationError as e: + raise _translate_error(e) from e if not task: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, diff --git a/roboco/api/utils/tasks.py b/roboco/api/utils/tasks.py index 6e2151e78..cec2b532c 100644 --- a/roboco/api/utils/tasks.py +++ b/roboco/api/utils/tasks.py @@ -15,6 +15,7 @@ from roboco.exceptions import GitError from roboco.models.base import AgentRole, TaskStatus from roboco.services.base import ( + AssigneeCapabilityError, NotFoundError, ServiceError, UnauthorizedError, @@ -222,6 +223,11 @@ def _translate_error(e: ServiceError) -> HTTPException: return HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=e.message) if isinstance(e, UnauthorizedError): return HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=e.message) + if isinstance(e, AssigneeCapabilityError): + # Capability-naming refusal: an assignment whose role cannot act on + # the task's status — the not_authorized category per the lifecycle + # spec, not a generic 400. + return HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=e.message) if isinstance(e, ValidationError): return HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=e.message) return HTTPException( diff --git a/roboco/foundation/policy/lifecycle.py b/roboco/foundation/policy/lifecycle.py index 578353c14..72c19c195 100644 --- a/roboco/foundation/policy/lifecycle.py +++ b/roboco/foundation/policy/lifecycle.py @@ -788,6 +788,48 @@ def _build_status_graph() -> dict[Status, frozenset[Status]]: } +def assignee_can_act(role: Role, status: Status) -> bool: + """True when ``role`` has at least one way to act on a task in ``status``. + + Derived FROM the tables above — never a parallel capability map: a role + can act on a status iff the status is in its CLAIM_RULES entry (the + claim path) or any atomic action whose source status is ``status`` + allows the role (the act-in-place path: complete from + awaiting_pm_review, ceo_approve from awaiting_ceo_approval, resume + from paused, ...). Terminal statuses are exempt — there is nothing + left to act on, so any assignee is coherent. This is the predicate the + assignment paths (delegate / reassign / admin set) consult before + planting an assignee on a task it could never drive (the 54e30535 + wedge: a product-owner id on a delivery task). + """ + if status in (Status.COMPLETED, Status.CANCELLED): + return True + if status in CLAIM_RULES.get(role, frozenset()): + return True + # "claim" and "cancel" are excluded here: "claim"'s allowed_roles/ + # source_statuses are the UNION across roles (the per-role narrowing IS + # CLAIM_RULES, already consulted above), and "cancel" is a near-universal + # escape hatch (PM/CEO from almost any status) — scanning it would make + # any cancellable task "actable" for its cancellers, which is not delivery + # capability. All other actions are role-accurate as declared. + return any( + role in spec.allowed_roles and status in spec.source_statuses + for name, spec in _ATOMIC_ACTIONS.items() + if name not in ("claim", "cancel") + ) + + +def assignee_capability_gap(role: Role, status: Status) -> str | None: + """Capability-naming refusal reason, or ``None`` when the role can act.""" + if assignee_can_act(role, status): + return None + return ( + f"role '{role.value}' has no action on status '{status.value}' — it" + " cannot claim or act on a task in that state; assign a role with a" + f" claim/action edge on '{status.value}'" + ) + + # --------------------------------------------------------------------------- # Team rules (predecessor canon: PERMISSIONS.md "Team-Based Restrictions") # Per-slug. None means "any team" (cross-cell or board roles). diff --git a/roboco/services/base.py b/roboco/services/base.py index 19c3ca91b..be4617fb7 100644 --- a/roboco/services/base.py +++ b/roboco/services/base.py @@ -61,6 +61,25 @@ def __init__( self.field = field +class AssigneeCapabilityError(ServiceError): + """An assignment would plant an assignee whose role cannot act on the + task's status. Refusal names the missing capability; API/gateway layers + translate to the not_authorized category (403). + """ + + def __init__( + self, + message: str, + *, + role: str | None = None, + status: str | None = None, + details: dict | None = None, + ) -> None: + super().__init__(message, details) + self.role = role + self.status = status + + class ConflictError(ServiceError): """Resource conflict (duplicate, state conflict). Translates to 409.""" diff --git a/roboco/services/gateway/choreographer/_impl.py b/roboco/services/gateway/choreographer/_impl.py index bcfcddce3..1f64b5fb6 100644 --- a/roboco/services/gateway/choreographer/_impl.py +++ b/roboco/services/gateway/choreographer/_impl.py @@ -30,7 +30,7 @@ validate_findings, ) from roboco.foundation.policy.content.validators import reject_trivial -from roboco.services.base import UnauthorizedError +from roboco.services.base import AssigneeCapabilityError, UnauthorizedError 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 @@ -5350,16 +5350,32 @@ async def reassign( task_id=task_id, verb="reassign", ) - after = await self.task.reassign_active_claim( - task_id, UUID(AGENT_UUIDS[new_assignee]) - ) - if after is None: + after: Any = None + rejection: Envelope | None = None + try: + after = await self.task.reassign_active_claim( + task_id, UUID(AGENT_UUIDS[new_assignee]) + ) + except AssigneeCapabilityError as exc: + # The target assignee's role cannot act on the task's current + # status — refused with the missing capability named. + rejection = Envelope.not_authorized( + message=exc.message, + remediate=( + "reassign to an agent whose role can act on the task's" + f" current status ({t.status})" + ), + context_briefing=briefing, + ) + if rejection is None and after is None: + 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, + ) + if rejection is not 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), + rejection.with_introspection(task=t, role=role_str), agent_id=agent_id, task_id=task_id, verb="reassign", @@ -7743,7 +7759,7 @@ async def _create_subtask_and_envelope( have gotten from the upfront completeness check. """ from roboco.foundation.policy.task_completeness import TaskCompletenessError - from roboco.services.base import ValidationError + from roboco.services.base import AssigneeCapabilityError, ValidationError parent_task_id = parent.id try: @@ -7766,6 +7782,23 @@ async def _create_subtask_and_envelope( task_id=parent_task_id, verb="delegate", ) + except AssigneeCapabilityError as exc: + # The delegated assignee's role cannot act on the status the + # subtask will hold — refused at creation with the missing + # capability named (the 54e30535 wedge shape). + return await self._emit_rejection( + Envelope.not_authorized( + message=exc.message, + remediate=( + "re-issue delegate(...) with an assignee whose role" + " can claim/act on the subtask's target status" + ), + 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 diff --git a/roboco/services/task.py b/roboco/services/task.py index 1b22bc17b..55a75acc8 100644 --- a/roboco/services/task.py +++ b/roboco/services/task.py @@ -73,6 +73,7 @@ from roboco.models.work_session import WorkSessionCreate from roboco.seeds.initial_data import AGENT_UUIDS from roboco.services.base import ( + AssigneeCapabilityError, BaseService, ConflictError, NotFoundError, @@ -4014,6 +4015,14 @@ async def update( ) old_parent_id = getattr(task, "parent_task_id", None) + # Capability backstop on the admin task-set path (PATCH assigned_to): + # a genuine reassignment may not plant an assignee whose role cannot + # act on the task's current status. No-op clears and same-value sets + # pass through. Checked BEFORE the field loop so a refusal never + # leaves the session dirty. + new_assignee = updates.get("assigned_to") + if new_assignee is not None and new_assignee != task.assigned_to: + self._refuse_incapable_assignee(task.status, new_assignee) for key, value in updates.items(): if hasattr(task, key) and value is not None: setattr(task, key, value) @@ -11947,6 +11956,32 @@ def _clear_stale_active_claimant( if prior_claimant is not None and prior_claimant != effective_assignee: task.active_claimant_id = cast("Any", None) + def _refuse_incapable_assignee(self, status: Any, new_assignee: Any) -> None: + """Refuse an assignment whose role cannot act on the target status. + + Capability is derived from the lifecycle role-transition map + (``CLAIM_RULES`` + the atomic action specs) — never a parallel + system; see ``lifecycle.assignee_capability_gap``. Terminal + statuses and unknown (non-seeded) assignee UUIDs pass through; + the existing identity guards own those. + """ + from roboco.foundation.identity import role_for_uuid_or_none + from roboco.foundation.policy import lifecycle as lifecycle_spec + + if new_assignee is None: + return + role = role_for_uuid_or_none(new_assignee) + if role is None: + return + task_status = lifecycle_spec.Status(str(getattr(status, "value", status))) + gap = lifecycle_spec.assignee_capability_gap(role, task_status) + if gap is not None: + raise AssigneeCapabilityError( + f"ASSIGNEE_INCAPABLE: {gap}", + role=role.value, + status=task_status.value, + ) + async def reassign( self, task_id: UUID, new_assignee: UUID | None ) -> TaskTable | None: @@ -12014,6 +12049,11 @@ async def reassign( if redirect.dev_notes_line is not None: task.dev_notes = (task.dev_notes or "") + redirect.dev_notes_line + # Capability backstop: the (post-redirect) assignee's role must have + # at least one action on the task's current status — otherwise the + # task sits assigned but unactable (the 54e30535 wedge shape). + self._refuse_incapable_assignee(task.status, effective_assignee) + task.assigned_to = ( cast("Any", effective_assignee) if effective_assignee else None ) @@ -12233,6 +12273,10 @@ async def reassign_active_claim( if redirect.dev_notes_line is not None: task.dev_notes = (task.dev_notes or "") + redirect.dev_notes_line + # Capability backstop (mirrors `reassign`): the post-redirect claimant + # must be able to act on a claimed/in_progress task. + self._refuse_incapable_assignee(task.status, effective_assignee) + old_assignee = cast("UUID | None", task.claimed_by) now = datetime.now(UTC) task.assigned_to = cast("Any", effective_assignee) @@ -13515,6 +13559,10 @@ async def create_subtask(self, req: TaskCreateRequest) -> TaskTable: adds_migration=req.adds_migration, touches_shared=req.touches_shared, ) + # Capability backstop at delegation-creation time: the assignee's role + # must be able to act on the status the subtask will hold (PENDING when + # assigned). Refuses the 54e30535 wedge shape before the row exists. + self._refuse_incapable_assignee(prepared.status, prepared.assigned_to) return await self.create(prepared) diff --git a/tests/unit/foundation/policy/test_assignee_capability.py b/tests/unit/foundation/policy/test_assignee_capability.py new file mode 100644 index 000000000..2dfb9881e --- /dev/null +++ b/tests/unit/foundation/policy/test_assignee_capability.py @@ -0,0 +1,108 @@ +"""Unit tests for the assignee-capability predicate derived from the +lifecycle role-transition map (``assignee_can_act`` / +``assignee_capability_gap``). + +The predicate must stay a pure derivation of ``CLAIM_RULES`` + the atomic +action specs — these tests pin the wedge shapes it exists to refuse +(54e30535: a product-owner id assigned a delivery task it can never act on). +""" + +from __future__ import annotations + +import pytest +from roboco.foundation.identity import Role +from roboco.foundation.policy.lifecycle import ( + _ATOMIC_ACTIONS, + CLAIM_RULES, + Status, + assignee_can_act, + assignee_capability_gap, +) + + +@pytest.mark.parametrize( + ("role", "status"), + [ + # Developer claim path + act-in-place statuses. + (Role.DEVELOPER, Status.PENDING), + (Role.DEVELOPER, Status.NEEDS_REVISION), + (Role.DEVELOPER, Status.CLAIMED), + (Role.DEVELOPER, Status.IN_PROGRESS), + (Role.DEVELOPER, Status.VERIFYING), + # QA can act only on the QA queue. + (Role.QA, Status.AWAITING_QA), + # Documenter on its queue. + (Role.DOCUMENTER, Status.AWAITING_DOCUMENTATION), + # PMs claim pending/needs_revision and act on review/backlog/blocked. + (Role.CELL_PM, Status.PENDING), + (Role.CELL_PM, Status.AWAITING_PM_REVIEW), + (Role.MAIN_PM, Status.BACKLOG), + (Role.MAIN_PM, Status.BLOCKED), + # PR reviewer claims the gate. + (Role.PR_REVIEWER, Status.AWAITING_PR_REVIEW), + # CEO acts on the approval queue. + (Role.CEO, Status.AWAITING_CEO_APPROVAL), + ], +) +def test_role_with_action_on_status_can_act(role: Role, status: Status) -> None: + assert assignee_can_act(role, status) + + +@pytest.mark.parametrize( + ("role", "status"), + [ + # The 54e30535 wedge shape: board/advisory roles have no delivery + # verbs — a delivery task assigned to them is unactable. + (Role.PRODUCT_OWNER, Status.PENDING), + (Role.HEAD_MARKETING, Status.IN_PROGRESS), + (Role.AUDITOR, Status.NEEDS_REVISION), + # Cross-queue assignments: nobody acts outside their lane. + (Role.DEVELOPER, Status.AWAITING_QA), + (Role.DEVELOPER, Status.AWAITING_PM_REVIEW), + (Role.QA, Status.PENDING), + (Role.QA, Status.AWAITING_DOCUMENTATION), + (Role.DOCUMENTER, Status.AWAITING_QA), + (Role.CELL_PM, Status.AWAITING_QA), + (Role.CEO, Status.PENDING), + ], +) +def test_role_without_action_on_status_cannot_act(role: Role, status: Status) -> None: + assert not assignee_can_act(role, status) + + +@pytest.mark.parametrize("status", list(Status)) +def test_terminal_statuses_are_exempt(status: Status) -> None: + """COMPLETED/CANCELLED have nothing left to act on — any role passes.""" + if status not in (Status.COMPLETED, Status.CANCELLED): + return + for role in Role: + assert assignee_can_act(role, status) + + +def test_predicate_is_a_derivation_not_a_parallel_map() -> None: + """Every can-act verdict must be explainable by the existing tables.""" + for role in Role: + for status in Status: + if not assignee_can_act(role, status): + continue + if status in (Status.COMPLETED, Status.CANCELLED): + continue # terminal exemption + via_claim = status in CLAIM_RULES.get(role, frozenset()) + via_action = any( + role in spec.allowed_roles and status in spec.source_statuses + for name, spec in _ATOMIC_ACTIONS.items() + if name + not in ("claim", "cancel") # union/escape-hatch actions excluded + ) + assert via_claim or via_action, (role, status) + + +def test_gap_message_names_role_and_status() -> None: + gap = assignee_capability_gap(Role.PRODUCT_OWNER, Status.PENDING) + assert gap is not None + assert "product_owner" in gap + assert "pending" in gap + + +def test_gap_is_none_when_role_can_act() -> None: + assert assignee_capability_gap(Role.DEVELOPER, Status.PENDING) is None diff --git a/tests/unit/services/test_assignee_capability_guard.py b/tests/unit/services/test_assignee_capability_guard.py new file mode 100644 index 000000000..3e3cbe7aa --- /dev/null +++ b/tests/unit/services/test_assignee_capability_guard.py @@ -0,0 +1,78 @@ +"""Unit tests for TaskService._refuse_incapable_assignee — the assignment-path +capability backstop (delegate / reassign / admin set). +""" + +from __future__ import annotations + +from unittest.mock import MagicMock +from uuid import uuid4 + +import pytest +from roboco.foundation.identity import AGENTS +from roboco.models.base import TaskStatus +from roboco.seeds.initial_data import AGENT_UUIDS +from roboco.services.base import AssigneeCapabilityError +from roboco.services.task import TaskService + + +def _svc() -> TaskService: + return TaskService(MagicMock()) + + +def _agent_uuid(role_value: str) -> object: + for row in AGENTS.values(): + if row.role.value == role_value: + return row.uuid + raise AssertionError(f"no seeded agent with role {role_value}") + + +def test_guard_accepts_capable_assignee() -> None: + svc = _svc() + # Must not raise. + svc._refuse_incapable_assignee(TaskStatus.PENDING, _agent_uuid("developer")) + + +def test_guard_accepts_null_and_unknown_assignee() -> None: + svc = _svc() + svc._refuse_incapable_assignee(TaskStatus.PENDING, None) + # A non-seeded UUID has no resolvable role — existing identity guards own it. + svc._refuse_incapable_assignee(TaskStatus.PENDING, uuid4()) + + +def test_guard_refuses_board_role_on_delivery_task() -> None: + """The 54e30535 wedge shape: a product-owner id on a pending task.""" + svc = _svc() + with pytest.raises(AssigneeCapabilityError) as excinfo: + svc._refuse_incapable_assignee(TaskStatus.PENDING, _agent_uuid("product_owner")) + assert "product_owner" in str(excinfo.value.message) + assert "pending" in str(excinfo.value.message) + + +@pytest.mark.parametrize( + ("role_value", "status"), + [ + ("product_owner", TaskStatus.IN_PROGRESS), + ("auditor", TaskStatus.NEEDS_REVISION), + ("head_marketing", TaskStatus.PENDING), + ("developer", TaskStatus.AWAITING_QA), + ("qa", TaskStatus.PENDING), + ], +) +def test_guard_refuses_cross_lane_assignments( + role_value: str, status: TaskStatus +) -> None: + svc = _svc() + with pytest.raises(AssigneeCapabilityError): + svc._refuse_incapable_assignee(status, _agent_uuid(role_value)) + + +def test_guard_accepts_terminal_status_for_any_role() -> None: + svc = _svc() + svc._refuse_incapable_assignee(TaskStatus.COMPLETED, _agent_uuid("product_owner")) + + +def test_seeded_uuid_lookup_resolves_every_seeded_role() -> None: + """Sanity: the AGENT_UUIDS/AGENTS import surface used above is complete.""" + for role_value in ("developer", "product_owner", "cell_pm", "qa"): + assert _agent_uuid(role_value) is not None + assert AGENT_UUIDS # the slug->uuid map stays populated From c2ce33309b7485834882eb723dfb40d514928ec3 Mon Sep 17 00:00:00 2001 From: Backend Developer 1 Date: Mon, 28 Sep 2026 20:28:49 +0000 Subject: [PATCH 2/2] [e7d5fb64] regenerate generated artifacts --- agents/prompts/_generated/auditor.md | 1 - agents/prompts/_generated/cell_pm.md | 2 - agents/prompts/_generated/developer.md | 3 -- agents/prompts/_generated/head_marketing.md | 1 - .../prompts/_generated/lifecycle-cell_pm.md | 1 - .../prompts/_generated/lifecycle-main_pm.md | 1 - agents/prompts/_generated/main_pm.md | 2 - agents/prompts/_generated/pr_reviewer.md | 1 - agents/prompts/_generated/product_owner.md | 1 - agents/prompts/_generated/qa.md | 2 - agents/prompts/_generated/verbs.md | 46 ---------------- docs/rag/lifecycle/intent-verbs.md | 44 +++++---------- docs/rag/lifecycle/status-transitions.md | 6 +-- panel/lib/lifecycle.json | 53 +++---------------- 14 files changed, 23 insertions(+), 141 deletions(-) diff --git a/agents/prompts/_generated/auditor.md b/agents/prompts/_generated/auditor.md index 5e64719ae..2fdc2699c 100644 --- a/agents/prompts/_generated/auditor.md +++ b/agents/prompts/_generated/auditor.md @@ -19,7 +19,6 @@ | `evidence` | `evidence(task_id: UUID)` | | `dm` | `dm(recipient: str, text: str, task_id: UUID | None = None, skill: str | None = None)` | | `read_a2a` | `read_a2a(see do_server)` | -| `task_time` | `task_time(task_id: UUID)` | | `approve_playbook` | `approve_playbook(playbook_id: UUID)` | | `reject_playbook` | `reject_playbook(playbook_id: UUID, reason: str)` | | `archive_playbook` | `archive_playbook(playbook_id: UUID)` | diff --git a/agents/prompts/_generated/cell_pm.md b/agents/prompts/_generated/cell_pm.md index 46a82ada5..5c54bd5a2 100644 --- a/agents/prompts/_generated/cell_pm.md +++ b/agents/prompts/_generated/cell_pm.md @@ -7,7 +7,6 @@ | Verb | Body schema | |------|-------------| -| `cancel_leaf` | `cancel_leaf(task_id: UUID, reason: str)` | | `complete` | `complete(task_id: UUID, notes: str)` | | `declare_coverage` | `declare_coverage(task_id: UUID, criteria: list[str])` | | `delegate` | `delegate(parent_task_id: UUID, title: str, description: str, assigned_to: str, team: str, task_type: str, nature: str, estimated_complexity: Complexity, acceptance_criteria: list[str], project_id: UUID | None = None, covers_parent_criteria: list[str] | None = None, intends_to_touch: list[str] | None = None, adds_migration: bool = False, touches_shared: bool = False, depends_on: list[UUID] | None = None)` | @@ -33,7 +32,6 @@ | `evidence` | `evidence(task_id: UUID)` | | `pr_update` | `pr_update(see do_server)` | | `draft_playbook` | `draft_playbook(title: str, problem: str, procedure: str, tags: list[str] = PydanticUndefined, source_task_id: UUID | None = None)` | -| `task_time` | `task_time(task_id: UUID)` | | `notify_list` | `notify_list(unread_only: bool = True, pending_ack_only: bool = False, limit: int = 20)` | | `notify_get` | `notify_get(notification_id: UUID)` | | `notify_ack` | `notify_ack(notification_id: UUID)` | diff --git a/agents/prompts/_generated/developer.md b/agents/prompts/_generated/developer.md index 62a341624..9b53c3fa3 100644 --- a/agents/prompts/_generated/developer.md +++ b/agents/prompts/_generated/developer.md @@ -30,10 +30,7 @@ | `draft_playbook` | `draft_playbook(title: str, problem: str, procedure: str, tags: list[str] = PydanticUndefined, source_task_id: UUID | None = None)` | | `propose_video` | `propose_video(composition_id: str, x_caption: str, tiktok_caption: str, platforms: list[str], input_props: str | Any | None = None)` | | `request_sandbox` | `request_sandbox(services: list[str] | None = None, extensions: str | list[str] | None = None)` | -| `run_sandbox_tests` | `run_sandbox_tests(command: str, image: str | None = None, timeout_seconds: int | None = None)` | | `request_render` | `request_render(composition_id: str | None = None, orientation: str = 'vertical', frame_count: int = 8, input_props: str | Any | None = None)` | -| `preflight_diff` | `preflight_diff(task_id: UUID)` | -| `triage_failure` | `triage_failure(task_id: UUID, test_name: str, error_excerpt: str = '')` | | `notify_list` | `notify_list(unread_only: bool = True, pending_ack_only: bool = False, limit: int = 20)` | | `notify_get` | `notify_get(notification_id: UUID)` | | `notify_ack` | `notify_ack(notification_id: UUID)` | diff --git a/agents/prompts/_generated/head_marketing.md b/agents/prompts/_generated/head_marketing.md index 8e4b9e9a4..85aa12688 100644 --- a/agents/prompts/_generated/head_marketing.md +++ b/agents/prompts/_generated/head_marketing.md @@ -21,7 +21,6 @@ | `notify` | `notify(target: str, text: str, priority: str = 'normal', task_id: UUID | None = None)` | | `evidence` | `evidence(task_id: UUID)` | | `nothing_to_propose` | `nothing_to_propose(task_id: UUID, reason: str)` | -| `task_time` | `task_time(task_id: UUID)` | | `notify_list` | `notify_list(unread_only: bool = True, pending_ack_only: bool = False, limit: int = 20)` | | `notify_get` | `notify_get(notification_id: UUID)` | | `notify_ack` | `notify_ack(notification_id: UUID)` | diff --git a/agents/prompts/_generated/lifecycle-cell_pm.md b/agents/prompts/_generated/lifecycle-cell_pm.md index e2230c94a..f88d4f6f2 100644 --- a/agents/prompts/_generated/lifecycle-cell_pm.md +++ b/agents/prompts/_generated/lifecycle-cell_pm.md @@ -3,7 +3,6 @@ These are the only verbs the gateway will accept from you. Calling any other verb will be rejected with a Decision telling you the right one. -- **cancel_leaf**: Close a zero-diff leaf: a delegated child whose findings a merged sibling already fixed, so no legitimate diff remains and i_am_done/complete can never accept it. Refuses a target with any children of its own (not a leaf), any commit ahead of its base, or an open PR - those go through the normal review path, not this. `reason` is recorded as your journal:decision and on the task's audit trail. Cell PM: only your own coordination task's children. Main PM: any root's descendant. - **complete**: Cell PM merges the PR (leaf into the cell branch, or the gated cell→root PR into the root branch) + transitions to completed; Main PM escalates the root to the CEO (who merges root→master). The merge runs BEFORE the complete transition: TaskService.complete asserts the PR is already merged, so the choreographer verb body (cell_pm_complete / main_pm_complete) owns the merge-first ordering — no trailing pr_merge side_effect is declared here. - **declare_coverage**: Stamp parent acceptance criteria onto an existing child's parent_ac_refs after the fact — for a replacement child whose delegate omitted covers_parent_criteria. Or, targeting your OWN root/coordination task, declare criteria as root-owned (only your own machinery satisfies them — never push these into a cell). No status change; the verb body owns ownership + criterion validation. - **delegate**: Create a subtask under the current task. Validates the delegation chain (main_pm->cell_pm; cell_pm->its team's devs) and the assignee-vs-task_type rule (Cell PMs get planning-typed tasks; devs get code/research, UX devs also design). documentation is NOT delegatable — the lifecycle auto-creates the doc phase after the code subtask passes QA. diff --git a/agents/prompts/_generated/lifecycle-main_pm.md b/agents/prompts/_generated/lifecycle-main_pm.md index 4d8233d4a..256201e0e 100644 --- a/agents/prompts/_generated/lifecycle-main_pm.md +++ b/agents/prompts/_generated/lifecycle-main_pm.md @@ -3,7 +3,6 @@ These are the only verbs the gateway will accept from you. Calling any other verb will be rejected with a Decision telling you the right one. -- **cancel_leaf**: Close a zero-diff leaf: a delegated child whose findings a merged sibling already fixed, so no legitimate diff remains and i_am_done/complete can never accept it. Refuses a target with any children of its own (not a leaf), any commit ahead of its base, or an open PR - those go through the normal review path, not this. `reason` is recorded as your journal:decision and on the task's audit trail. Cell PM: only your own coordination task's children. Main PM: any root's descendant. - **complete**: Cell PM merges the PR (leaf into the cell branch, or the gated cell→root PR into the root branch) + transitions to completed; Main PM escalates the root to the CEO (who merges root→master). The merge runs BEFORE the complete transition: TaskService.complete asserts the PR is already merged, so the choreographer verb body (cell_pm_complete / main_pm_complete) owns the merge-first ordering — no trailing pr_merge side_effect is declared here. - **declare_coverage**: Stamp parent acceptance criteria onto an existing child's parent_ac_refs after the fact — for a replacement child whose delegate omitted covers_parent_criteria. Or, targeting your OWN root/coordination task, declare criteria as root-owned (only your own machinery satisfies them — never push these into a cell). No status change; the verb body owns ownership + criterion validation. - **delegate**: Create a subtask under the current task. Validates the delegation chain (main_pm->cell_pm; cell_pm->its team's devs) and the assignee-vs-task_type rule (Cell PMs get planning-typed tasks; devs get code/research, UX devs also design). documentation is NOT delegatable — the lifecycle auto-creates the doc phase after the code subtask passes QA. diff --git a/agents/prompts/_generated/main_pm.md b/agents/prompts/_generated/main_pm.md index ae06e4a98..1e93b940f 100644 --- a/agents/prompts/_generated/main_pm.md +++ b/agents/prompts/_generated/main_pm.md @@ -7,7 +7,6 @@ | Verb | Body schema | |------|-------------| -| `cancel_leaf` | `cancel_leaf(task_id: UUID, reason: str)` | | `complete` | `complete(task_id: UUID, notes: str)` | | `declare_coverage` | `declare_coverage(task_id: UUID, criteria: list[str])` | | `delegate` | `delegate(parent_task_id: UUID, title: str, description: str, assigned_to: str, team: str, task_type: str, nature: str, estimated_complexity: Complexity, acceptance_criteria: list[str], project_id: UUID | None = None, covers_parent_criteria: list[str] | None = None, intends_to_touch: list[str] | None = None, adds_migration: bool = False, touches_shared: bool = False, depends_on: list[UUID] | None = None)` | @@ -34,7 +33,6 @@ | `evidence` | `evidence(task_id: UUID)` | | `pr_update` | `pr_update(see do_server)` | | `draft_playbook` | `draft_playbook(title: str, problem: str, procedure: str, tags: list[str] = PydanticUndefined, source_task_id: UUID | None = None)` | -| `task_time` | `task_time(task_id: UUID)` | | `notify_list` | `notify_list(unread_only: bool = True, pending_ack_only: bool = False, limit: int = 20)` | | `notify_get` | `notify_get(notification_id: UUID)` | | `notify_ack` | `notify_ack(notification_id: UUID)` | diff --git a/agents/prompts/_generated/pr_reviewer.md b/agents/prompts/_generated/pr_reviewer.md index 5bb211314..7bdae032f 100644 --- a/agents/prompts/_generated/pr_reviewer.md +++ b/agents/prompts/_generated/pr_reviewer.md @@ -26,4 +26,3 @@ | `read_a2a` | `read_a2a(see do_server)` | | `notify_list` | `notify_list(unread_only: bool = True, pending_ack_only: bool = False, limit: int = 20)` | | `notify_get` | `notify_get(notification_id: UUID)` | -| `task_time` | `task_time(task_id: UUID)` | diff --git a/agents/prompts/_generated/product_owner.md b/agents/prompts/_generated/product_owner.md index 11a09c207..bc0f086e0 100644 --- a/agents/prompts/_generated/product_owner.md +++ b/agents/prompts/_generated/product_owner.md @@ -21,7 +21,6 @@ | `notify` | `notify(target: str, text: str, priority: str = 'normal', task_id: UUID | None = None)` | | `evidence` | `evidence(task_id: UUID)` | | `nothing_to_propose` | `nothing_to_propose(task_id: UUID, reason: str)` | -| `task_time` | `task_time(task_id: UUID)` | | `notify_list` | `notify_list(unread_only: bool = True, pending_ack_only: bool = False, limit: int = 20)` | | `notify_get` | `notify_get(notification_id: UUID)` | | `notify_ack` | `notify_ack(notification_id: UUID)` | diff --git a/agents/prompts/_generated/qa.md b/agents/prompts/_generated/qa.md index 3110368a0..e45f876cb 100644 --- a/agents/prompts/_generated/qa.md +++ b/agents/prompts/_generated/qa.md @@ -25,9 +25,7 @@ | `evidence` | `evidence(task_id: UUID)` | | `draft_playbook` | `draft_playbook(title: str, problem: str, procedure: str, tags: list[str] = PydanticUndefined, source_task_id: UUID | None = None)` | | `request_sandbox` | `request_sandbox(services: list[str] | None = None, extensions: str | list[str] | None = None)` | -| `run_sandbox_tests` | `run_sandbox_tests(command: str, image: str | None = None, timeout_seconds: int | None = None)` | | `request_render` | `request_render(composition_id: str | None = None, orientation: str = 'vertical', frame_count: int = 8, input_props: str | Any | None = None)` | -| `triage_failure` | `triage_failure(task_id: UUID, test_name: str, error_excerpt: str = '')` | | `notify_list` | `notify_list(unread_only: bool = True, pending_ack_only: bool = False, limit: int = 20)` | | `notify_get` | `notify_get(notification_id: UUID)` | | `notify_ack` | `notify_ack(notification_id: UUID)` | diff --git a/agents/prompts/_generated/verbs.md b/agents/prompts/_generated/verbs.md index d7fc44563..c3870cb31 100644 --- a/agents/prompts/_generated/verbs.md +++ b/agents/prompts/_generated/verbs.md @@ -39,10 +39,7 @@ real tools live in their agent_sdk drivers, not role_config. | `draft_playbook` | `draft_playbook(title: str, problem: str, procedure: str, tags: list[str] = PydanticUndefined, source_task_id: UUID | None = None)` | | `propose_video` | `propose_video(composition_id: str, x_caption: str, tiktok_caption: str, platforms: list[str], input_props: str | Any | None = None)` | | `request_sandbox` | `request_sandbox(services: list[str] | None = None, extensions: str | list[str] | None = None)` | -| `run_sandbox_tests` | `run_sandbox_tests(command: str, image: str | None = None, timeout_seconds: int | None = None)` | | `request_render` | `request_render(composition_id: str | None = None, orientation: str = 'vertical', frame_count: int = 8, input_props: str | Any | None = None)` | -| `preflight_diff` | `preflight_diff(task_id: UUID)` | -| `triage_failure` | `triage_failure(task_id: UUID, test_name: str, error_excerpt: str = '')` | | `notify_list` | `notify_list(unread_only: bool = True, pending_ack_only: bool = False, limit: int = 20)` | | `notify_get` | `notify_get(notification_id: UUID)` | | `notify_ack` | `notify_ack(notification_id: UUID)` | @@ -73,9 +70,7 @@ real tools live in their agent_sdk drivers, not role_config. | `evidence` | `evidence(task_id: UUID)` | | `draft_playbook` | `draft_playbook(title: str, problem: str, procedure: str, tags: list[str] = PydanticUndefined, source_task_id: UUID | None = None)` | | `request_sandbox` | `request_sandbox(services: list[str] | None = None, extensions: str | list[str] | None = None)` | -| `run_sandbox_tests` | `run_sandbox_tests(command: str, image: str | None = None, timeout_seconds: int | None = None)` | | `request_render` | `request_render(composition_id: str | None = None, orientation: str = 'vertical', frame_count: int = 8, input_props: str | Any | None = None)` | -| `triage_failure` | `triage_failure(task_id: UUID, test_name: str, error_excerpt: str = '')` | | `notify_list` | `notify_list(unread_only: bool = True, pending_ack_only: bool = False, limit: int = 20)` | | `notify_get` | `notify_get(notification_id: UUID)` | | `notify_ack` | `notify_ack(notification_id: UUID)` | @@ -119,7 +114,6 @@ real tools live in their agent_sdk drivers, not role_config. | Verb | Body schema | |------|-------------| -| `cancel_leaf` | `cancel_leaf(task_id: UUID, reason: str)` | | `complete` | `complete(task_id: UUID, notes: str)` | | `declare_coverage` | `declare_coverage(task_id: UUID, criteria: list[str])` | | `delegate` | `delegate(parent_task_id: UUID, title: str, description: str, assigned_to: str, team: str, task_type: str, nature: str, estimated_complexity: Complexity, acceptance_criteria: list[str], project_id: UUID | None = None, covers_parent_criteria: list[str] | None = None, intends_to_touch: list[str] | None = None, adds_migration: bool = False, touches_shared: bool = False, depends_on: list[UUID] | None = None)` | @@ -145,7 +139,6 @@ real tools live in their agent_sdk drivers, not role_config. | `evidence` | `evidence(task_id: UUID)` | | `pr_update` | `pr_update(see do_server)` | | `draft_playbook` | `draft_playbook(title: str, problem: str, procedure: str, tags: list[str] = PydanticUndefined, source_task_id: UUID | None = None)` | -| `task_time` | `task_time(task_id: UUID)` | | `notify_list` | `notify_list(unread_only: bool = True, pending_ack_only: bool = False, limit: int = 20)` | | `notify_get` | `notify_get(notification_id: UUID)` | | `notify_ack` | `notify_ack(notification_id: UUID)` | @@ -158,7 +151,6 @@ real tools live in their agent_sdk drivers, not role_config. | Verb | Body schema | |------|-------------| -| `cancel_leaf` | `cancel_leaf(task_id: UUID, reason: str)` | | `complete` | `complete(task_id: UUID, notes: str)` | | `declare_coverage` | `declare_coverage(task_id: UUID, criteria: list[str])` | | `delegate` | `delegate(parent_task_id: UUID, title: str, description: str, assigned_to: str, team: str, task_type: str, nature: str, estimated_complexity: Complexity, acceptance_criteria: list[str], project_id: UUID | None = None, covers_parent_criteria: list[str] | None = None, intends_to_touch: list[str] | None = None, adds_migration: bool = False, touches_shared: bool = False, depends_on: list[UUID] | None = None)` | @@ -185,7 +177,6 @@ real tools live in their agent_sdk drivers, not role_config. | `evidence` | `evidence(task_id: UUID)` | | `pr_update` | `pr_update(see do_server)` | | `draft_playbook` | `draft_playbook(title: str, problem: str, procedure: str, tags: list[str] = PydanticUndefined, source_task_id: UUID | None = None)` | -| `task_time` | `task_time(task_id: UUID)` | | `notify_list` | `notify_list(unread_only: bool = True, pending_ack_only: bool = False, limit: int = 20)` | | `notify_get` | `notify_get(notification_id: UUID)` | | `notify_ack` | `notify_ack(notification_id: UUID)` | @@ -212,7 +203,6 @@ real tools live in their agent_sdk drivers, not role_config. | `notify` | `notify(target: str, text: str, priority: str = 'normal', task_id: UUID | None = None)` | | `evidence` | `evidence(task_id: UUID)` | | `nothing_to_propose` | `nothing_to_propose(task_id: UUID, reason: str)` | -| `task_time` | `task_time(task_id: UUID)` | | `notify_list` | `notify_list(unread_only: bool = True, pending_ack_only: bool = False, limit: int = 20)` | | `notify_get` | `notify_get(notification_id: UUID)` | | `notify_ack` | `notify_ack(notification_id: UUID)` | @@ -244,7 +234,6 @@ real tools live in their agent_sdk drivers, not role_config. | `notify` | `notify(target: str, text: str, priority: str = 'normal', task_id: UUID | None = None)` | | `evidence` | `evidence(task_id: UUID)` | | `nothing_to_propose` | `nothing_to_propose(task_id: UUID, reason: str)` | -| `task_time` | `task_time(task_id: UUID)` | | `notify_list` | `notify_list(unread_only: bool = True, pending_ack_only: bool = False, limit: int = 20)` | | `notify_get` | `notify_get(notification_id: UUID)` | | `notify_ack` | `notify_ack(notification_id: UUID)` | @@ -275,7 +264,6 @@ real tools live in their agent_sdk drivers, not role_config. | `evidence` | `evidence(task_id: UUID)` | | `dm` | `dm(recipient: str, text: str, task_id: UUID | None = None, skill: str | None = None)` | | `read_a2a` | `read_a2a(see do_server)` | -| `task_time` | `task_time(task_id: UUID)` | | `approve_playbook` | `approve_playbook(playbook_id: UUID)` | | `reject_playbook` | `reject_playbook(playbook_id: UUID, reason: str)` | | `archive_playbook` | `archive_playbook(playbook_id: UUID)` | @@ -312,38 +300,4 @@ real tools live in their agent_sdk drivers, not role_config. | `read_a2a` | `read_a2a(see do_server)` | | `notify_list` | `notify_list(unread_only: bool = True, pending_ack_only: bool = False, limit: int = 20)` | | `notify_get` | `notify_get(notification_id: UUID)` | -| `task_time` | `task_time(task_id: UUID)` | - -## devops - -### Flow verbs - -| Verb | Body schema | -|------|-------------| -| `claim_gate_review` | `claim_gate_review(task_id: UUID)` | -| `give_me_work` | `give_me_work()` | -| `i_am_done` | `i_am_done(task_id: UUID, notes: str = '', resolved_findings: list[ResolvedFindingInput] = PydanticUndefined)` | -| `i_am_idle` | `i_am_idle()` | -| `i_will_work_on` | `i_will_work_on(task_id: UUID, plan: str | None = None, steps: list[str | str] = PydanticUndefined, technical_considerations: list[str] = PydanticUndefined, risks: list[str | str] = PydanticUndefined, open_questions: list[str | str | bool] = PydanticUndefined)` | -| `open_pr` | `open_pr(task_id: UUID)` | -| `pr_fail` | `pr_fail(task_id: UUID, issues: list[str] = PydanticUndefined, findings: list[str | Any] = PydanticUndefined)` | -| `pr_pass` | `pr_pass(task_id: UUID, notes: str)` | -| `record_devops_review` | `record_devops_review(task_id: UUID, notes: str)` | -| `sync_branch` | `sync_branch(task_id: UUID, stash: bool = False)` | -| `unclaim` | `unclaim(task_id: UUID)` | - -### Content (do) tools - -| Tool | Body schema | -|------|-------------| -| `commit` | `commit(message: str, files: list[str] | None = None)` | -| `note` | `note(text: str, scope: str = 'note', task_id: UUID | None = None, title: str | None = None, context: str = '', options: list[str | str] | None = None, chosen: str = '', rationale: str = '', consequences: list[str] | None = None, what_done: str = '', what_learned: str = '', what_struggled: str = '', next_steps: list[str] | None = None, section: str | Any | None = None, done: str = '', next: str = '', where_to_look: list[str] | None = None)` | -| `evidence` | `evidence(task_id: UUID)` | -| `dm` | `dm(recipient: str, text: str, task_id: UUID | None = None, skill: str | None = None)` | -| `read_messages` | `read_messages()` | -| `read_a2a` | `read_a2a(see do_server)` | -| `notify_list` | `notify_list(unread_only: bool = True, pending_ack_only: bool = False, limit: int = 20)` | -| `notify_get` | `notify_get(notification_id: UUID)` | -| `notify_ack` | `notify_ack(notification_id: UUID)` | -| `task_time` | `task_time(task_id: UUID)` | diff --git a/docs/rag/lifecycle/intent-verbs.md b/docs/rag/lifecycle/intent-verbs.md index ed72bfb19..d7558b979 100644 --- a/docs/rag/lifecycle/intent-verbs.md +++ b/docs/rag/lifecycle/intent-verbs.md @@ -1,16 +1,5 @@ # Intent Verbs (gateway-facing surface) -## cancel_leaf - -Close a zero-diff leaf: a delegated child whose findings a merged sibling already fixed, so no legitimate diff remains and i_am_done/complete can never accept it. Refuses a target with any children of its own (not a leaf), any commit ahead of its base, or an open PR - those go through the normal review path, not this. `reason` is recorded as your journal:decision and on the task's audit trail. Cell PM: only your own coordination task's children. Main PM: any root's descendant. - -**Allowed roles:** cell_pm, main_pm - -**Composes:** (no atomic actions) - -**Preconditions:** non_terminal - - ## claim_doc_task Claim awaiting_documentation. Returns evidence inline. @@ -24,7 +13,7 @@ Claim awaiting_documentation. Returns evidence inline. Claim an assembled-PR review task (awaiting_pr_review) WITHOUT transitioning it — mirrors QA's claim_review. The assembled diff and the parent task's acceptance criteria are returned inline. -**Allowed roles:** devops, pr_reviewer +**Allowed roles:** pr_reviewer **Composes:** (no atomic actions) @@ -109,7 +98,7 @@ Fail QA with concrete issues. Transitions to needs_revision. Return your most-actionable task or signal idle. -**Allowed roles:** cell_pm, developer, devops, documenter, main_pm, pr_reviewer, qa +**Allowed roles:** cell_pm, developer, documenter, main_pm, pr_reviewer, qa **Composes:** (no atomic actions) @@ -127,7 +116,7 @@ Escalate to PM. Logs a struggle journal entry. Submit work for QA. Auto-runs in_progress->verifying then verifying->awaiting_qa. Strict - PR must be open (call open_pr first) and >=1 commit. -**Allowed roles:** developer, devops +**Allowed roles:** developer **Composes:** submit_verification → submit_qa @@ -138,7 +127,7 @@ Submit work for QA. Auto-runs in_progress->verifying then verifying->awaiting_qa Signal you have no active work. PMs auto-pause owned in_progress tasks. -**Allowed roles:** auditor, cell_pm, developer, devops, documenter, head_marketing, main_pm, pr_reviewer, product_owner, prompter, qa, secretary +**Allowed roles:** auditor, cell_pm, developer, documenter, head_marketing, main_pm, pr_reviewer, product_owner, prompter, qa, secretary **Composes:** (no atomic actions) @@ -158,7 +147,7 @@ PM mirror of i_will_work_on for parent tasks. Claim, plan, transition to in_prog **Allowed roles:** cell_pm, main_pm -**Composes:** claim → set_plan +**Composes:** claim → set_plan → start **Preconditions:** plan @@ -167,9 +156,9 @@ PM mirror of i_will_work_on for parent tasks. Claim, plan, transition to in_prog Claim a task, set the plan, and transition to in_progress. Atomic - preconditions checked before any state mutation. -**Allowed roles:** developer, devops +**Allowed roles:** developer -**Composes:** claim → set_plan +**Composes:** claim → set_plan → start **Preconditions:** plan @@ -178,7 +167,7 @@ Claim a task, set the plan, and transition to in_progress. Atomic - precondition Push the branch and open a PR. Atomic - preconditions (assignee, >=1 commit, no prior PR) checked BEFORE any git operation. After success, call i_am_done. -**Allowed roles:** developer, devops +**Allowed roles:** developer **Composes:** (no atomic actions) @@ -209,7 +198,7 @@ Post one complete change-request to the external PR and finish the review task. Fail the assembled-PR review with concrete issues. Transitions awaiting_pr_review -> needs_revision, routed back like a QA fail. -**Allowed roles:** devops, pr_reviewer +**Allowed roles:** pr_reviewer **Composes:** pr_fail @@ -218,7 +207,7 @@ Fail the assembled-PR review with concrete issues. Transitions awaiting_pr_revie Pass the assembled-PR review. Transitions awaiting_pr_review -> awaiting_pm_review so the PM can merge. -**Allowed roles:** devops, pr_reviewer +**Allowed roles:** pr_reviewer **Composes:** pr_pass @@ -232,15 +221,6 @@ Hand a claimed/in_progress task to another developer in your own cell. The branc **Composes:** (no atomic actions) -## record_devops_review - -Record the DevOps infra-review verdict (pass) on an assembled-PR gate task WITHOUT transitioning it: the primary reviewer's pr_pass then composes. Use pr_fail instead to reject the PR. - -**Allowed roles:** devops - -**Composes:** (no atomic actions) - - ## request_changes Reject the merge review with concrete issues. Transitions awaiting_pm_review -> needs_revision, routed back like a QA fail (original developer for a leaf, revision PM for an assembled task). Use this for an AC/scope violation caught at merge review — never i_am_blocked/escalate, which have no revision routing. @@ -287,7 +267,7 @@ Cell PM opens the cell→root PR and moves the cell task into the PR-review gate Rebase your task's branch onto its current base THROUGH the gate (raw git is denied). Use when your branch has fallen behind its base — e.g. a sibling task's PR merged into the parent branch while you worked. Fetches origin, rebases head onto base, and force-pushes (with-lease). No DB state change. On conflicts the rebase is aborted and the conflicted files are returned — resolve by hand, commit, then sync_branch again. Pass stash=True to auto-stash uncommitted changes instead of refusing DIRTY_WORKSPACE; they are restored after the rebase. -**Allowed roles:** developer, devops +**Allowed roles:** developer **Composes:** (no atomic actions) @@ -325,7 +305,7 @@ PM unblocks a blocked task; restores pre-block state. Voluntarily release a claim back to pending. The work-in-progress branch is preserved. A PR reviewer who claimed an external review (in_progress) or a gate review (awaiting_pr_review) and cannot finish releases the claim here rather than wedging the lane until the stale-claim reaper. -**Allowed roles:** cell_pm, developer, devops, documenter, main_pm, pr_reviewer, qa +**Allowed roles:** cell_pm, developer, documenter, main_pm, pr_reviewer, qa **Composes:** (no atomic actions) diff --git a/docs/rag/lifecycle/status-transitions.md b/docs/rag/lifecycle/status-transitions.md index 766bdba3d..d7f39fdf1 100644 --- a/docs/rag/lifecycle/status-transitions.md +++ b/docs/rag/lifecycle/status-transitions.md @@ -13,10 +13,10 @@ | awaiting_pm_review | cancelled | cancel | cell_pm, ceo, main_pm | | awaiting_pm_review | completed | complete | cell_pm, main_pm | | awaiting_pm_review | needs_revision | request_changes | cell_pm, main_pm | -| awaiting_pr_review | awaiting_pm_review | pr_pass | devops, pr_reviewer | +| awaiting_pr_review | awaiting_pm_review | pr_pass | pr_reviewer | | awaiting_pr_review | cancelled | cancel | cell_pm, ceo, main_pm | -| awaiting_pr_review | claimed | claim | devops, pr_reviewer | -| awaiting_pr_review | needs_revision | pr_fail | devops, pr_reviewer | +| awaiting_pr_review | claimed | claim | pr_reviewer | +| awaiting_pr_review | needs_revision | pr_fail | pr_reviewer | | awaiting_qa | awaiting_documentation | qa_pass | qa | | awaiting_qa | cancelled | cancel | cell_pm, ceo, main_pm | | awaiting_qa | claimed | claim | qa | diff --git a/panel/lib/lifecycle.json b/panel/lib/lifecycle.json index e6d93f12f..c673a92ee 100644 --- a/panel/lib/lifecycle.json +++ b/panel/lib/lifecycle.json @@ -10,11 +10,6 @@ "needs_revision", "pending" ], - "devops": [ - "awaiting_pr_review", - "needs_revision", - "pending" - ], "documenter": [ "awaiting_documentation", "pending" @@ -34,17 +29,6 @@ ] }, "intents": [ - { - "allowed_roles": [ - "cell_pm", - "main_pm" - ], - "composes": [], - "description": "Close a zero-diff leaf: a delegated child whose findings a merged sibling already fixed, so no legitimate diff remains and i_am_done/complete can never accept it. Refuses a target with any children of its own (not a leaf), any commit ahead of its base, or an open PR - those go through the normal review path, not this. `reason` is recorded as your journal:decision and on the task's audit trail. Cell PM: only your own coordination task's children. Main PM: any root's descendant.", - "name": "cancel_leaf", - "pre_side_effects": [], - "side_effects": [] - }, { "allowed_roles": [ "documenter" @@ -57,7 +41,6 @@ }, { "allowed_roles": [ - "devops", "pr_reviewer" ], "composes": [], @@ -167,7 +150,6 @@ "allowed_roles": [ "cell_pm", "developer", - "devops", "documenter", "main_pm", "pr_reviewer", @@ -195,8 +177,7 @@ }, { "allowed_roles": [ - "developer", - "devops" + "developer" ], "composes": [ "submit_verification", @@ -212,7 +193,6 @@ "auditor", "cell_pm", "developer", - "devops", "documenter", "head_marketing", "main_pm", @@ -247,7 +227,8 @@ ], "composes": [ "claim", - "set_plan" + "set_plan", + "start" ], "description": "PM mirror of i_will_work_on for parent tasks. Claim, plan, transition to in_progress; from there delegate subtasks.", "name": "i_will_plan", @@ -256,12 +237,12 @@ }, { "allowed_roles": [ - "developer", - "devops" + "developer" ], "composes": [ "claim", - "set_plan" + "set_plan", + "start" ], "description": "Claim a task, set the plan, and transition to in_progress. Atomic - preconditions checked before any state mutation.", "name": "i_will_work_on", @@ -270,8 +251,7 @@ }, { "allowed_roles": [ - "developer", - "devops" + "developer" ], "composes": [], "description": "Push the branch and open a PR. Atomic - preconditions (assignee, >=1 commit, no prior PR) checked BEFORE any git operation. After success, call i_am_done.", @@ -308,7 +288,6 @@ }, { "allowed_roles": [ - "devops", "pr_reviewer" ], "composes": [ @@ -321,7 +300,6 @@ }, { "allowed_roles": [ - "devops", "pr_reviewer" ], "composes": [ @@ -342,16 +320,6 @@ "pre_side_effects": [], "side_effects": [] }, - { - "allowed_roles": [ - "devops" - ], - "composes": [], - "description": "Record the DevOps infra-review verdict (pass) on an assembled-PR gate task WITHOUT transitioning it: the primary reviewer's pr_pass then composes. Use pr_fail instead to reject the PR.", - "name": "record_devops_review", - "pre_side_effects": [], - "side_effects": [] - }, { "allowed_roles": [ "cell_pm", @@ -411,8 +379,7 @@ }, { "allowed_roles": [ - "developer", - "devops" + "developer" ], "composes": [], "description": "Rebase your task's branch onto its current base THROUGH the gate (raw git is denied). Use when your branch has fallen behind its base \u2014 e.g. a sibling task's PR merged into the parent branch while you worked. Fetches origin, rebases head onto base, and force-pushes (with-lease). No DB state change. On conflicts the rebase is aborted and the conflicted files are returned \u2014 resolve by hand, commit, then sync_branch again. Pass stash=True to auto-stash uncommitted changes instead of refusing DIRTY_WORKSPACE; they are restored after the rebase.", @@ -461,7 +428,6 @@ "allowed_roles": [ "cell_pm", "developer", - "devops", "documenter", "main_pm", "pr_reviewer", @@ -584,7 +550,6 @@ { "action": "pr_pass", "roles": [ - "devops", "pr_reviewer" ], "source": "awaiting_pr_review", @@ -603,7 +568,6 @@ { "action": "claim", "roles": [ - "devops", "pr_reviewer" ], "source": "awaiting_pr_review", @@ -612,7 +576,6 @@ { "action": "pr_fail", "roles": [ - "devops", "pr_reviewer" ], "source": "awaiting_pr_review",