feat(a2a): CEO can DM the Auditor and PR reviewers (#623)

* feat(a2a): CEO can DM the Auditor and PR reviewers

A mid-flight PR reviewer or Auditor that's stuck was unreachable — the CEO
had no way to DM them. Both roles now carry dm/read_a2a, so the CEO can open
a 1:1 and they can reply in-thread through the existing CEO-reply path.

Scoped deliberately: the Auditor stays a silent observer to its peers — it
gains no peer-initiation surface (can_a2a_direct routes it through
_check_auditor_a2a, which refuses every initiation target; it can only reply
inside a CEO-opened DM). PR reviewers keep their owning-PM scope. Intake and
Secretary stay excluded — they have their own dedicated chat pages.

NO_COMMS_ROLES drops to {prompter, secretary}; the panel's EXCLUDE_NON_DM_ROLES
matches. KB/docs updated so the 'auditor/pr_reviewer have no dm' claim isn't
left stale.

* test(a2a): smoke guard checks _NO_COMMS_ROLES, not a hardcoded 'auditor'

The dm() runtime guard no longer names the auditor (it now carries dm to
reply to the CEO); it refuses the canonical _NO_COMMS_ROLES set. Assert on
that set so the smoke test tracks the guard, not a stale role name.

* chore(foundation): regenerate verb tables for auditor/pr_reviewer dm+read_a2a

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
Renzo F
2026-07-21 05:54:29 +02:00
committed by GitHub
co-authored by Renn F
parent 775872cac0
commit 73c05cfa5e
24 changed files with 295 additions and 146 deletions
+2 -2
View File
@@ -355,11 +355,11 @@ Each agent gets a **spawn manifest** at `/app/tool-manifest.json` listing the ve
| pr_reviewer | `give_me_work`, `claim_pr_review`, `post_pr_review` (inbound external/fork PRs), `claim_gate_review`, `pr_pass`, `pr_fail` (in-path assembled-PR gate), `unclaim` | | pr_reviewer | `give_me_work`, `claim_pr_review`, `post_pr_review` (inbound external/fork PRs), `claim_gate_review`, `pr_pass`, `pr_fail` (in-path assembled-PR gate), `unclaim` |
| product_owner | `triage`, `escalate_to_ceo` | | product_owner | `triage`, `escalate_to_ceo` |
| head_marketing| `triage`, `escalate_to_ceo` | | head_marketing| `triage`, `escalate_to_ceo` |
| auditor | `triage`, `waive_finding` (read-only — no `dm`) | | auditor | `triage`, `waive_finding` (read-only; carries `dm`/`read_a2a` as a content tool so it can reply to a CEO-opened DM, but never initiates) |
| prompter | (none beyond `i_am_idle` — not a delivery-lifecycle role; intake interviewer, human-only) | | prompter | (none beyond `i_am_idle` — not a delivery-lifecycle role; intake interviewer, human-only) |
| secretary | (none beyond `i_am_idle` — human-only chief-of-staff; reads company state + runs gated CEO directives) | | secretary | (none beyond `i_am_idle` — human-only chief-of-staff; reads company state + runs gated CEO directives) |
Content tools (do_server) — most roles: `commit`, `note`, `dm`, `read_a2a`, `evidence`. Delivery roles (developer / qa / documenter / cell_pm / main_pm) also get `draft_playbook` (draft a curated playbook for the KB). Product Owner additionally gets `propose_roadmap` (product_owner-only, authors the weekly board-roadmap cycle) and Head of Marketing additionally gets `propose_feature_spotlight` (head_marketing-only, drafts a feature-spotlight X post) — see "Board roadmap engine" / "RoboCo X account" below. Auditor is restricted to `note` (scope=reflect) + `evidence`, plus the playbook-curation verbs `approve_playbook` / `reject_playbook` / `archive_playbook` (a bounded, deliberate expansion — KB curation, not agent comms, so its no-`dm` restriction holds) and, when the Obsidian vault is armed, `curate_vault` (writes one narrative paragraph onto a just-completed root's vault note — see "Obsidian vault V1+V2" below). The `pr_reviewer` posts its change-request on the PR itself (no agent comms). The `prompter` (intake) and `secretary` are restricted to `note` + `evidence` — human-only, no `dm`/`notify`. The `note`/journal write returns as soon as the entry is persisted; RAG indexing (Ollama embedding) runs fire-and-forget, so the tool no longer times out under concurrent load. Content tools (do_server) — most roles: `commit`, `note`, `dm`, `read_a2a`, `evidence`. Delivery roles (developer / qa / documenter / cell_pm / main_pm) also get `draft_playbook` (draft a curated playbook for the KB). Product Owner additionally gets `propose_roadmap` (product_owner-only, authors the weekly board-roadmap cycle) and Head of Marketing additionally gets `propose_feature_spotlight` (head_marketing-only, drafts a feature-spotlight X post) — see "Board roadmap engine" / "RoboCo X account" below. Auditor is restricted to `note` (scope=reflect) + `evidence` + `dm`/`read_a2a`, plus the playbook-curation verbs `approve_playbook` / `reject_playbook` / `archive_playbook` (a bounded, deliberate expansion — KB curation, not agent comms) and, when the Obsidian vault is armed, `curate_vault` (writes one narrative paragraph onto a just-completed root's vault note — see "Obsidian vault V1+V2" below). The auditor's `dm`/`read_a2a` exists so it can read and reply in-thread when the CEO opens a DM with it (mid-task, stuck) — it still never *initiates* peer A2A (`agents_config.can_a2a_direct` refuses it unconditionally as sender), preserving it as a silent observer to other agents. The `pr_reviewer` likewise now carries `dm`/`read_a2a` for the same CEO-reachability reason, on top of posting its change-request on the PR itself; its only INITIATION target stays its owning cell_pm/main_pm. The `prompter` (intake) and `secretary` are restricted to `note` + `evidence` — human-only, no `dm`/`notify`, they have their own dedicated chat pages instead. The `note`/journal write returns as soon as the entry is persisted; RAG indexing (Ollama embedding) runs fire-and-forget, so the tool no longer times out under concurrent load.
### MCP servers running per agent container ### MCP servers running per agent container
+2
View File
@@ -17,6 +17,8 @@
|------|-------------| |------|-------------|
| `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)` | | `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)` | | `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)` |
| `approve_playbook` | `approve_playbook(playbook_id: UUID)` | | `approve_playbook` | `approve_playbook(playbook_id: UUID)` |
| `reject_playbook` | `reject_playbook(playbook_id: UUID, reason: str)` | | `reject_playbook` | `reject_playbook(playbook_id: UUID, reason: str)` |
| `archive_playbook` | `archive_playbook(playbook_id: UUID)` | | `archive_playbook` | `archive_playbook(playbook_id: UUID)` |
+2
View File
@@ -22,5 +22,7 @@
|------|-------------| |------|-------------|
| `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)` | | `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)` | | `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)` |
| `notify_list` | `notify_list(unread_only: bool = True, pending_ack_only: bool = False, limit: int = 20)` | | `notify_list` | `notify_list(unread_only: bool = True, pending_ack_only: bool = False, limit: int = 20)` |
| `notify_get` | `notify_get(notification_id: UUID)` | | `notify_get` | `notify_get(notification_id: UUID)` |
+4
View File
@@ -251,6 +251,8 @@ real tools live in their agent_sdk drivers, not role_config.
|------|-------------| |------|-------------|
| `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)` | | `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)` | | `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)` |
| `approve_playbook` | `approve_playbook(playbook_id: UUID)` | | `approve_playbook` | `approve_playbook(playbook_id: UUID)` |
| `reject_playbook` | `reject_playbook(playbook_id: UUID, reason: str)` | | `reject_playbook` | `reject_playbook(playbook_id: UUID, reason: str)` |
| `archive_playbook` | `archive_playbook(playbook_id: UUID)` | | `archive_playbook` | `archive_playbook(playbook_id: UUID)` |
@@ -279,6 +281,8 @@ real tools live in their agent_sdk drivers, not role_config.
|------|-------------| |------|-------------|
| `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)` | | `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)` | | `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)` |
| `notify_list` | `notify_list(unread_only: bool = True, pending_ack_only: bool = False, limit: int = 20)` | | `notify_list` | `notify_list(unread_only: bool = True, pending_ack_only: bool = False, limit: int = 20)` |
| `notify_get` | `notify_get(notification_id: UUID)` | | `notify_get` | `notify_get(notification_id: UUID)` |
+1 -1
View File
@@ -22,7 +22,7 @@ The "misc" foundation-policy slice holds the pure, service-agnostic rule catalog
|---|---|---|---| |---|---|---|---|
| parse_priority | function | roboco/foundation/policy/communications.py:23 | Resolve a NotificationPriority from raw_priority string or legacy urgent flag; unknown→NORMAL | | parse_priority | function | roboco/foundation/policy/communications.py:23 | Resolve a NotificationPriority from raw_priority string or legacy urgent flag; unknown→NORMAL |
| NOTIFY_SENDER_ROLES | constant | roboco/foundation/policy/communications.py:51 | frozenset of roles permitted to call notify() (CELL_PM, MAIN_PM, PRODUCT_OWNER, HEAD_MARKETING, CEO) | | NOTIFY_SENDER_ROLES | constant | roboco/foundation/policy/communications.py:51 | frozenset of roles permitted to call notify() (CELL_PM, MAIN_PM, PRODUCT_OWNER, HEAD_MARKETING, CEO) |
| NO_COMMS_ROLES | constant | roboco/foundation/policy/communications.py:66 | frozenset of roles with NO agent-comms surface at all (AUDITOR, PR_REVIEWER, PROMPTER, SECRETARY) — the canonical set both `content_actions.dm()`'s sender-side guard and `agents_config.can_a2a_direct`'s CEO-target-side check consume, so the two enforcement points can't drift apart | | NO_COMMS_ROLES | constant | roboco/foundation/policy/communications.py:66 | frozenset of roles with NO agent-comms surface at all (PROMPTER, SECRETARY — human-only, own dedicated chat pages) — the canonical set both `content_actions.dm()`'s sender-side guard and `agents_config.can_a2a_direct`'s CEO-target-side check consume, so the two enforcement points can't drift apart. AUDITOR/PR_REVIEWER carry `dm`/`read_a2a` (CEO-reachable, reply-only) but are not in this set — the auditor's peer-silence is enforced separately in `can_a2a_direct`'s `from_role == "auditor"` branch |
| ACK_REQUIRED_BY_TYPE | constant | roboco/foundation/policy/communications.py:80 | NotificationType→requires_ack mapping (action-required vs informational) | | ACK_REQUIRED_BY_TYPE | constant | roboco/foundation/policy/communications.py:80 | NotificationType→requires_ack mapping (action-required vs informational) |
| Scope | enum | roboco/foundation/policy/journaling.py:21 | Journal entry scope StrEnum (note/decision/reflect/learning/struggle) | | Scope | enum | roboco/foundation/policy/journaling.py:21 | Journal entry scope StrEnum (note/decision/reflect/learning/struggle) |
| SCOPE_TO_TYPE | constant | roboco/foundation/policy/journaling.py:32 | Scope→JournalEntryType single-source mapping | | SCOPE_TO_TYPE | constant | roboco/foundation/policy/journaling.py:32 | Scope→JournalEntryType single-source mapping |
+3 -3
View File
@@ -116,10 +116,10 @@ This slice is the prompt-composition pipeline and the role/team/permission taxon
| _check_cell_pm_a2a | function | roboco/agents_config.py:590 | A2A permission for cell PM (own cell / other PMs / main-pm allowed; board escalated) | | _check_cell_pm_a2a | function | roboco/agents_config.py:590 | A2A permission for cell PM (own cell / other PMs / main-pm allowed; board escalated) |
| _check_cell_member_a2a | function | roboco/agents_config.py:604 | A2A permission for cell members (same-cell allowed; cross-cell via PMs) | | _check_cell_member_a2a | function | roboco/agents_config.py:604 | A2A permission for cell members (same-cell allowed; cross-cell via PMs) |
| _check_main_pm_a2a | function | roboco/agents_config.py:624 | A2A permission for main PM (_MAIN_PM_TARGETS allowed) | | _check_main_pm_a2a | function | roboco/agents_config.py:624 | A2A permission for main PM (_MAIN_PM_TARGETS allowed) |
| can_a2a_direct | function | roboco/agents_config.py:632 | (allowed, error) for direct A2A from one agent to another; routes CEO via notify, board/main_pm/cell-member via handlers; the CEO branch now consults `_check_ceo_a2a` (below) instead of an unconditional `True` | | can_a2a_direct | function | roboco/agents_config.py:632 | (allowed, error) for direct A2A from one agent to another; routes CEO via `_check_ceo_a2a`, `from_role == "auditor"` unconditionally refuses (silent peer observer — replies to a CEO-opened DM go through A2AService's stateful reply path instead, not this matrix), board/main_pm/cell-member via handlers |
| _check_ceo_a2a | function | roboco/agents_config.py:635 | CEO-initiated A2A target check: refuses `to_role in NO_COMMS_ROLES` (auditor/pr_reviewer/prompter/secretary — no `dm`/`read_a2a` on the manifest, so nothing on the other end could read or answer it), else allowed | | _check_ceo_a2a | function | roboco/agents_config.py:635 | CEO-initiated A2A target check: refuses `to_role in NO_COMMS_ROLES` (prompter/secretary only — no `dm`/`read_a2a` on the manifest, so nothing on the other end could read or answer it), else allowed. auditor/pr_reviewer carry `dm`/`read_a2a` now (CEO-reachable, reply-only) so they're no longer in NO_COMMS_ROLES |
| get_a2a_route_hint | function | roboco/agents_config.py:670 | Human-readable routing hint for an A2A message | | get_a2a_route_hint | function | roboco/agents_config.py:670 | Human-readable routing hint for an A2A message |
| A2A_ALLOWED_PAIRS | constant | roboco/agents_config.py | Statically-derived (via `_compute_a2a_allowed_pairs()`, calling `can_a2a_direct` for every pair) set of legal A2A pairs — sized 88 (`ceo` group 18) after `_check_ceo_a2a` excludes no-comms roles; the panel switchboard's section matrix reads off this same computation | | A2A_ALLOWED_PAIRS | constant | roboco/agents_config.py | Statically-derived (via `_compute_a2a_allowed_pairs()`, calling `can_a2a_direct` for every pair) set of legal A2A pairs — sized 93 (`ceo` group 23) now that auditor + the 4 pr_reviewer slugs are CEO-reachable; the panel switchboard's section matrix reads off this same computation |
| _PATTERNS | list | roboco/agent_sdk/prompt_guard.py:28 | Five (regex, reason) injection patterns: ignore-previous, role-override, fake role prefix, control-token mimicry, fake executive-order | | _PATTERNS | list | roboco/agent_sdk/prompt_guard.py:28 | Five (regex, reason) injection patterns: ignore-previous, role-override, fake role prefix, control-token mimicry, fake executive-order |
| detect_injection | function | roboco/agent_sdk/prompt_guard.py:63 | Return deny reason if text matches an injection pattern (lowercased), else None | | detect_injection | function | roboco/agent_sdk/prompt_guard.py:63 | Return deny reason if text matches an injection pattern (lowercased), else None |
| refusal_message | function | roboco/agent_sdk/prompt_guard.py:72 | Guidance string shown on denial (mirrors bash hook text) | | refusal_message | function | roboco/agent_sdk/prompt_guard.py:72 | Guidance string shown on denial (mirrors bash hook text) |
+1 -1
View File
@@ -67,7 +67,7 @@ Sending notifications means calling the `notify(target, text, priority)` content
| qa | No | - | | qa | No | - |
| documenter | No | - | | documenter | No | - |
Non-senders (developer, qa, documenter, auditor) still communicate via `dm(recipient, text)` for direct agent-to-agent messages — those are not ack-required notifications. The Auditor is restricted further: it has `note(scope=reflect)` + `evidence` + read-only `notify_list`/`notify_get`, and NO `dm`/`notify`. Non-senders (developer, qa, documenter) still communicate via `dm(recipient, text)` for direct agent-to-agent messages — those are not ack-required notifications. The Auditor is restricted further: `note(scope=reflect)` + `evidence` + read-only `notify_list`/`notify_get`, and NO `notify`. It does carry `dm`/`read_a2a`, but only to reply in-thread when the CEO opens a DM with it — `can_a2a_direct` refuses it as a sender unconditionally, so it never initiates to a peer.
## Task-Creator Roles ## Task-Creator Roles
+2 -2
View File
@@ -89,7 +89,7 @@ Both share the same flow verbs and read-only git (none), but their content verbs
**Flow verbs:** `triage`, `i_am_idle` (read-only) **Flow verbs:** `triage`, `i_am_idle` (read-only)
**Content verbs:** `note` (scope=reflect), `evidence` (no `dm` — Auditor observes silently) **Content verbs:** `note` (scope=reflect), `evidence`, `dm`, `read_a2a` (dm/read_a2a exist only so it can reply in-thread to a CEO-opened DM — it never initiates to a peer, so it still observes silently)
**Read-only git:** none. **Read-only git:** none.
@@ -97,7 +97,7 @@ Both share the same flow verbs and read-only git (none), but their content verbs
**Flow verbs:** `give_me_work`, `claim_pr_review`, `post_pr_review` (inbound external/fork + internal PRs), `claim_gate_review`, `pr_pass`, `pr_fail` (in-path assembled-PR gate), `unclaim`, `i_am_idle` (read-only) **Flow verbs:** `give_me_work`, `claim_pr_review`, `post_pr_review` (inbound external/fork + internal PRs), `claim_gate_review`, `pr_pass`, `pr_fail` (in-path assembled-PR gate), `unclaim`, `i_am_idle` (read-only)
**Content verbs:** `note`, `evidence`, plus notification reads (`notify_list`, `notify_get`) — no `dm`: the change-request is posted server-side on the PR itself. **Content verbs:** `note`, `evidence`, `dm`, `read_a2a`, plus notification reads (`notify_list`, `notify_get`) — the change-request itself is still posted server-side on the PR; `dm`/`read_a2a` exist so it can reply in-thread to a CEO-opened DM, and its only INITIATION target is its owning cell_pm/main_pm (the in-path gate verdict).
**Read-only git:** none. **Read-only git:** none.
+7 -6
View File
@@ -22,7 +22,7 @@ In addition to read-only observation, the Auditor is spawned reactively when a t
- The orchestrator's `_dispatch_audit_work` watches for unacknowledged `ALERT` notifications targeted at the auditor and spawns the auditor with a quality-alert prompt. - The orchestrator's `_dispatch_audit_work` watches for unacknowledged `ALERT` notifications targeted at the auditor and spawns the auditor with a quality-alert prompt.
- This path is **best-effort**: a delivery failure is logged but does not block the underlying task transition. - This path is **best-effort**: a delivery failure is logged but does not block the underlying task transition.
You still cannot claim tasks, message agents, or write code — the reactive spawn only gives you a timely lens on quality events. You still cannot claim tasks, initiate a message to a peer agent, or write code — the reactive spawn only gives you a timely lens on quality events.
## Scheduled Sweep Path ## Scheduled Sweep Path
@@ -33,7 +33,7 @@ The Auditor is also spawned on a periodic sweep:
- If all conditions pass, the orchestrator spawns the auditor with a sweep prompt that instructs it to scan recent task state, quality drift, QA pass/fail patterns, convention violations, tracing gaps, and cross-cell hand-off friction. - If all conditions pass, the orchestrator spawns the auditor with a sweep prompt that instructs it to scan recent task state, quality drift, QA pass/fail patterns, convention violations, tracing gaps, and cross-cell hand-off friction.
- This path is **best-effort** and shares the same interval throttle with reactive alert spawns. - This path is **best-effort** and shares the same interval throttle with reactive alert spawns.
You still cannot claim tasks, message agents, or write code — the scheduled sweep is another read-only lens on delivery health. You still cannot claim tasks, initiate a message to a peer agent, or write code — the scheduled sweep is another read-only lens on delivery health.
## What You CAN Do ## What You CAN Do
@@ -43,7 +43,8 @@ You still cannot claim tasks, message agents, or write code — the scheduled sw
- Attach evidence via `evidence(task_id)` - Attach evidence via `evidence(task_id)`
- Search the knowledge base via `roboco_ask_mentor` / `roboco_kb_search` - Search the knowledge base via `roboco_ask_mentor` / `roboco_kb_search`
- Waive one open **minor/nit** revision-findings-ledger finding via `waive_finding(finding_id, note)` — see below - Waive one open **minor/nit** revision-findings-ledger finding via `waive_finding(finding_id, note)` — see below
- Curate the KB's playbook queue via `approve_playbook` / `reject_playbook` / `archive_playbook` — a deliberate, bounded expansion of your read-only surface (KB curation, not agent comms, so the no-`dm` restriction still holds) - Curate the KB's playbook queue via `approve_playbook` / `reject_playbook` / `archive_playbook` — a deliberate, bounded expansion of your read-only surface (KB curation, not agent-initiated comms)
- Read `dm`s and reply in-thread when the CEO opens a DM with you (`read_a2a` / `dm`) — reachable mid-task if you're stuck, but you still never *initiate* to a peer agent
- Curate the Obsidian vault's narrative for a just-completed root task-tree via `curate_vault(task_id, narrative)` — see below (only when `ROBOCO_OBSIDIAN_VAULT_ENABLED`) - Curate the Obsidian vault's narrative for a just-completed root task-tree via `curate_vault(task_id, narrative)` — see below (only when `ROBOCO_OBSIDIAN_VAULT_ENABLED`)
## What You CANNOT Do ## What You CANNOT Do
@@ -51,7 +52,7 @@ You still cannot claim tasks, message agents, or write code — the scheduled sw
- Claim, create, assign, complete, or cancel tasks - Claim, create, assign, complete, or cancel tasks
- Pass or fail QA - Pass or fail QA
- Escalate (`triage` is your only flow verb besides `i_am_idle`/`waive_finding`) - Escalate (`triage` is your only flow verb besides `i_am_idle`/`waive_finding`)
- DM agents (`dm`) or send `notify` - Initiate a `dm` to a peer agent (you still reply in-thread if the CEO opens one), or send `notify`
- Acknowledge notifications (silent observer — `notify_ack` is not yours) - Acknowledge notifications (silent observer — `notify_ack` is not yours)
- Write to project docs, write code, or run git write operations - Write to project docs, write code, or run git write operations
@@ -72,7 +73,7 @@ waive_finding(
The Auditor has **silent read access** across the org: The Auditor has **silent read access** across the org:
- Reads task state and the knowledge base - Reads task state and the knowledge base
- Cannot send messages outward — there is no `dm` / `notify` - Never initiates messages outward — no `notify`, and `dm` only replies inside a DM the CEO opened (never starts one to a peer)
- Observations are recorded privately via `note(scope="reflect")` - Observations are recorded privately via `note(scope="reflect")`
## Observation Areas ## Observation Areas
@@ -86,7 +87,7 @@ Monitor for:
## Recording Findings ## Recording Findings
The Auditor cannot create tasks or message agents. Findings are captured as private reflections, which the KB indexes for later review: The Auditor cannot create tasks or initiate a message to a peer agent. Findings are captured as private reflections, which the KB indexes for later review:
```python ```python
note( note(
+3 -2
View File
@@ -43,11 +43,12 @@ You cannot `pr_pass` / `pr_fail` an assembled PR you authored (self-review guard
- Read-only inspect git via `roboco_git_status / _log / _diff / _branch_list`. - Read-only inspect git via `roboco_git_status / _log / _diff / _branch_list`.
- Search the knowledge base via `roboco_ask_mentor` / `roboco_kb_search`. - Search the knowledge base via `roboco_ask_mentor` / `roboco_kb_search`.
- Note evidence via `note(...)` and `evidence(...)`. - Note evidence via `note(...)` and `evidence(...)`.
- Read `dm`s and reply in-thread when the CEO opens a DM with you (`read_a2a` / `dm`), and deliver an in-path gate verdict to your owning cell_pm/main_pm via `dm` — your only two comms surfaces.
## What You CANNOT Do ## What You CANNOT Do
- Modify code, `commit`, push, open / merge PRs — not in your manifest. - Modify code, `commit`, push, open / merge PRs — not in your manifest.
- `dm` other agents — you have no comms surface; your output is the PR review. - Initiate a `dm` to anyone other than your owning cell_pm/main_pm — your output is still the PR review, not agent chatter.
- Send `notify` (ack-required notifications) — PMs / Board only. - Send `notify` (ack-required notifications) — PMs / Board only.
- Decide the PR's fate. You review; the **CEO** decides. Your completed review surfaces in the **CEO PR Review Queue** (Command Center), where the CEO chooses **Supersede** (the org cuts its own branch off the contributor's commits, hardens the work, opens its own PR, and — once that merges — closes and links the contributor PR) or **Dismiss**. - Decide the PR's fate. You review; the **CEO** decides. Your completed review surfaces in the **CEO PR Review Queue** (Command Center), where the CEO chooses **Supersede** (the org cuts its own branch off the contributor's commits, hardens the work, opens its own PR, and — once that merges — closes and links the contributor PR) or **Dismiss**.
@@ -65,7 +66,7 @@ i_am_idle() → out of work
| MCP server | Verbs you can call | | MCP server | Verbs you can call |
|-----------------------|--------------------| |-----------------------|--------------------|
| `roboco-flow` | `give_me_work`, `claim_pr_review`, `post_pr_review`, `claim_gate_review`, `pr_pass`, `pr_fail`, `unclaim`, `i_am_idle` | | `roboco-flow` | `give_me_work`, `claim_pr_review`, `post_pr_review`, `claim_gate_review`, `pr_pass`, `pr_fail`, `unclaim`, `i_am_idle` |
| `roboco-do` | `note`, `evidence`, `notify_list`, `notify_get` (no `dm` / `commit` / `notify`) | | `roboco-do` | `note`, `evidence`, `dm`, `read_a2a`, `notify_list`, `notify_get` (`dm` only to your owning cell_pm/main_pm, or in reply to a CEO-opened DM — no `commit` / `notify`) |
| `roboco-git-readonly` | `roboco_git_status`, `roboco_git_log`, `roboco_git_diff`, `roboco_git_branch_list` | | `roboco-git-readonly` | `roboco_git_status`, `roboco_git_log`, `roboco_git_diff`, `roboco_git_branch_list` |
| `roboco-optimal` | `roboco_ask_mentor`, `roboco_kb_search` | | `roboco-optimal` | `roboco_ask_mentor`, `roboco_kb_search` |
+2 -2
View File
@@ -130,7 +130,7 @@ triage() # read-only list of actionable tasks
i_am_idle() i_am_idle()
``` ```
The Auditor is a silent observer: read-only `triage`, no `dm`/`notify`, no claim/complete/cancel. The Auditor is a silent observer: read-only `triage`, no `notify`, no claim/complete/cancel. It carries `dm`/`read_a2a` but only to reply in-thread when the CEO opens a DM with it — it never initiates `dm` to a peer.
## PR Reviewer flow ## PR Reviewer flow
@@ -142,7 +142,7 @@ unclaim(task_id) # release a claimed inbound or gate review back
i_am_idle() i_am_idle()
``` ```
The PR Reviewer reviews inbound external/fork (and, behind a flag, internal) PRs the org did not open. It is read-only: no `commit`/`open_pr`/`merge`, no `dm` — the change-request is posted server-side on the PR itself, and the CEO decides Supersede/Dismiss from the PR Review Queue. The PR Reviewer reviews inbound external/fork (and, behind a flag, internal) PRs the org did not open. It is read-only: no `commit`/`open_pr`/`merge` — the change-request is posted server-side on the PR itself, and the CEO decides Supersede/Dismiss from the PR Review Queue. It carries `dm`/`read_a2a`, but only to its owning cell_pm/main_pm (the in-path gate verdict) or in reply to a CEO-opened DM — never broader agent chatter.
The same role also runs the **in-path PR-review gate** on the org's own assembled delivery PRs — the merge-level review before the PM merges: The same role also runs the **in-path PR-review gate** on the org's own assembled delivery PRs — the merge-level review before the PM merges:
+1 -1
View File
@@ -53,6 +53,6 @@ If a conversation surfaces work that needs a new task:
Most roles can `dm` (same-cell) and read incoming messages with `read_a2a`, plus check their notify inbox with `notify_list` / `notify_get`. Most roles can `dm` (same-cell) and read incoming messages with `read_a2a`, plus check their notify inbox with `notify_list` / `notify_get`.
The **Auditor** is a silent observer: it can read (`notify_list`, `notify_get`) but has **no** `dm` or `notify` it never communicates outwardly. The **Auditor** is a silent observer of peers: it never *initiates* `dm` to another agent and has no `notify`. It does carry `dm`/`read_a2a` so it can read and reply in-thread when the CEO opens a DM with it — that reply path is stateful (not gated by the peer-initiation rule).
Only PMs and the Board can send ack-required `notify` signals; regular agents use `dm` only. Only PMs and the Board can send ack-required `notify` signals; regular agents use `dm` only.
@@ -23,15 +23,15 @@ import { getErrorMessage } from "@/lib/api/client";
import { useCreateCeoConversation } from "@/hooks/use-a2a-live"; import { useCreateCeoConversation } from "@/hooks/use-a2a-live";
import { useAgentDefinitions } from "@/hooks/use-agents"; import { useAgentDefinitions } from "@/hooks/use-agents";
// Self, plus every role that can't actually read/answer a DM: auditor and // Self, plus every role that can't actually read/answer a DM: prompter and
// pr_reviewer carry no read_a2a on their manifests, prompter and secretary // secretary are human-only note/evidence roles with their own dedicated chat
// are human-only note/evidence roles — a DM to any of them is a black hole. // pages — a DM to either is a black hole. Auditor and pr_reviewer now carry
// dm/read_a2a (the CEO can reach a mid-flight one and it can reply in-thread)
// so they're no longer excluded here.
// Exported so other "start a fresh 1:1" surfaces (the /tg Mini App chat tab) // Exported so other "start a fresh 1:1" surfaces (the /tg Mini App chat tab)
// share the exact same exclusion list instead of drifting out of sync. // share the exact same exclusion list instead of drifting out of sync.
export const EXCLUDE_NON_DM_ROLES = [ export const EXCLUDE_NON_DM_ROLES = [
AgentRole.CEO, AgentRole.CEO,
AgentRole.AUDITOR,
AgentRole.PR_REVIEWER,
AgentRole.PROMPTER, AgentRole.PROMPTER,
AgentRole.SECRETARY, AgentRole.SECRETARY,
]; ];
@@ -141,9 +141,8 @@ export function A2ANewDmDialog({
<DialogHeader> <DialogHeader>
<DialogTitle>New direct message</DialogTitle> <DialogTitle>New direct message</DialogTitle>
<DialogDescription> <DialogDescription>
Starts (or reopens) your own 1:1 with an agent separate from Starts (or reopens) your own 1:1 with an agent separate from the
the threads you&apos;re watching, and visible only to you and threads you&apos;re watching, and visible only to you and them.
them.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
@@ -186,7 +186,7 @@ describe("AgentCard", () => {
); );
}); });
it("hides the DM quick-action for a role that can't read/answer a DM", () => { it("shows the DM quick-action for the auditor now that it carries dm/read_a2a", () => {
const auditor = { const auditor = {
id: "auditor", id: "auditor",
name: "Auditor", name: "Auditor",
@@ -194,9 +194,40 @@ describe("AgentCard", () => {
team: "board", team: "board",
} as unknown as AgentDefinition; } as unknown as AgentDefinition;
render(<AgentCard agent={auditor} agentStatus={statusOf()} />); render(<AgentCard agent={auditor} agentStatus={statusOf()} />);
expect(
screen.getByRole("button", { name: "DM this agent" }),
).toBeInTheDocument();
});
it("shows the DM quick-action for a PR reviewer now that it carries dm/read_a2a", () => {
const prReviewer = {
id: "pr-reviewer-1",
name: "PR Reviewer",
role: "pr_reviewer",
team: "board",
} as unknown as AgentDefinition;
render(<AgentCard agent={prReviewer} agentStatus={statusOf()} />);
expect(
screen.getByRole("button", { name: "DM this agent" }),
).toBeInTheDocument();
});
it("hides the DM quick-action for the human-only prompter/secretary roles", () => {
for (const role of ["prompter", "secretary"]) {
const agent = {
id: role,
name: role,
role,
team: null,
} as unknown as AgentDefinition;
const { unmount } = render(
<AgentCard agent={agent} agentStatus={statusOf()} />,
);
expect( expect(
screen.queryByRole("button", { name: "DM this agent" }), screen.queryByRole("button", { name: "DM this agent" }),
).not.toBeInTheDocument(); ).not.toBeInTheDocument();
unmount();
}
}); });
it("hides the DM quick-action for the CEO card", () => { it("hides the DM quick-action for the CEO card", () => {
+22 -6
View File
@@ -636,10 +636,10 @@ def _check_ceo_a2a(to_role: str) -> tuple[bool, str | None]:
"""Check A2A permissions for the CEO's asymmetric send-to-anyone reach. """Check A2A permissions for the CEO's asymmetric send-to-anyone reach.
A target with no agent-comms surface (no dm/read_a2a on its manifest A target with no agent-comms surface (no dm/read_a2a on its manifest
auditor, pr_reviewer, prompter, secretary) can never read or answer a prompter, secretary) can never read or answer a DM regardless of who
DM regardless of who sends it; the panel's New-DM dialog already sends it; the panel's New-DM dialog already excludes these roles
excludes these roles client-side (EXCLUDE_NON_DM_ROLES), this is the client-side (EXCLUDE_NON_DM_ROLES), this is the server-side backstop so
server-side backstop so a direct API/A2A-service call can't bypass it. a direct API/A2A-service call can't bypass it.
""" """
if to_role in _comms.NO_COMMS_ROLES: if to_role in _comms.NO_COMMS_ROLES:
return ( return (
@@ -650,6 +650,19 @@ def _check_ceo_a2a(to_role: str) -> tuple[bool, str | None]:
return True, None return True, None
def _check_auditor_a2a() -> tuple[bool, str | None]:
"""Silent observer: the auditor never INITIATES A2A to any target.
It can still read and reply inside a DM the CEO opens (a stateful reply
via A2AService._get_conversation_for_reply_to_ceo, not gated here), so a
mid-flight auditor is reachable by the CEO for help.
"""
return (
False,
"The auditor observes silently; it only replies inside a CEO-opened DM.",
)
def _check_pr_reviewer_a2a(to_role: str) -> tuple[bool, str | None]: def _check_pr_reviewer_a2a(to_role: str) -> tuple[bool, str | None]:
"""Check A2A permissions for a PR reviewer. """Check A2A permissions for a PR reviewer.
@@ -693,8 +706,10 @@ def can_a2a_direct(from_agent: str, to_agent: str) -> tuple[bool, str | None]:
"CEO opened — use notify() otherwise.", "CEO opened — use notify() otherwise.",
) )
# Board → board/main-pm (not CEO, not cells directly) # Board → board/main-pm (not CEO, not cells directly). Auditor is split
if from_role in ("product_owner", "head_marketing", "auditor"): # out of this tuple — it's dispatched via _check_auditor_a2a below since
# it never reaches a peer at all, board or otherwise.
if from_role in ("product_owner", "head_marketing"):
return ( return (
(True, None) (True, None)
if to_role in _BOARD_ROLES or to_role == "main_pm" if to_role in _BOARD_ROLES or to_role == "main_pm"
@@ -706,6 +721,7 @@ def can_a2a_direct(from_agent: str, to_agent: str) -> tuple[bool, str | None]:
"main_pm": _check_main_pm_a2a(to_role, to_team), "main_pm": _check_main_pm_a2a(to_role, to_team),
"cell_pm": _check_cell_pm_a2a(from_team, to_agent, to_role, to_team), "cell_pm": _check_cell_pm_a2a(from_team, to_agent, to_role, to_team),
"pr_reviewer": _check_pr_reviewer_a2a(to_role), "pr_reviewer": _check_pr_reviewer_a2a(to_role),
"auditor": _check_auditor_a2a(),
} }
if from_role in handlers: if from_role in handlers:
return handlers[from_role] return handlers[from_role]
+11 -9
View File
@@ -56,17 +56,19 @@ NOTIFY_SENDER_ROLES: frozenset[Role] = frozenset(
) )
# Roles with no agent-comms surface at all: auditor (silent observer, no dm/ # Roles with no agent-comms surface at all: the human-only prompter/secretary
# read_a2a on its manifest), pr_reviewer (posts findings on the PR itself), # (note + evidence only — they own dedicated chat pages, not agent A2A). A DM
# and the human-only prompter/secretary (note + evidence only). A DM to any # to either is a black hole — nothing on the other end can read or answer it.
# of these is a black hole — nothing on the other end can read or answer it. # Auditor and pr_reviewer are NOT here: both now carry dm/read_a2a so the CEO
# Canonical set consumed by both the dm() sender-side guard # can reach a mid-flight one and it can reply in-thread, but neither gains a
# (services.gateway.content_actions) and the CEO's asymmetric target check # peer-initiation surface — the auditor stays silent by the can_a2a_direct
# (agents_config.can_a2a_direct) so the two never drift apart. # rule (agents_config.can_a2a_direct), the pr_reviewer stays scoped to its
# owning PM (_check_pr_reviewer_a2a). Canonical set consumed by both the dm()
# sender-side guard (services.gateway.content_actions) and the CEO's
# asymmetric target check (agents_config.can_a2a_direct) so the two never
# drift apart.
NO_COMMS_ROLES: frozenset[Role] = frozenset( NO_COMMS_ROLES: frozenset[Role] = frozenset(
{ {
Role.AUDITOR,
Role.PR_REVIEWER,
Role.PROMPTER, Role.PROMPTER,
Role.SECRETARY, Role.SECRETARY,
} }
+14 -18
View File
@@ -99,9 +99,12 @@ _NOTIFY_ALLOWED_ROLES: frozenset[str] = frozenset(
r.value for r in _comms.NOTIFY_SENDER_ROLES r.value for r in _comms.NOTIFY_SENDER_ROLES
) )
# Roles with NO agent-comms surface (CLAUDE.md): auditor (silent observer), # Roles with NO agent-comms surface (CLAUDE.md): the human-only prompter and
# pr_reviewer (posts review findings on the PR itself — no dm), prompter # secretary — restricted to note + evidence, no dm/notify, they own their own
# and secretary (human-only, restricted to note + evidence — no dm/notify). # dedicated chat pages instead. (Auditor and pr_reviewer carry dm/read_a2a
# now — the CEO can DM either and it can reply in-thread — so they're no
# longer in this set; the auditor's silence toward PEERS is enforced
# separately in agents_config.can_a2a_direct.)
# The spawn manifest already omits dm from these roles' tool surfaces, but # The spawn manifest already omits dm from these roles' tool surfaces, but
# that is convention-only — this frozenset is the handler-level defence-in-depth # that is convention-only — this frozenset is the handler-level defence-in-depth
# that refuses any call that bypassed the manifest (direct verb dispatch, test # that refuses any call that bypassed the manifest (direct verb dispatch, test
@@ -113,16 +116,6 @@ _NOTIFY_ALLOWED_ROLES: frozenset[str] = frozenset(
_NO_COMMS_ROLES: frozenset[str] = frozenset(r.value for r in _comms.NO_COMMS_ROLES) _NO_COMMS_ROLES: frozenset[str] = frozenset(r.value for r in _comms.NO_COMMS_ROLES)
def _no_comms_remediate(role: str) -> str:
"""Role-appropriate remediation for a no-comms role blocked at dm."""
if role == "auditor":
return "record observations via note(scope='reflect') instead"
if role == "pr_reviewer":
return "post review findings on the PR itself via pr_pass/pr_fail instead"
# prompter / secretary are human-only (note + evidence).
return "use note() to record; this human-only role has no agent-comms surface"
_DECISION_SECTIONS: tuple[tuple[str, str], ...] = ( _DECISION_SECTIONS: tuple[tuple[str, str], ...] = (
("context", "Context"), ("context", "Context"),
("options", "Options Considered"), ("options", "Options Considered"),
@@ -1702,10 +1695,10 @@ class ContentActions:
"""A2A direct message. Requires task_id (active or explicit).""" """A2A direct message. Requires task_id (active or explicit)."""
if rej := self._reject_soup(text, field="message", min_chars=2): if rej := self._reject_soup(text, field="message", min_chars=2):
return rej return rej
# Spec §5.5: silent / no-comms roles — defense-in-depth runtime guard. # Spec §5.5: no-comms roles — defense-in-depth runtime guard. dm() is
# Defense-in-depth: dm() is the channel through which a no-comms role # the channel through which a no-comms role could "speak"; covers the
# could "speak". Covers auditor, # human-only prompter/secretary (own dedicated chat pages, no agent
# pr_reviewer, and the human-only prompter / secretary. # A2A surface at all).
agent = await self.task.agent_for(agent_id) agent = await self.task.agent_for(agent_id)
caller_role = str(agent.role) if agent is not None else "" caller_role = str(agent.role) if agent is not None else ""
if caller_role in _NO_COMMS_ROLES: if caller_role in _NO_COMMS_ROLES:
@@ -1714,7 +1707,10 @@ class ContentActions:
f"role '{caller_role}' is a silent / no-comms role;" f"role '{caller_role}' is a silent / no-comms role;"
" dm is not permitted" " dm is not permitted"
), ),
remediate=_no_comms_remediate(caller_role), remediate=(
"use note() to record; this human-only role has no"
" agent-comms surface"
),
context_briefing={}, context_briefing={},
) )
+12 -6
View File
@@ -140,13 +140,17 @@ _HEAD_MARKETING_DO = (
_AUDITOR_FLOW = spec.intents_for_role(spec.Role.AUDITOR) _AUDITOR_FLOW = spec.intents_for_role(spec.Role.AUDITOR)
# Auditor reads, does not chat or escalate. notify_list/get for inbox visibility; # Auditor reads, does not chat or escalate. notify_list/get for inbox visibility;
# no ack (silent observer — wouldn't ack notifications). # no ack (silent observer — wouldn't ack notifications). It now carries
# dm/read_a2a so the CEO can open a DM with a mid-flight auditor and it can
# reply in-thread, but it still never INITIATES peer A2A — that's enforced in
# agents_config.can_a2a_direct, not by omitting the tool.
# The Auditor is the playbook quality gate — a deliberate, bounded expansion of # The Auditor is the playbook quality gate — a deliberate, bounded expansion of
# its surface (approve/reject/archive are KB curation actions, not agent comms, # its surface (approve/reject/archive are KB curation actions, not agent comms).
# so the no-dm restriction is preserved).
_AUDITOR_DO = ( _AUDITOR_DO = (
"note", "note",
"evidence", "evidence",
"dm",
"read_a2a",
"approve_playbook", "approve_playbook",
"reject_playbook", "reject_playbook",
"archive_playbook", "archive_playbook",
@@ -157,10 +161,12 @@ _AUDITOR_DO = (
# PR reviewer: a read-only reviewer of inbound external/fork PRs. Flow verbs come # 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 # 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 # records findings (note/evidence); the change-request is posted server-side. It
# it has no outward agent comms (no dm). # now carries dm/read_a2a so the CEO can reach one mid-review and it can reply
# in-thread; its only INITIATION target stays its owning cell_pm/main_pm
# (agents_config._check_pr_reviewer_a2a).
_PR_REVIEWER_FLOW = spec.intents_for_role(spec.Role.PR_REVIEWER) _PR_REVIEWER_FLOW = spec.intents_for_role(spec.Role.PR_REVIEWER)
_PR_REVIEWER_DO = ("note", "evidence", "notify_list", "notify_get") _PR_REVIEWER_DO = ("note", "evidence", "dm", "read_a2a", "notify_list", "notify_get")
_PROMPTER_FLOW = spec.intents_for_role( _PROMPTER_FLOW = spec.intents_for_role(
spec.Role.PROMPTER spec.Role.PROMPTER
+58 -9
View File
@@ -2497,16 +2497,30 @@ async def test_agent_reply_to_ceo_creates_no_wake(a2a_setup: dict) -> None:
async def test_ceo_dm_to_non_a2a_role_denied_at_conversation_creation( async def test_ceo_dm_to_non_a2a_role_denied_at_conversation_creation(
a2a_setup: dict, a2a_setup: dict,
) -> None: ) -> None:
"""A CEO DM to a role with no dm/read_a2a on its manifest (pr_reviewer, """A CEO DM to a role with no dm/read_a2a on its manifest (the human-only
auditor) must be refused outright at conversation creation the root- prompter/secretary own dedicated chat pages) must be refused outright
cause fix (can_a2a_direct's CEO branch now excludes NO_COMMS_ROLES) at conversation creation the root-cause fix (can_a2a_direct's CEO
supersedes the old symptom-level fix of letting the conversation exist branch excludes NO_COMMS_ROLES) supersedes the old symptom-level fix of
and only suppressing the wake notification (the recipient could never letting the conversation exist and only suppressing the wake
ack it, so it would be immortal, permanently suppress future wakes via notification (the recipient could never ack it, so it would be
the dedup pre-check, and drive futile respawns).""" immortal, permanently suppress future wakes via the dedup pre-check,
and drive futile respawns)."""
svc: A2AService = a2a_setup["svc"] svc: A2AService = a2a_setup["svc"]
with pytest.raises(A2AAccessDeniedError, match="no agent-comms surface"): with pytest.raises(A2AAccessDeniedError, match="no agent-comms surface"):
await svc.get_or_create_conversation(agent_a="ceo", agent_b="pr-reviewer-1") await svc.get_or_create_conversation(agent_a="ceo", agent_b="secretary-1")
@pytest.mark.asyncio
@pytest.mark.parametrize("target_slug", ["auditor", "pr-reviewer-1"])
async def test_ceo_dm_to_auditor_or_pr_reviewer_conversation_allowed(
a2a_setup: dict, target_slug: str
) -> None:
"""The auditor and PR reviewer now carry dm/read_a2a, so a CEO can open
a DM with a mid-flight one the conversation must be created, not
refused, even though neither gains a peer-initiation surface."""
svc: A2AService = a2a_setup["svc"]
conv = await svc.get_or_create_conversation(agent_a="ceo", agent_b=target_slug)
assert conv is not None
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -2523,11 +2537,46 @@ async def test_maybe_wake_ceo_recipient_still_noops_for_no_comms_role(
with patch( with patch(
"roboco.services.notification.NotificationService", return_value=mock_ns "roboco.services.notification.NotificationService", return_value=mock_ns
): ):
await svc._maybe_wake_ceo_recipient("ceo", "pr-reviewer-1", None) await svc._maybe_wake_ceo_recipient("ceo", "secretary-1", None)
mock_ns.send_a2a_notification.assert_not_awaited() mock_ns.send_a2a_notification.assert_not_awaited()
@pytest.mark.asyncio
async def test_maybe_wake_ceo_recipient_wakes_auditor_now_that_it_has_read_a2a(
a2a_setup: dict,
) -> None:
"""auditor now carries read_a2a, so a CEO DM to an offline auditor wakes
it the same way it wakes any other reachable agent the manifest check
that used to no-op for it must now let the wake through."""
svc: A2AService = a2a_setup["svc"]
auditor = AgentTable(
id=uuid4(),
name="Auditor",
slug="auditor",
role=AgentRole.AUDITOR,
team=None,
status=AgentStatus.ACTIVE,
model_config={},
system_prompt="auditor",
capabilities=[],
permissions={},
metrics={},
)
db_session = svc.session
db_session.add(auditor)
await db_session.flush()
mock_ns = AsyncMock()
mock_ns.send_a2a_notification = AsyncMock(return_value=None)
with patch(
"roboco.services.notification.NotificationService", return_value=mock_ns
):
await svc._maybe_wake_ceo_recipient("ceo", "auditor", None)
mock_ns.send_a2a_notification.assert_awaited_once()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_interject_as_ceo_wakes_only_addressed_participant( async def test_interject_as_ceo_wakes_only_addressed_participant(
a2a_setup: dict, a2a_setup: dict,
@@ -79,14 +79,14 @@ def test_envelope_circuit_open_kind_distinct_from_tracing_gap() -> None:
assert env_co.as_dict()["error"] != env_tg.as_dict()["error"] assert env_co.as_dict()["error"] != env_tg.as_dict()["error"]
def test_auditor_silent_runtime_guard_in_dm() -> None: def test_no_comms_runtime_guard_in_dm() -> None:
"""Spec §5.5: auditor dm refused at runtime (defense in depth). """Spec §5.5: no-comms roles' dm() refused at runtime (defense in depth).
say() was retired with the channels/messaging subsystem; dm() (A2A) is The auditor and pr_reviewer now carry dm/read_a2a (the CEO can DM a
the sole surviving agent-comms verb this guard still needs to cover. mid-flight one and it replies in-thread), so the runtime guard covers only
the human-only prompter/secretary checked against the canonical
_NO_COMMS_ROLES set rather than a hardcoded role name. The behavioral test
lives in tests/unit/gateway/test_auditor_silent_guard.py.
""" """
# The actual guard test lives in tests/unit/gateway/test_auditor_silent_guard.py.
# Smoke gate verifies the guard exists by checking the source for the
# specific role-check pattern.
dm_source = inspect.getsource(content_actions.ContentActions.dm) dm_source = inspect.getsource(content_actions.ContentActions.dm)
assert "auditor" in dm_source.lower(), "dm() missing auditor runtime guard" assert "_NO_COMMS_ROLES" in dm_source, "dm() missing no-comms runtime guard"
+15 -6
View File
@@ -99,23 +99,32 @@ def test_can_a2a_direct_to_ceo_message_explains_reply_only() -> None:
@pytest.mark.parametrize( @pytest.mark.parametrize(
"target_slug", "target_slug",
["auditor", "pr-reviewer-1", "intake-1", "secretary-1"], ["intake-1", "secretary-1"],
) )
def test_can_a2a_direct_ceo_to_no_comms_role_denied(target_slug: str) -> None: def test_can_a2a_direct_ceo_to_no_comms_role_denied(target_slug: str) -> None:
"""The CEO's asymmetric reach still can't target a role with no dm/ """The CEO's asymmetric reach still can't target a role with no dm/
read_a2a on its manifest (auditor, pr_reviewer, prompter, secretary) read_a2a on its manifest (prompter, secretary human-only, own chat
nothing on the other end could ever read or answer the DM. The panel's pages) nothing on the other end could ever read or answer the DM."""
New-DM dialog already filters these client-side (EXCLUDE_NON_DM_ROLES);
this is the server-side backstop for a direct API/A2A-service call."""
allowed, reason = can_a2a_direct("ceo", target_slug) allowed, reason = can_a2a_direct("ceo", target_slug)
assert allowed is False assert allowed is False
assert reason is not None assert reason is not None
assert "comms" in reason.lower() assert "comms" in reason.lower()
@pytest.mark.parametrize("target_slug", ["auditor", "pr-reviewer-1"])
def test_can_a2a_direct_ceo_to_auditor_or_pr_reviewer_allowed(target_slug: str) -> None:
"""The CEO can now DM a mid-flight auditor or PR reviewer — both carry
dm/read_a2a so they can read and reply in-thread, even though neither
gains a peer-initiation surface (auditor stays silent via
can_a2a_direct; the PR reviewer stays scoped to its owning PM)."""
allowed, reason = can_a2a_direct("ceo", target_slug)
assert allowed is True
assert reason is None
def test_can_a2a_direct_ceo_to_no_comms_role_reuses_canonical_set() -> None: def test_can_a2a_direct_ceo_to_no_comms_role_reuses_canonical_set() -> None:
"""The refusal set must be exactly foundation.policy.communications' """The refusal set must be exactly foundation.policy.communications'
NO_COMMS_ROLES the same set services.gateway.content_actions uses to NO_COMMS_ROLES the same set services.gateway.content_actions uses to
gate the dm() sender side so the two never drift apart.""" gate the dm() sender side so the two never drift apart."""
expected = {"auditor", "pr_reviewer", "prompter", "secretary"} expected = {"prompter", "secretary"}
assert {role.value for role in NO_COMMS_ROLES} == expected assert {role.value for role in NO_COMMS_ROLES} == expected
+46 -36
View File
@@ -1,10 +1,15 @@
"""Auditor is silent — runtime guard refuses dm(). """dm() sender-side no-comms guard: prompter/secretary refused; auditor and
pr_reviewer now pass through (they carry dm/read_a2a so the CEO can DM a
mid-flight one and it can reply in-thread).
Spec §5.5: the auditor is a silent observer. The spawn manifest already Spec §5.5 originally made the auditor's silence absolute (no dm surface at
omits `dm` from the auditor's tool surface, but that is a convention-only all). It's now scoped: the auditor still never INITIATES peer A2A — that's
defense. These tests pin a defense-in-depth runtime guard inside enforced in agents_config.can_a2a_direct, not by ContentActions.dm's role
ContentActions.dm: if the caller's role is "auditor", the verb refuses with gate but the gate itself (``_NO_COMMS_ROLES``, derived from
Envelope.not_authorized regardless of how the call arrived. foundation.policy.communications.NO_COMMS_ROLES) no longer blocks it or
pr_reviewer. These tests pin that the handler-level guard (defense-in-depth
for any call that bypassed the manifest) matches the current NO_COMMS_ROLES
set exactly.
""" """
from __future__ import annotations from __future__ import annotations
@@ -43,30 +48,9 @@ def _make_deps(agent_role: str, **overrides: AsyncMock) -> ContentActionsDeps:
) )
@pytest.mark.asyncio
async def test_auditor_dm_returns_not_authorized() -> None:
"""Auditor role calling dm() is refused regardless of manifest."""
auditor_id = uuid4()
deps = _make_deps("auditor")
actions = ContentActions(deps)
env = await actions.dm(
agent_id=auditor_id,
recipient=str(uuid4()),
text="hi",
task_id=uuid4(),
)
body = env.as_dict()
assert body["error"] == "not_authorized"
haystack = (body.get("message") or "") + " " + (body.get("remediate") or "")
assert "silent" in haystack.lower() or "auditor" in haystack.lower()
deps.a2a.send.assert_not_called()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_developer_dm_passes_auditor_guard() -> None: async def test_developer_dm_passes_auditor_guard() -> None:
"""dm() for a non-auditor role is not blocked by the new guard.""" """dm() for a non-auditor role is not blocked by the no-comms guard."""
dev_id = uuid4() dev_id = uuid4()
deps = _make_deps("developer") deps = _make_deps("developer")
actions = ContentActions(deps) actions = ContentActions(deps)
@@ -85,22 +69,23 @@ async def test_developer_dm_passes_auditor_guard() -> None:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# The same no-comms invariant covers pr_reviewer / prompter / secretary # The no-comms invariant now covers only prompter/secretary (CLAUDE.md):
# (CLAUDE.md): pr_reviewer "posts its change-request on the PR itself — no # they're restricted to note + evidence — human-only, own dedicated chat
# say/dm"; prompter + secretary are "restricted to note + evidence — no # pages. Auditor and pr_reviewer carry dm/read_a2a on their manifests (a CEO
# say/dm/notify". The auditor guard's own comment claims defence-in-depth for # can DM either and they can reply in-thread) so they must NOT hit this
# "any call that bypassed the manifest" — that rationale must hold for these # guard — the auditor's silence toward PEERS is enforced separately, in
# three roles too, or the claimed defence-in-depth is only 1 of 4 silent roles. # agents_config.can_a2a_direct, not here.
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
_NO_COMMS_ROLES = ("pr_reviewer", "prompter", "secretary") _NO_COMMS_ROLES = ("prompter", "secretary")
_DM_CAPABLE_ROLES = ("auditor", "pr_reviewer")
@pytest.mark.asyncio @pytest.mark.asyncio
@pytest.mark.parametrize("role", _NO_COMMS_ROLES) @pytest.mark.parametrize("role", _NO_COMMS_ROLES)
async def test_no_comms_role_dm_returns_not_authorized(role: str) -> None: async def test_no_comms_role_dm_returns_not_authorized(role: str) -> None:
"""pr_reviewer / prompter / secretary may not dm() — handler-level guard. """prompter / secretary may not dm() — handler-level guard.
Asserts the no-comms signal ("silent") in the message so the test fails for Asserts the no-comms signal ("silent") in the message so the test fails for
the right reason on RED: without the role guard, dm() with an unowned the right reason on RED: without the role guard, dm() with an unowned
@@ -122,3 +107,28 @@ async def test_no_comms_role_dm_returns_not_authorized(role: str) -> None:
haystack = (body.get("message") or "") + " " + (body.get("remediate") or "") haystack = (body.get("message") or "") + " " + (body.get("remediate") or "")
assert "silent" in haystack.lower() assert "silent" in haystack.lower()
deps.a2a.send.assert_not_called() deps.a2a.send.assert_not_called()
@pytest.mark.asyncio
@pytest.mark.parametrize("role", _DM_CAPABLE_ROLES)
async def test_auditor_and_pr_reviewer_dm_pass_no_comms_guard(role: str) -> None:
"""auditor / pr_reviewer are no longer refused by the no-comms guard.
Mirrors test_developer_dm_passes_auditor_guard: whatever else dm() does
downstream (ownership checks on the fake task_id), it must not be the
no-comms ("silent") rejection that guard no longer names these roles.
"""
deps = _make_deps(role)
actions = ContentActions(deps)
env = await actions.dm(
agent_id=uuid4(),
recipient=str(uuid4()),
text="hi",
task_id=uuid4(),
)
body = env.as_dict()
if body.get("error") == "not_authorized":
haystack = (body.get("message") or "") + " " + (body.get("remediate") or "")
assert "silent" not in haystack.lower()
+8 -4
View File
@@ -1,8 +1,9 @@
"""Playbook content verbs — role grants + ContentActions RBAC. """Playbook content verbs — role grants + ContentActions RBAC.
Delivery roles DRAFT playbooks; only the Auditor CURATES (approve/reject/archive). Delivery roles DRAFT playbooks; only the Auditor CURATES (approve/reject/archive).
The Auditor's no-say/no-dm restriction is preserved (these are KB curation Curation is KB-curation, not agent comms, and stays separate from the
actions, not agent comms). Auditor's dm/read_a2a surface (CEO-reachable, reply-only — see
agents_config.can_a2a_direct for the peer-initiation refusal).
""" """
from __future__ import annotations from __future__ import annotations
@@ -33,9 +34,12 @@ def test_auditor_curates_but_does_not_draft() -> None:
for verb in _CURATE_VERBS: for verb in _CURATE_VERBS:
assert verb in do_tools assert verb in do_tools
assert "draft_playbook" not in do_tools assert "draft_playbook" not in do_tools
# No-say/no-dm preserved. # No "say" tool exists; dm/read_a2a ARE present (CEO-reachable, reply-only
# — the auditor still never initiates peer A2A, enforced in
# agents_config.can_a2a_direct, not by omitting the tool here).
assert "say" not in do_tools assert "say" not in do_tools
assert "dm" not in do_tools assert "dm" in do_tools
assert "read_a2a" in do_tools
def test_delivery_role_cannot_curate() -> None: def test_delivery_role_cannot_curate() -> None:
+30 -13
View File
@@ -471,6 +471,23 @@ def test_can_a2a_direct_board_to_developer_denied() -> None:
assert reason is not None assert reason is not None
def test_can_a2a_direct_auditor_to_product_owner_denied() -> None:
"""The auditor is a silent observer of peers — it never INITIATES A2A,
even to a fellow board member. It still replies inside a CEO-opened DM
(a stateful path in A2AService, not gated by this matrix)."""
allowed, reason = can_a2a_direct("auditor", "product-owner")
assert allowed is False
assert reason is not None
assert "silent" in reason.lower()
def test_can_a2a_direct_auditor_to_main_pm_denied() -> None:
allowed, reason = can_a2a_direct("auditor", "main-pm")
assert allowed is False
assert reason is not None
assert "silent" in reason.lower()
def test_can_a2a_direct_pr_reviewer_to_main_pm_allowed() -> None: def test_can_a2a_direct_pr_reviewer_to_main_pm_allowed() -> None:
"""The root→master gate reviewer delivers pr_fail change-requests to the """The root→master gate reviewer delivers pr_fail change-requests to the
owning Main PM. Denying it silently strands the verdict (blind re-submit).""" owning Main PM. Denying it silently strands the verdict (blind re-submit)."""
@@ -533,10 +550,10 @@ def test_get_a2a_route_hint_unknown_from_agent_falls_through() -> None:
# A2A_ALLOWED_PAIRS — the switchboard's static org-chart pair matrix # A2A_ALLOWED_PAIRS — the switchboard's static org-chart pair matrix
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
_EXPECTED_PAIR_COUNT = 88 _EXPECTED_PAIR_COUNT = 93
_EXPECTED_GROUP_COUNTS = { _EXPECTED_GROUP_COUNTS = {
"board": 3, "board": 3,
"ceo": 18, "ceo": 23,
"cell-backend": 15, "cell-backend": 15,
"cell-frontend": 15, "cell-frontend": 15,
"cell-ux_ui": 15, "cell-ux_ui": 15,
@@ -574,22 +591,22 @@ def test_a2a_allowed_pairs_excludes_non_participants_keeps_ceo() -> None:
def test_a2a_allowed_pairs_ceo_paired_with_every_dm_capable_agent() -> None: def test_a2a_allowed_pairs_ceo_paired_with_every_dm_capable_agent() -> None:
"""CEO → anyone with an agent-comms surface is allowed, so every non-CEO """CEO → anyone with an agent-comms surface is allowed. Every switchboard
switchboard slug EXCEPT the no-comms roles (auditor, pr_reviewer no slug is now dm-capable (auditor and pr_reviewer carry dm/read_a2a so a
dm/read_a2a on their manifest, so a CEO DM to them is a black hole) mid-flight one can read + reply prompter/secretary are the only
appears in exactly one ``ceo``-group pair.""" no-comms roles left, and they're already excluded from the switchboard
entirely, see test_a2a_allowed_pairs_excludes_non_participants_keeps_ceo),
so every non-CEO slug appears in exactly one ``ceo``-group pair."""
ceo_pairs = [p for p in A2A_ALLOWED_PAIRS if "ceo" in (p.agent_a, p.agent_b)] ceo_pairs = [p for p in A2A_ALLOWED_PAIRS if "ceo" in (p.agent_a, p.agent_b)]
non_ceo_slugs = ( non_ceo_slugs = (
{p.agent_a for p in A2A_ALLOWED_PAIRS} | {p.agent_b for p in A2A_ALLOWED_PAIRS} {p.agent_a for p in A2A_ALLOWED_PAIRS} | {p.agent_b for p in A2A_ALLOWED_PAIRS}
) - {"ceo"} ) - {"ceo"}
dm_capable_slugs = {
s for s in non_ceo_slugs if get_agent_role(s) not in ("auditor", "pr_reviewer")
}
assert all(p.group_key == "ceo" for p in ceo_pairs) assert all(p.group_key == "ceo" for p in ceo_pairs)
assert len(ceo_pairs) == len(dm_capable_slugs) assert len(ceo_pairs) == len(non_ceo_slugs)
# And the no-comms roles are confirmed absent from any ceo-group pair. ceo_slugs = ({p.agent_a for p in ceo_pairs} | {p.agent_b for p in ceo_pairs}) - {
ceo_slugs = {p.agent_a for p in ceo_pairs} | {p.agent_b for p in ceo_pairs} "ceo"
assert ceo_slugs.isdisjoint(non_ceo_slugs - dm_capable_slugs) }
assert ceo_slugs == non_ceo_slugs
def test_a2a_allowed_pairs_group_key_counts() -> None: def test_a2a_allowed_pairs_group_key_counts() -> None: