diff --git a/agents/prompts/_generated/lifecycle-pr_reviewer.md b/agents/prompts/_generated/lifecycle-pr_reviewer.md new file mode 100644 index 00000000..18f425f0 --- /dev/null +++ b/agents/prompts/_generated/lifecycle-pr_reviewer.md @@ -0,0 +1,9 @@ +# Verbs available to your role (pr_reviewer) + +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. + +- **claim_pr_review**: Claim an inbound external-PR review task and start work. pending -> claimed -> in_progress. +- **give_me_work**: Return your most-actionable task or signal idle. +- **i_am_idle**: Signal you have no active work. PMs auto-pause owned in_progress tasks. +- **post_pr_review**: Post one complete change-request to the external PR and finish the review task. in_progress -> completed. diff --git a/agents/prompts/_generated/pr_reviewer.md b/agents/prompts/_generated/pr_reviewer.md new file mode 100644 index 00000000..87e5d5e3 --- /dev/null +++ b/agents/prompts/_generated/pr_reviewer.md @@ -0,0 +1,23 @@ + + + +## Verbs available to you (autogenerated source of truth) + +### Flow verbs + +| Verb | Body schema | +|------|-------------| +| `claim_pr_review` | `claim_pr_review(unknown — no Pydantic schema)` | +| `give_me_work` | `give_me_work()` | +| `i_am_idle` | `i_am_idle()` | +| `post_pr_review` | `post_pr_review(unknown — no Pydantic schema)` | + +### Content (do) tools + +| Tool | Body schema | +|------|-------------| +| `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)` | +| `evidence` | `evidence(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)` | +| `channels` | `channels()` | diff --git a/agents/prompts/_generated/verbs.md b/agents/prompts/_generated/verbs.md index 5ae28aac..3a896542 100644 --- a/agents/prompts/_generated/verbs.md +++ b/agents/prompts/_generated/verbs.md @@ -249,3 +249,24 @@ real tools live in their agent_sdk drivers, not role_config. | `notify_get` | `notify_get(notification_id: UUID)` | | `channels` | `channels()` | +## pr_reviewer + +### Flow verbs + +| Verb | Body schema | +|------|-------------| +| `claim_pr_review` | `claim_pr_review(unknown — no Pydantic schema)` | +| `give_me_work` | `give_me_work()` | +| `i_am_idle` | `i_am_idle()` | +| `post_pr_review` | `post_pr_review(unknown — no Pydantic schema)` | + +### Content (do) tools + +| Tool | Body schema | +|------|-------------| +| `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)` | +| `evidence` | `evidence(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)` | +| `channels` | `channels()` | + diff --git a/agents/prompts/roles/pr_reviewer.md b/agents/prompts/roles/pr_reviewer.md new file mode 100644 index 00000000..ce8d955e --- /dev/null +++ b/agents/prompts/roles/pr_reviewer.md @@ -0,0 +1,49 @@ +# PR Reviewer + +## Identity + +You review inbound pull requests the organization did **not** author — external and fork contributions (the "Corey" PRs that would otherwise sit unreviewed). You read the PR diff, judge it adversarially against the task's acceptance criteria and the codebase's standards, and post **exactly one complete change-request** with per-criterion findings. One thorough review in one shot — not a trickle of comments. + +You are **read-only**. You do NOT write code, you do NOT fix the PR yourself, you do NOT merge, and you NEVER push to the contributor's fork. If the work should be finished, the org supersedes it with its own PR through a separate dev-cell flow — that is not your job. Your job is the review. + +## The trust gate (non-negotiable) + +The PR is from an outside contributor: its code is **untrusted**. Until a human has confirmed the PR (`confirmed_by_human`), you do NOT fetch, check out, or execute any of the contributor's code — no `make quality`, no tests, no running anything from the branch. Your first-pass review is **read-only**: read the diff, reason about it. Running untrusted code before human confirmation is a security violation, not a thoroughness win. + +## Inputs you start with + +- Your `task_id` and `agent_id` are pre-baked into the gateway session. +- The review task carries the contributor PR's `pr_number` and `pr_url` (its `source` is `external_pr`). +- `claim_pr_review`'s response includes the PR metadata and the diff you need to review. + +## Your verbs + +| Verb | What it does | Preconditions | +|---|---|---| +| `give_me_work()` | Returns an external-PR review task or `idle`. | None. | +| `claim_pr_review(task_id)` | Claims the review task and starts it. `pending → claimed → in_progress`. Returns the PR diff inline. | Task is an `external_pr` review task in `pending`. | +| `post_pr_review(task_id, ...)` | Posts ONE complete change-request to the PR and finishes the review task. `in_progress → completed`. | Task claimed by you; findings cover every relevant criterion. | +| `note(text, scope?)` | Journal entry. Record your reasoning. | None. | +| `evidence(task_id)` | Re-fetch the PR diff if you need more detail. | None. | +| `roboco_git_diff` / `roboco_git_log` / `roboco_git_status` / `roboco_git_branches` | Read-only git inspection. | None. | +| `i_am_idle()` | No review work right now. | No active review claim. | + +## Workflow + +1. `give_me_work()` → an `external_pr` review task. +2. `claim_pr_review(task_id)` → read the diff in full. +3. Review the diff **read-only**. Do NOT run the contributor's code unless the PR is human-confirmed. +4. For each acceptance criterion and each correctness/security/quality concern, find the specific evidence (file/line) and form a concrete, actionable finding. +5. `note(scope='learning', ...)` capturing what the review surfaced. +6. `post_pr_review(task_id, ...)` → one complete change-request, per-criterion findings, each referencing file + line + expected vs actual. + +## Anti-patterns + +- ❌ Running, building, or testing the contributor's code before `confirmed_by_human`. Read-only first — always. +- ❌ Pushing to the contributor's fork, or editing/merging the PR. You review; you never write or merge. +- ❌ A trickle of vague comments. Post ONE complete review; each finding names file + line + expected vs actual. +- ❌ Approving without reading the full diff. + +## When the gateway returns an error + +Errors include `error`, `message`, `remediate`, `missing`. Read `remediate` — it names the literal next call. Fix that one piece and retry the same verb. diff --git a/alembic/versions/037_agentrole_pr_reviewer.py b/alembic/versions/037_agentrole_pr_reviewer.py new file mode 100644 index 00000000..7e28edbd --- /dev/null +++ b/alembic/versions/037_agentrole_pr_reviewer.py @@ -0,0 +1,32 @@ +"""Add 'pr_reviewer' to the postgres agentrole enum. + +The PR reviewer (``Role.PR_REVIEWER`` in foundation/identity) is a read-only +agent that reviews inbound external/fork PRs. Seeding/spawning its agent row +requires the postgres ``agentrole`` enum to carry the value. Mirrors migration +034's pattern. + +Revision ID: 037_agentrole_pr_reviewer +Revises: 036_ac_ids_and_parent_refs +Create Date: 2026-06-16 +""" + +from __future__ import annotations + +from alembic import op + +revision = "037_agentrole_pr_reviewer" +down_revision = "036_ac_ids_and_parent_refs" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # Unguarded (renders in offline --sql so the enum-migration-parity test + # sees it) and idempotent. PG 16 permits ADD VALUE inside a transaction. + op.execute("ALTER TYPE agentrole ADD VALUE IF NOT EXISTS 'pr_reviewer'") + + +def downgrade() -> None: + # Postgres does not support removing enum values without a destructive + # type recreation. Forward-only by design (see migration 034). + pass diff --git a/docs/rag/lifecycle/intent-verbs.md b/docs/rag/lifecycle/intent-verbs.md index 165a366c..b1db0409 100644 --- a/docs/rag/lifecycle/intent-verbs.md +++ b/docs/rag/lifecycle/intent-verbs.md @@ -9,6 +9,15 @@ Claim awaiting_documentation. Returns evidence inline. **Composes:** (no atomic actions) +## claim_pr_review + +Claim an inbound external-PR review task and start work. pending -> claimed -> in_progress. + +**Allowed roles:** pr_reviewer + +**Composes:** claim → start + + ## claim_review Claim a task in awaiting_qa for review. Returns evidence inline. @@ -69,7 +78,7 @@ Fail QA with concrete issues. Transitions to needs_revision. Return your most-actionable task or signal idle. -**Allowed roles:** cell_pm, developer, documenter, main_pm, qa +**Allowed roles:** cell_pm, developer, documenter, main_pm, pr_reviewer, qa **Composes:** (no atomic actions) @@ -98,7 +107,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, documenter, head_marketing, main_pm, 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) @@ -156,6 +165,15 @@ Pass QA. Transitions awaiting_qa -> awaiting_documentation. **Composes:** qa_pass +## post_pr_review + +Post one complete change-request to the external PR and finish the review task. in_progress -> completed. + +**Allowed roles:** pr_reviewer + +**Composes:** pr_review_done + + ## reassign Hand a claimed/in_progress task to another developer in your own cell. The branch is keyed to the task (not the agent), so it is preserved — the new developer continues the work-in-progress. No status change. diff --git a/docs/rag/lifecycle/status-transitions.md b/docs/rag/lifecycle/status-transitions.md index 0ca61e91..42082b85 100644 --- a/docs/rag/lifecycle/status-transitions.md +++ b/docs/rag/lifecycle/status-transitions.md @@ -26,6 +26,7 @@ | in_progress | awaiting_pm_review | submit_pm_review | any | | in_progress | blocked | block | any | | in_progress | cancelled | cancel | cell_pm, ceo, main_pm | +| in_progress | completed | pr_review_done | pr_reviewer | | in_progress | paused | pause | any | | in_progress | verifying | submit_verification | any | | needs_revision | cancelled | cancel | cell_pm, ceo, main_pm | diff --git a/panel/lib/lifecycle.json b/panel/lib/lifecycle.json index 8c710677..be0a49fc 100644 --- a/panel/lib/lifecycle.json +++ b/panel/lib/lifecycle.json @@ -17,6 +17,9 @@ "main_pm": [ "pending" ], + "pr_reviewer": [ + "pending" + ], "product_owner": [], "qa": [ "awaiting_qa" @@ -33,6 +36,19 @@ "pre_side_effects": [], "side_effects": [] }, + { + "allowed_roles": [ + "pr_reviewer" + ], + "composes": [ + "claim", + "start" + ], + "description": "Claim an inbound external-PR review task and start work. pending -> claimed -> in_progress.", + "name": "claim_pr_review", + "pre_side_effects": [], + "side_effects": [] + }, { "allowed_roles": [ "qa" @@ -114,6 +130,7 @@ "developer", "documenter", "main_pm", + "pr_reviewer", "qa" ], "composes": [], @@ -157,6 +174,7 @@ "documenter", "head_marketing", "main_pm", + "pr_reviewer", "product_owner", "prompter", "qa", @@ -234,6 +252,18 @@ "pre_side_effects": [], "side_effects": [] }, + { + "allowed_roles": [ + "pr_reviewer" + ], + "composes": [ + "pr_review_done" + ], + "description": "Post one complete change-request to the external PR and finish the review task. in_progress -> completed.", + "name": "post_pr_review", + "pre_side_effects": [], + "side_effects": [] + }, { "allowed_roles": [ "cell_pm" @@ -528,6 +558,14 @@ "source": "in_progress", "target": "cancelled" }, + { + "action": "pr_review_done", + "roles": [ + "pr_reviewer" + ], + "source": "in_progress", + "target": "completed" + }, { "action": "pause", "roles": null, diff --git a/roboco/agents/factories/_base.py b/roboco/agents/factories/_base.py index 65c66575..bbdaf377 100644 --- a/roboco/agents/factories/_base.py +++ b/roboco/agents/factories/_base.py @@ -67,6 +67,8 @@ _ROLE_LAYER_MAP: dict[str, str] = { "prompter": "prompter.md", # Secretary — CEO's chief-of-staff live-session role prompt. "secretary": "secretary.md", + # PR reviewer — reviews inbound external/fork PRs, posts one change-request. + "pr_reviewer": "pr_reviewer.md", } _TEAM_LAYER_MAP: dict[str, str] = { @@ -137,6 +139,7 @@ _ROLE_BUILTIN_TOOLS: dict[str, tuple[str, ...]] = { "product_owner": _BUILTIN_TOOLS_COMMON, "head_marketing": _BUILTIN_TOOLS_COMMON, "auditor": _BUILTIN_TOOLS_COMMON, + "pr_reviewer": _BUILTIN_TOOLS_COMMON, } diff --git a/roboco/foundation/identity.py b/roboco/foundation/identity.py index f0abf30a..ecd49f6b 100644 --- a/roboco/foundation/identity.py +++ b/roboco/foundation/identity.py @@ -28,6 +28,7 @@ class Role(StrEnum): PRODUCT_OWNER = "product_owner" HEAD_MARKETING = "head_marketing" AUDITOR = "auditor" + PR_REVIEWER = "pr_reviewer" # reviews PRs (inbound external/fork PRs first) PROMPTER = "prompter" # intake interviewer — talks only to the human, drafts tasks SECRETARY = "secretary" # CEO's chief-of-staff — acts only under CEO command CEO = "ceo" @@ -211,6 +212,16 @@ AGENTS: dict[str, AgentRow] = { Team.BOARD, _u("00000000-0000-0000-0004-000000000006"), ), + # PR reviewer — a single global, read-only agent. Reviews PRs (inbound + # external/fork PRs first) and posts one change-request; never writes code. + # Like the auditor it is board-team and read-only, but it DOES act on review + # tasks. + "pr-reviewer-1": AgentRow( + "pr-reviewer-1", + Role.PR_REVIEWER, + Team.BOARD, + _u("00000000-0000-0000-0004-000000000007"), + ), } @@ -220,6 +231,7 @@ BOARD_ROLES: frozenset[Role] = frozenset( {Role.PRODUCT_OWNER, Role.HEAD_MARKETING, Role.AUDITOR} ) DEV_ROLES: frozenset[Role] = frozenset({Role.DEVELOPER}) +REVIEWER_ROLES: frozenset[Role] = frozenset({Role.PR_REVIEWER}) ALL_ROLES: frozenset[Role] = frozenset(Role) # Hierarchical level for "X or above" checks. SYSTEM is the sentinel below all @@ -235,6 +247,7 @@ ROLE_LEVEL: dict[Role, RoleLevel] = { Role.PRODUCT_OWNER: RoleLevel.BOARD, Role.HEAD_MARKETING: RoleLevel.BOARD, Role.AUDITOR: RoleLevel.AUDITOR, + Role.PR_REVIEWER: RoleLevel.QA, # a code reviewer, peer to QA in authority Role.PROMPTER: RoleLevel.INTAKE, Role.SECRETARY: RoleLevel.BOARD, Role.CEO: RoleLevel.CEO, diff --git a/roboco/foundation/policy/journaling.py b/roboco/foundation/policy/journaling.py index 9808c743..5ce5e72c 100644 --- a/roboco/foundation/policy/journaling.py +++ b/roboco/foundation/policy/journaling.py @@ -63,6 +63,10 @@ ROLE_READ_TIERS: dict[Role, ReadTier] = { Role.PROMPTER: ReadTier.OWN, # Secretary advises the CEO — reads everything to give an informed picture. Role.SECRETARY: ReadTier.ALL, + # PR reviewer is global (reviews any cell's PRs). Cross-cell tier so it can + # read developer / QA / cell-PM journals to understand the internal intent + # behind a change — like QA does — without board-private access. + Role.PR_REVIEWER: ReadTier.ALL_CELLS, Role.CEO: ReadTier.ALL, } diff --git a/roboco/foundation/policy/lifecycle.py b/roboco/foundation/policy/lifecycle.py index 23e80758..cfb3373a 100644 --- a/roboco/foundation/policy/lifecycle.py +++ b/roboco/foundation/policy/lifecycle.py @@ -252,6 +252,13 @@ _STATUS_TRANSITIONS: tuple[StatusTransition, ...] = ( # Dev verify + submit StatusTransition(Status.IN_PROGRESS, Status.VERIFYING, "submit_verification", None), StatusTransition(Status.VERIFYING, Status.AWAITING_QA, "submit_qa", None), + # PR reviewer posts its change-request and the review task is done + StatusTransition( + Status.IN_PROGRESS, + Status.COMPLETED, + "pr_review_done", + frozenset({Role.PR_REVIEWER}), + ), # QA pass / fail StatusTransition( Status.AWAITING_QA, @@ -363,7 +370,9 @@ _ATOMIC_ACTIONS: dict[str, ActionSpec] = { # checks consistency between them. "claim": ActionSpec( name="claim", - allowed_roles=frozenset(_DEV_ROLES | _QA_ROLES | _DOC_ROLES | _PM_ROLES), + allowed_roles=frozenset( + _DEV_ROLES | _QA_ROLES | _DOC_ROLES | _PM_ROLES | {Role.PR_REVIEWER} + ), source_statuses=frozenset( { Status.PENDING, @@ -380,7 +389,9 @@ _ATOMIC_ACTIONS: dict[str, ActionSpec] = { ), "start": ActionSpec( name="start", - allowed_roles=frozenset(_DEV_ROLES | _QA_ROLES | _DOC_ROLES | _PM_ROLES), + allowed_roles=frozenset( + _DEV_ROLES | _QA_ROLES | _DOC_ROLES | _PM_ROLES | {Role.PR_REVIEWER} + ), source_statuses=frozenset({Status.CLAIMED}), target_status=Status.IN_PROGRESS, allowed_task_types=None, @@ -478,6 +489,16 @@ _ATOMIC_ACTIONS: dict[str, ActionSpec] = { self_review_block=True, needs_team_match=True, ), + "pr_review_done": ActionSpec( + name="pr_review_done", + allowed_roles=frozenset({Role.PR_REVIEWER}), + source_statuses=frozenset({Status.IN_PROGRESS}), + target_status=Status.COMPLETED, + allowed_task_types=None, + preconditions=(), + self_review_block=False, + needs_team_match=False, + ), "docs_complete": ActionSpec( name="docs_complete", allowed_roles=_DOC_ROLES, @@ -585,6 +606,7 @@ CLAIM_RULES: dict[Role, frozenset[Status]] = { Role.PRODUCT_OWNER: frozenset(), Role.HEAD_MARKETING: frozenset(), Role.AUDITOR: frozenset(), + Role.PR_REVIEWER: frozenset({Status.PENDING}), Role.CEO: frozenset(), } @@ -614,6 +636,7 @@ ROLE_TEAM_RULES: dict[str, str | None] = { "product-owner": None, "head-marketing": None, "auditor": None, + "pr-reviewer-1": None, "ceo": None, } @@ -773,6 +796,7 @@ _INTENT_VERBS: dict[str, IntentSpec] = { Role.DOCUMENTER, Role.CELL_PM, Role.MAIN_PM, + Role.PR_REVIEWER, } ), description="Return your most-actionable task or signal idle.", @@ -912,6 +936,7 @@ _INTENT_VERBS: dict[str, IntentSpec] = { Role.AUDITOR, Role.PROMPTER, Role.SECRETARY, + Role.PR_REVIEWER, } ), description=( @@ -950,6 +975,33 @@ _INTENT_VERBS: dict[str, IntentSpec] = { side_effects=(), next_hint=_next_hint_dev_revise, ), + # PR reviewer verbs (inbound external/fork PRs — distinct from QA's surface) + "claim_pr_review": IntentSpec( + name="claim_pr_review", + allowed_roles=frozenset({Role.PR_REVIEWER}), + description=( + "Claim an inbound external-PR review task and start work." + " pending -> claimed -> in_progress." + ), + composes=("claim", "start"), + extra_preconditions=(), + side_effects=(), + next_hint=lambda _t: ( + "review the contributor's diff, then post_pr_review(task_id, ...)" + ), + ), + "post_pr_review": IntentSpec( + name="post_pr_review", + allowed_roles=frozenset({Role.PR_REVIEWER}), + description=( + "Post one complete change-request to the external PR and finish the" + " review task. in_progress -> completed." + ), + composes=("pr_review_done",), + extra_preconditions=(), + side_effects=(), + next_hint=_next_hint_idle, + ), # Phase 3: documenter verbs "claim_doc_task": IntentSpec( name="claim_doc_task", diff --git a/roboco/foundation/policy/tracing.py b/roboco/foundation/policy/tracing.py index 5e61138f..0533c459 100644 --- a/roboco/foundation/policy/tracing.py +++ b/roboco/foundation/policy/tracing.py @@ -263,6 +263,8 @@ VERB_REQUIREMENTS: dict[str, frozenset[Requirement]] = { Requirement.JOURNAL_LEARNING, } ), + # PR reviewer posts its change-request — must record a learning entry first. + "post_pr_review": frozenset({Requirement.JOURNAL_LEARNING}), # Doc submit. "i_documented": frozenset( { @@ -313,6 +315,8 @@ VERBS_WITHOUT_TRACING: frozenset[str] = frozenset( "resume", # pure state move paused→in_progress # claim_review's tracing applies on pass_review / fail_review. "claim_review", + # claim_pr_review's tracing applies on post_pr_review. + "claim_pr_review", # claim_doc_task's tracing applies on i_documented. "claim_doc_task", # open_pr is a mechanical push+open; preconditions are inline. diff --git a/roboco/runtime/orchestrator.py b/roboco/runtime/orchestrator.py index 65c3edcf..bcf66ab4 100644 --- a/roboco/runtime/orchestrator.py +++ b/roboco/runtime/orchestrator.py @@ -2399,6 +2399,7 @@ class AgentOrchestrator: "product_owner": _COMMON_BUILTIN_TOOLS, "head_marketing": _COMMON_BUILTIN_TOOLS, "auditor": _COMMON_BUILTIN_TOOLS, + "pr_reviewer": _COMMON_BUILTIN_TOOLS, } def _build_tool_load_block(self, role: str) -> str: diff --git a/roboco/seeds/initial_data.py b/roboco/seeds/initial_data.py index 55d56aac..66423756 100644 --- a/roboco/seeds/initial_data.py +++ b/roboco/seeds/initial_data.py @@ -109,6 +109,7 @@ _AGENT_PRESENTATION: dict[str, dict[str, Any]] = { "auditor": {"name": "Auditor"}, "intake-1": {"name": "Intake"}, "secretary-1": {"name": "Secretary"}, + "pr-reviewer-1": {"name": "PR Reviewer"}, } diff --git a/roboco/services/gateway/role_config.py b/roboco/services/gateway/role_config.py index d5de7f99..c63cd8e7 100644 --- a/roboco/services/gateway/role_config.py +++ b/roboco/services/gateway/role_config.py @@ -115,6 +115,13 @@ _AUDITOR_FLOW = spec.intents_for_role(spec.Role.AUDITOR) # no ack (silent observer — wouldn't ack notifications). channels for read map. _AUDITOR_DO = ("note", "evidence", "notify_list", "notify_get", *_CHANNEL_DISCOVERY) +# PR reviewer: a read-only reviewer of inbound external/fork PRs. Flow verbs come +# from the lifecycle spec (a dedicated review trio, not QA's). It reads diffs and +# records findings (note/evidence); the change-request is posted server-side, so +# it has no outward agent comms (no say/dm). +_PR_REVIEWER_FLOW = spec.intents_for_role(spec.Role.PR_REVIEWER) +_PR_REVIEWER_DO = ("note", "evidence", "notify_list", "notify_get", *_CHANNEL_DISCOVERY) + _PROMPTER_FLOW = spec.intents_for_role( spec.Role.PROMPTER ) # none — not a lifecycle role @@ -197,6 +204,17 @@ ROLE_CONFIGS: dict[str, RoleConfig] = { allows_subagent=False, description="Silent observer; reads but never communicates outwardly.", ), + "pr_reviewer": RoleConfig( + role="pr_reviewer", + flow_tools=_PR_REVIEWER_FLOW, + do_tools=_PR_REVIEWER_DO, + allows_write=False, + allows_subagent=False, + description=( + "Reviews inbound external/fork PRs and posts one change-request. " + "Read-only; never writes code or merges." + ), + ), "prompter": RoleConfig( role="prompter", flow_tools=_PROMPTER_FLOW, diff --git a/tests/foundation/test_identity.py b/tests/foundation/test_identity.py index e25e9595..ef2df6e0 100644 --- a/tests/foundation/test_identity.py +++ b/tests/foundation/test_identity.py @@ -20,6 +20,7 @@ def test_role_enum_has_every_role_inc_system() -> None: "product_owner", "head_marketing", "auditor", + "pr_reviewer", "prompter", "secretary", "ceo", @@ -84,6 +85,7 @@ def test_agents_catalog_has_all_seed_slugs() -> None: "auditor", "intake-1", "secretary-1", + "pr-reviewer-1", } actual = set(identity.AGENTS.keys()) assert actual == expected_slugs, f"agent catalog drift: {actual ^ expected_slugs}" diff --git a/tests/foundation/test_lifecycle_spec.py b/tests/foundation/test_lifecycle_spec.py index 56c289bc..c4d6a929 100644 --- a/tests/foundation/test_lifecycle_spec.py +++ b/tests/foundation/test_lifecycle_spec.py @@ -32,6 +32,7 @@ def test_role_enum_has_every_pre_gateway_role() -> None: "product_owner", "head_marketing", "auditor", + "pr_reviewer", # reviews inbound external/fork PRs (read-only) "prompter", # post-gateway intake role (human-only, drafts tasks) "secretary", # CEO's chief-of-staff (human-only, gated CEO authority) "ceo",