chore(board): revive dormant board wiring — research key, pitch flow, auditor playbooks (#684)

* chore(compose): pass research key/provider + provisioning token/org through to the orchestrator

ROBOCO_RESEARCH_API_KEY / ROBOCO_RESEARCH_PROVIDER and ROBOCO_PROVISIONING_TOKEN /
ROBOCO_PROVISIONING_ORG were absent from every compose environment stanza, so .env
values never reached the container: research silently ran on the NullProvider
(empty results forever) and any approved pitch died on ProvisioningDisabledError.
.env.example also falsely claimed the provisioning creds are panel-managed.

* feat(board): pitch CEO notification + auditor playbook-draft surfacing

A proposed pitch now nudges the CEO (APPROVAL notification + Telegram link to the
Pitches tab, best-effort — a send failure never fails the verb). auditor_triage
surfaces the oldest pending playbook draft once anomalies are clear — the curation
verbs were granted but nothing ever pointed the Auditor at the review queue; the
scheduled audit prompt names the discovery path.

* docs(prompts): pitch doctrine section + auditor reply-only-dm drift fix

board.md never mentioned the pitch verb, so no board agent ever had a reason to
call it — it gets a dedicated section mirroring the roadmap/spotlight ones, plus
a roadmap-exploration escape hatch (needs-its-own-repo ideas pitch instead).
product-owner.md gains its missing propose_roadmap + pitch entries. The flat
'Auditor has no dm' claims are corrected to the real grant: never initiates,
reply-only in a CEO-opened thread. Doctrine guarded by a prompt-content test.

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
Renzo F
2026-07-24 14:41:14 +02:00
committed by GitHub
co-authored by Renn F
parent 71f5426e40
commit 21910d75ea
15 changed files with 270 additions and 23 deletions
+8 -2
View File
@@ -394,14 +394,20 @@ ROBOCO_EXTERNAL_PR_REQUIRE_HUMAN_CONFIRM=true
# Gives Board/PM roles web_search + web_fetch. INERT (empty results) without a
# provider key — set one to make it live (Tavily / Brave / Exa, per your config).
# ROBOCO_RESEARCH_ENABLED=true
# tavily | brave | exa | null — provider adapter for the key below.
# ROBOCO_RESEARCH_PROVIDER=tavily
# ROBOCO_RESEARCH_API_KEY=
# =============================================================================
# GitHub repository auto-provisioning (pitch -> approve -> auto-provision)
# =============================================================================
# The only place that CREATES GitHub repos. Inert without a stored token/org
# regardless of this flag (set via the panel, not env).
# The only place that CREATES GitHub repos. Inert without a token/org set,
# regardless of this flag.
# ROBOCO_PROVISIONING_ENABLED=true
# Org-scoped PAT (needs repo + org admin scope) used to create repos.
# ROBOCO_PROVISIONING_TOKEN=
# GitHub organization the new repos are provisioned into.
# ROBOCO_PROVISIONING_ORG=
# =============================================================================
# Transcript pruning (retention maintenance)
+4 -4
View File
@@ -9,7 +9,7 @@ cell: null
reports_to: ceo
```
You silently observe org activity and log anomalies. You do **not** communicate outwardly.
You silently observe org activity and log anomalies. You do **not** initiate outward communication — but if the CEO opens a direct message with you, you can read and reply in that thread.
You may be spawned reactively by a quality alert or on a scheduled sweep when delivery activity has occurred. In both cases your output is the same: observe, record, and go idle.
@@ -19,14 +19,14 @@ You may be spawned reactively by a quality alert or on a scheduled sweep when de
- Cross-cell quality drift
## Your verbs
- `triage()` surfaces the next anomaly (long-running blocked task, etc.)
- `triage()` surfaces the next anomaly (long-running blocked task, etc.); once anomalies are clear, it surfaces the oldest pending playbook draft awaiting your curation instead
- `note(text, scope='reflect', task_id)` — your audit notebook. Log every anomaly you observe. (You may also `note(scope='handoff', task_id, section={'summary':'...','severity':'info'|'watch'|'risk'})` to fill a task's auditor_notes section.)
- `evidence(task_id)` to inspect a task in detail
- `i_am_idle()` when no anomalies remain — **but you must have recorded at least one observation this session first.** Recording observations is your entire output and is obligated like everyone else's notes: if you have not noted anything recently, `i_am_idle()` is blocked. Always `note(scope='reflect', ...)` what you observed (even "scanned X, no anomalies") before going idle.
## Access
- **Read-only** to ALL tasks.
- You have **no** `dm` verb. Your output is your journal.
- You carry `dm`/`read_a2a`, but only to read and reply in-thread when the CEO opens a DM with you — you can never initiate one. Your primary output is your journal.
- Errors include a `remediate` field — follow it.
## Principle
@@ -35,4 +35,4 @@ Observe, don't interfere. The CEO reads your reflect-notes when reviewing org he
## Vault curation (Obsidian)
When a root task completes, you may be spawned specifically to curate its Obsidian-vault note (feature-flagged, no-op when disabled). The deterministic sections (description, AC, links) already exist — your job is the narrative: what happened, key decisions, any rework story, in your own words.
- `curate_vault(task_id, narrative)` — call this EXACTLY ONCE per curation spawn, naming the task id from your prompt.
- This is separate from your playbook curation (`approve_playbook`/`reject_playbook`/`archive_playbook`) and from your audit sweeps — a distinct, bounded duty.
- This is separate from your playbook curation (`approve_playbook`/`reject_playbook`/`archive_playbook`) and from your audit sweeps — a distinct, bounded duty. You discover a pending draft via `triage()`: once anomalies are clear, it names the oldest one.
@@ -22,6 +22,7 @@ You are the Head of Marketing. You handle external positioning, feature announce
- `evidence(task_id)` to inspect before deciding
- `dm` for board + main-pm coordination
- `propose_feature_spotlight(feature_slug, feature_title, body)` drafts ONE marketing post spotlighting a shipped feature — held for CEO approval, never posted directly (see the Feature-spotlight cycle below)
- `pitch(title, slug, problem, proposed_solution, target_cells)` — propose a genuinely new product/repo for the CEO to approve (rare; not for anything that fits as a roadmap item or an existing project's task)
- `i_am_idle()` when no strategic work waits
## MegaTasks (batched, sequenced work)
@@ -21,6 +21,8 @@ You are the Product Owner. You define product vision and priorities, and escalat
- `escalate_to_ceo(task_id, reason)` for that task once you've logged a `note(scope='decision', task_id, text)`
- `evidence(task_id)` to inspect a task before deciding
- `dm` for board + main-pm coordination
- `propose_roadmap(cycle_goal, items)` — author a themed roadmap cycle when spawned on a `board_roadmap` exploration task (Product-Owner-only)
- `pitch(title, slug, problem, proposed_solution, target_cells)` — propose a genuinely new product/repo for the CEO to approve (rare; not for anything that fits as a roadmap item or an existing project's task)
- `i_am_idle()` when no strategic work waits
## MegaTasks (batched, sequenced work)
+14 -4
View File
@@ -4,7 +4,7 @@
You are a strategic overseer (Product Owner, Head of Marketing, or Auditor). You triage tasks at the org level, escalate strategic decisions to the CEO, and stay out of execution. The Board sits *above* Main PM — you do NOT communicate directly with Cell PMs, and you do NOT execute tasks yourself. You do NOT write code. You do NOT merge. You do NOT delegate (Main PM does that).
The Auditor is silent: read-only, no `dm`, observations recorded as journal entries. Product Owner and Head of Marketing can `dm`, but only escalate up to CEO — never down to Cell PMs. If you have feedback for a cell, you write it to the CEO or to Main PM and let Main PM relay it.
The Auditor is silent to other agents: read-only, cannot initiate a `dm`, observations recorded as journal entries — it carries `dm`/`read_a2a` only to read and reply in-thread when the CEO opens a direct message with it, never to start one. Product Owner and Head of Marketing can `dm`, but only escalate up to CEO — never down to Cell PMs. If you have feedback for a cell, you write it to the CEO or to Main PM and let Main PM relay it.
If you find yourself reaching for `Bash git`, `Edit`, or any execution tool, stop — you are about to step out of role. The right move at the Board level is `escalate_to_ceo` for strategic decisions, or `note` for observations.
@@ -31,7 +31,7 @@ When the briefing carries `company_goals`, that charter is your reference for tr
| `note(text, scope?, task_id?)` | Journal. Required: `scope='decision'` before `escalate_to_ceo`. Auditor uses `scope='reflect'` for observations. | None. |
| `evidence(task_id)` | Inspect a task's PR + commits + diff. | None. |
| `roboco_git_status(project_slug)` / `roboco_git_log(project_slug, limit?, branch?)` / `roboco_git_diff(project_slug, branch?, base?)` / `roboco_git_branches(project_slug)` | Read-only git inspection — strategic visibility without touching repository state. | None. |
| `dm(recipient, text)` | A2A direct message to a peer (e.g. `dm('main-pm', ...)`). **Auditor cannot use it — silent observer.** | None for PO/HoM; denied for Auditor. |
| `dm(recipient, text)` | A2A direct message to a peer (e.g. `dm('main-pm', ...)`). **Auditor cannot initiate — silent observer — but can read and reply in-thread if the CEO opens a DM with it.** | None for PO/HoM; Auditor: refused as sender to any agent, usable only to reply inside a CEO-opened thread. |
| `notify(target, text, priority?)` | Send a formal ack-required notification to an agent (`be-dev-1`, `ceo`, etc.). `priority` is one of `normal`/`high`/`urgent` (default `normal`). **Auditor cannot use this — silent observer.** | None for PO/HoM; denied for Auditor. |
| `i_am_idle()` | Exit cleanly. | None. |
@@ -45,7 +45,7 @@ When the briefing carries `company_goals`, that charter is your reference for tr
| `blocked` | `note(scope='reflect')` capturing what the blocker reveals at the strategic level; escalate if it indicates a systemic issue |
| `completed` / `cancelled` | strategic post-mortem via `note(scope='reflect')` if there's a lesson worth recording |
**Auditor**: every row above ends in `note(scope='reflect')` and `i_am_idle()`. You have no `dm`/`escalate_*` — your only output is the journal, which the CEO reads.
**Auditor**: every row above ends in `note(scope='reflect')` and `i_am_idle()`. You have no `escalate_*` and cannot initiate a `dm` — your primary output is the journal, which the CEO reads (you may reply in-thread if the CEO opens a DM with you, but you never start one).
## Workflow
@@ -89,7 +89,7 @@ The Auditor has no escalation verb — every observation flows through the journ
- ❌ Acting on tasks not assigned to your scope (product / marketing / audit). If a task is mid-flight in a cell, Main PM owns it; do not reach in.
- ❌ Communicating directly with Cell PMs. The chain is Board -> CEO -> Main PM -> Cell PMs. Use `escalate_to_ceo` or message `main-pm-board`.
- ❌ Running `Bash git ...`, `Edit`, or `Write`. The Board does not execute — every action is a triage call, an escalation, or a journal entry.
- ❌ (Auditor only) Calling `dm`. The Auditor is silent; record observations with `note(scope='reflect')` and let the journal layer surface them.
- ❌ (Auditor only) Initiating a `dm`. The Auditor is silent to other agents — it may only reply in-thread when the CEO opens a DM with it; record observations with `note(scope='reflect')` and let the journal layer surface them.
- ❌ Skipping the `journal:decision` entry before `escalate_to_ceo`. The gateway rejects with a tracing-gap envelope.
- ❌ Trying to merge or complete tasks. PMs and CEO own merge/complete; the Board does not have those verbs.
@@ -106,6 +106,8 @@ When you are spawned on a `board_roadmap` task, you are not reviewing someone el
3. Call `propose_roadmap(cycle_goal, items)` **exactly once** with 37 item drafts (each: `title`, `description`, `acceptance_criteria`, `project_slug`, `team`, `priority`, `rationale`). This persists the cycle for the CEO's per-item review — you do not `escalate_to_ceo` for this, and there is no `note(scope='decision')` gate on it.
4. `i_am_idle()`. The CEO approves or rejects each item individually; an approved item lands in the backlog for normal PM activation — you never claim, plan, delegate, or start any of them yourself.
An idea too big for a roadmap item — it needs its own repo/product, not a task in an existing project — goes through `pitch` instead of being stuffed into the cycle (see "Pitching a new product" below).
## Feature-spotlight exploration (Head of Marketing only)
When you are spawned on an `x_feature_exploration` task, you are not reviewing someone else's work — you are originating a marketing post, alone (the Product Owner is not part of this cycle). The task is your periodic prompt to investigate what RoboCo has actually shipped and spotlight one under-publicized capability:
@@ -115,6 +117,14 @@ When you are spawned on an `x_feature_exploration` task, you are not reviewing s
3. Call `propose_feature_spotlight(feature_slug, feature_title, body)` **exactly once**, with a body in your voice (see your identity's VOICE GUIDE), plain text, max 280 characters, no invented facts.
4. `i_am_idle()`. The CEO reviews, edits, approves, or rejects the draft in the X post queue — you never post anything yourself.
## Pitching a new product (Product Owner & Head of Marketing)
Unlike roadmap/feature-spotlight exploration, this isn't a dedicated spawn — it's a call you make whenever triage, a board review, or a roadmap-exploration cycle surfaces an idea that genuinely needs its own product/repo, not a task in any existing project.
1. Confirm it can't be scoped as a roadmap item or a task inside an existing project — if it can, it's not a pitch.
2. Call `pitch(title, slug, problem, proposed_solution, target_cells)` **exactly once** for the idea. It is rare and deliberate — most work is a roadmap item or an existing project's task, never a pitch.
3. Continue your triage/exploration, or `i_am_idle()`. The CEO reviews and decides in the panel's Pitches queue; approval auto-provisions a repo per target cell and seeds the first Main-PM delivery task — you do nothing further.
## When the gateway returns an error
Errors include `error`, `message`, `remediate`, `missing`. Read `remediate` — it tells you the literal next call. If you get a tracing-gap envelope, the `missing` field names what's missing (typically a `journal:decision` entry). Fix that one piece and retry the same verb.
+8
View File
@@ -438,6 +438,14 @@ services:
# Strategy engine + internal PR review — both default OFF.
ROBOCO_STRATEGY_ENGINE_ENABLED: ${ROBOCO_STRATEGY_ENGINE_ENABLED:-false}
ROBOCO_INTERNAL_PR_ENABLED: ${ROBOCO_INTERNAL_PR_ENABLED:-false}
# Web research + pitch auto-provisioning: both master switches default
# ON in config (unset here, so they inherit that default); these are
# the key/token passthrough — without them a real value set in .env
# never reaches the container and both stay inert regardless.
ROBOCO_RESEARCH_API_KEY: ${ROBOCO_RESEARCH_API_KEY:-}
ROBOCO_RESEARCH_PROVIDER: ${ROBOCO_RESEARCH_PROVIDER:-tavily}
ROBOCO_PROVISIONING_TOKEN: ${ROBOCO_PROVISIONING_TOKEN:-}
ROBOCO_PROVISIONING_ORG: ${ROBOCO_PROVISIONING_ORG:-}
# Spawn preflight — inert in practice (every real delivery role is
# gateway-enabled); carried at OFF for parity with the build compose.
ROBOCO_SPAWN_PREFLIGHT_ENABLED: ${ROBOCO_SPAWN_PREFLIGHT_ENABLED:-false}
+10 -3
View File
@@ -645,12 +645,19 @@ services:
ROBOCO_STRATEGY_ENGINE_ENABLED: ${ROBOCO_STRATEGY_ENGINE_ENABLED:-true}
ROBOCO_INTERNAL_PR_ENABLED: ${ROBOCO_INTERNAL_PR_ENABLED:-true}
# Remaining default-OFF capabilities, ARMED here (personal deploy): web
# research (inert without ROBOCO_RESEARCH_API_KEY → empty results), pitch
# auto-provisioning (inert until a pitch is approved + a git token is set),
# and transcript pruning (retention maintenance). All left OFF in the
# research (inert without ROBOCO_RESEARCH_API_KEY → empty results — the
# two lines below are what actually gets the .env key/provider into the
# container; without them .env values never reach it), pitch
# auto-provisioning (inert until a pitch is approved + a git token is
# set — same deal for ROBOCO_PROVISIONING_TOKEN/_ORG below), and
# transcript pruning (retention maintenance). All left OFF in the
# registry compose. Override any via .env.
ROBOCO_RESEARCH_ENABLED: ${ROBOCO_RESEARCH_ENABLED:-true}
ROBOCO_RESEARCH_API_KEY: ${ROBOCO_RESEARCH_API_KEY:-}
ROBOCO_RESEARCH_PROVIDER: ${ROBOCO_RESEARCH_PROVIDER:-tavily}
ROBOCO_PROVISIONING_ENABLED: ${ROBOCO_PROVISIONING_ENABLED:-true}
ROBOCO_PROVISIONING_TOKEN: ${ROBOCO_PROVISIONING_TOKEN:-}
ROBOCO_PROVISIONING_ORG: ${ROBOCO_PROVISIONING_ORG:-}
ROBOCO_TRANSCRIPT_PRUNE_ENABLED: ${ROBOCO_TRANSCRIPT_PRUNE_ENABLED:-true}
# Two knobs that also default ON here (like every feature), but change
# FAILURE / AUTH behavior — the .env must be set up for each before a real
+10 -3
View File
@@ -645,12 +645,19 @@ services:
ROBOCO_STRATEGY_ENGINE_ENABLED: ${ROBOCO_STRATEGY_ENGINE_ENABLED:-true}
ROBOCO_INTERNAL_PR_ENABLED: ${ROBOCO_INTERNAL_PR_ENABLED:-true}
# Remaining default-OFF capabilities, ARMED here (personal deploy): web
# research (inert without ROBOCO_RESEARCH_API_KEY → empty results), pitch
# auto-provisioning (inert until a pitch is approved + a git token is set),
# and transcript pruning (retention maintenance). All left OFF in the
# research (inert without ROBOCO_RESEARCH_API_KEY → empty results — the
# two lines below are what actually gets the .env key/provider into the
# container; without them .env values never reach it), pitch
# auto-provisioning (inert until a pitch is approved + a git token is
# set — same deal for ROBOCO_PROVISIONING_TOKEN/_ORG below), and
# transcript pruning (retention maintenance). All left OFF in the
# registry compose. Override any via .env.
ROBOCO_RESEARCH_ENABLED: ${ROBOCO_RESEARCH_ENABLED:-true}
ROBOCO_RESEARCH_API_KEY: ${ROBOCO_RESEARCH_API_KEY:-}
ROBOCO_RESEARCH_PROVIDER: ${ROBOCO_RESEARCH_PROVIDER:-tavily}
ROBOCO_PROVISIONING_ENABLED: ${ROBOCO_PROVISIONING_ENABLED:-true}
ROBOCO_PROVISIONING_TOKEN: ${ROBOCO_PROVISIONING_TOKEN:-}
ROBOCO_PROVISIONING_ORG: ${ROBOCO_PROVISIONING_ORG:-}
ROBOCO_TRANSCRIPT_PRUNE_ENABLED: ${ROBOCO_TRANSCRIPT_PRUNE_ENABLED:-true}
# Two knobs that also default ON here (like every feature), but change
# FAILURE / AUTH behavior — the .env must be set up for each before a real
+4 -2
View File
@@ -15869,9 +15869,11 @@ Your job:
2. Check quality drift: QA pass/fail patterns, convention violations,
tracing gaps on recently completed work
3. Spot cross-cell hand-off friction and silent stranded work
4. Record every observation via note(scope='reflect'); if nothing is
4. Call triage() beyond anomalies it also surfaces the oldest pending
playbook draft awaiting your approve_playbook/reject_playbook curation
5. Record every observation via note(scope='reflect'); if nothing is
amiss, note exactly that
5. Call i_am_idle() when complete
6. Call i_am_idle() when complete
"""
return """Periodic AUDIT requested.
+21 -1
View File
@@ -76,7 +76,10 @@ class BoardMixin(_Base):
)
async def auditor_triage(self, auditor_agent_id: UUID) -> Envelope:
"""Phase 4: Auditor triage — surfaces anomalies (long-running blocked, etc.)."""
"""Phase 4: Auditor triage — surfaces anomalies (long-running blocked,
etc.) first (rarer and hotter), then pending playbook drafts awaiting
curation otherwise nothing ever points the Auditor at the review
queue and drafts rot uncurated."""
anomalies = await self.task.list_long_running_blocked()
if anomalies:
t = anomalies[0]
@@ -91,6 +94,23 @@ class BoardMixin(_Base):
auditor_agent_id, t.id, full=True
),
)
from roboco.services.playbook import get_playbook_service
drafts = await get_playbook_service(self.task.session).list_drafts()
if drafts:
p = drafts[0]
return Envelope.ok(
status="draft",
task_id=None,
next=(
f"review playbook draft '{p.title}' ({str(p.id)[:8]}) via "
f"approve_playbook(playbook_id='{p.id}') or "
f"reject_playbook(playbook_id='{p.id}', reason='...')"
),
context_briefing=await self._briefing_for(
auditor_agent_id, None, full=True
),
)
return Envelope.ok(
status="idle",
task_id=None,
@@ -1196,6 +1196,7 @@ class ContentActions:
remediate="fix the pitch fields and retry",
context_briefing={},
)
await self._notify_pitch(pitch)
return Envelope.ok(
status="proposed",
task_id=str(pitch.id),
@@ -1203,6 +1204,17 @@ class ContentActions:
context_briefing={},
)
async def _notify_pitch(self, pitch: Any) -> None:
"""Best-effort CEO nudge the moment a pitch is proposed — without it
a pitch rots silently until the CEO happens to open the Pitches
queue. A send failure never fails ``pitch()`` itself."""
if self._deps.notification_delivery is None:
return
try:
await self._deps.notification_delivery.notify_ceo_of_pitch(pitch=pitch)
except Exception as exc:
logger.warning("pitch telegram notify failed (best-effort)", error=str(exc))
@classmethod
def _reject_roadmap_item_fields(
cls, raw: dict[str, Any], idx: int
+36 -1
View File
@@ -28,7 +28,7 @@ from roboco.agents_config import (
get_pm_for_team,
)
from roboco.config import settings
from roboco.db.tables import AgentTable, NotificationTable, TaskTable
from roboco.db.tables import AgentTable, NotificationTable, PitchTable, TaskTable
from roboco.events import Event, EventType, get_event_bus
from roboco.foundation.policy.communications import (
ACK_REQUIRED_BY_TYPE,
@@ -1164,6 +1164,41 @@ class NotificationDeliveryService(BaseService):
task_id=task_id, subject=notification.subject, actionable=True
)
async def notify_ceo_of_pitch(self, *, pitch: PitchTable) -> None:
"""Best-effort CEO nudge the moment a Board pitch is proposed —
without it a pitch sits in the queue with no signal until the CEO
happens to open the panel. Shaped like ``notify_ceo_of_escalation``
(APPROVAL/HIGH to the CEO + a Telegram push), but a pitch isn't a
task: ``related_task_id`` stays unset and the Telegram link points
at the panel's Pitches tab instead of a task deep-link.
"""
ceo = await self._get_ceo_agent()
if not ceo:
return
cells = ", ".join(pitch.target_cells)
problem_line = pitch.problem.strip().split("\n", 1)[0][:200]
notification = NotificationTable(
type=NotificationType.APPROVAL,
priority=NotificationPriority.HIGH,
from_agent=pitch.created_by,
to_agents=[ceo.id],
subject=f"Pitch awaiting review: {pitch.title}",
body=(
f"Slug: {pitch.slug}\nTarget cells: {cells}\n\n"
f"{problem_line}\n\n"
"Review it in the panel's Pitches tab (Business page)."
),
requires_ack=ACK_REQUIRED_BY_TYPE[NotificationType.APPROVAL],
)
await self._persist_and_deliver(notification)
text = f"<b>{_esc(notification.subject)}</b>"
if settings.panel_base_url:
link = f"{settings.panel_base_url.rstrip('/')}/business?tab=pitches"
text += f'\n<a href="{_esc_attr(link)}">Open in panel</a>'
await self._send_telegram_deferred(
text=text, reply_markup=None, disable_link_preview=True
)
async def notify_ceo_of_budget_breach(
self,
*,
@@ -0,0 +1,53 @@
"""Board pitch doctrine + the Auditor dm nuance are actually in the prose.
Guards two prompt-drift classes: the `pitch` verb had no doctrine section
anywhere (no board agent ever had a reason to call it), and board.md /
auditor.md both flatly claimed the Auditor has NO `dm` when role_config.py
grants it a reply-only, CEO-thread-only `dm`/`read_a2a`.
"""
from __future__ import annotations
from roboco.agents.factories._base import _get_prompts_base_path, _load_layer
_PROMPTS = _get_prompts_base_path()
def test_board_role_prompt_documents_pitch() -> None:
text = _load_layer(_PROMPTS / "roles" / "board.md")
assert text, "board.md is missing or empty"
assert "pitch(" in text
assert "## Pitching a new product" in text
def test_product_owner_identity_lists_propose_roadmap_and_pitch() -> None:
text = _load_layer(_PROMPTS / "identities" / "product-owner.md")
assert text, "product-owner.md is missing or empty"
assert "propose_roadmap" in text
assert "pitch(" in text
def test_head_marketing_identity_lists_pitch() -> None:
text = _load_layer(_PROMPTS / "identities" / "head-marketing.md")
assert text, "head-marketing.md is missing or empty"
assert "pitch(" in text
def test_board_role_prompt_no_longer_claims_flat_no_dm_for_auditor() -> None:
text = _load_layer(_PROMPTS / "roles" / "board.md")
assert "The Auditor is silent: read-only, no `dm`" not in text
assert "You have no `dm`/`escalate_*`" not in text
assert "reply in-thread when the CEO opens a DM with it" in text
def test_auditor_identity_no_longer_claims_flat_no_dm() -> None:
text = _load_layer(_PROMPTS / "identities" / "auditor.md")
assert text, "auditor.md is missing or empty"
assert "You have **no** `dm` verb" not in text
assert "only to read and reply in-thread when the CEO opens a DM" in text
def test_auditor_identity_documents_playbook_discovery_via_triage() -> None:
text = _load_layer(_PROMPTS / "identities" / "auditor.md")
assert "pending playbook draft" in text
assert "triage()" in text
@@ -7,7 +7,7 @@ from __future__ import annotations
from datetime import UTC, datetime
from typing import Any
from unittest.mock import AsyncMock, MagicMock
from unittest.mock import AsyncMock, MagicMock, patch
from uuid import uuid4
import pytest
@@ -76,13 +76,44 @@ async def test_auditor_triage_returns_idle_when_no_anomalies() -> None:
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
env = await c.auditor_triage(auditor_id)
playbook_svc = AsyncMock()
playbook_svc.list_drafts.return_value = []
with patch(
"roboco.services.playbook.get_playbook_service", return_value=playbook_svc
):
env = await c.auditor_triage(auditor_id)
body = env.as_dict()
assert body["status"] == "idle"
assert body["task_id"] is None
assert "i_am_idle" in body["next"]
@pytest.mark.asyncio
async def test_auditor_triage_surfaces_playbook_draft_when_no_anomalies() -> None:
"""No anomalies, but a pending playbook draft — surfaced instead of idle
so the Auditor's approve_playbook/reject_playbook duty is discoverable."""
auditor_id = uuid4()
task_svc = AsyncMock()
task_svc.agent_for.return_value = MagicMock(role="auditor", team="board")
task_svc.list_long_running_blocked.return_value = []
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
draft = MagicMock(id=uuid4(), title="Rebase onto a live rung before cutting")
playbook_svc = AsyncMock()
playbook_svc.list_drafts.return_value = [draft]
with patch(
"roboco.services.playbook.get_playbook_service", return_value=playbook_svc
):
env = await c.auditor_triage(auditor_id)
body = env.as_dict()
assert body["status"] == "draft"
assert body["task_id"] is None
assert str(draft.id)[:8] in body["next"]
assert "approve_playbook" in body["next"]
assert "reject_playbook" in body["next"]
@pytest.mark.asyncio
async def test_auditor_triage_only_first_anomaly_surfaces() -> None:
"""Auditor gets the most-stale blocked task; others wait until next call."""
@@ -2,6 +2,7 @@
from __future__ import annotations
from typing import Any
from unittest.mock import AsyncMock, MagicMock
from uuid import uuid4
@@ -9,7 +10,7 @@ import pytest
from roboco.services.gateway.content_actions import ContentActions, ContentActionsDeps
def _actions(role: str) -> ContentActions:
def _actions(role: str, *, notification_delivery: Any = None) -> ContentActions:
task = MagicMock()
agent = MagicMock()
agent.role = role
@@ -22,6 +23,7 @@ def _actions(role: str) -> ContentActions:
journal=MagicMock(),
workspace=MagicMock(),
notifications=MagicMock(),
notification_delivery=notification_delivery,
)
return ContentActions(deps)
@@ -75,3 +77,54 @@ async def test_pitch_rejects_non_cell_target() -> None:
target_cells=["board"],
)
assert env.error == "invalid_state"
@pytest.mark.asyncio
async def test_pitch_notifies_ceo_on_success(monkeypatch: pytest.MonkeyPatch) -> None:
"""A successful pitch nudges the CEO via the notification-delivery seam."""
created = MagicMock()
created.id = uuid4()
svc = MagicMock()
svc.create = AsyncMock(return_value=created)
monkeypatch.setattr("roboco.services.pitch.get_pitch_service", lambda _s: svc)
notification_delivery = AsyncMock()
env = await _actions(
"product_owner", notification_delivery=notification_delivery
).pitch(
agent_id=uuid4(),
title="Widget",
slug="widget",
problem="people need widgets",
proposed_solution="build a widget service",
target_cells=["backend", "frontend"],
)
assert env.error is None
assert env.status == "proposed"
notification_delivery.notify_ceo_of_pitch.assert_awaited_once_with(pitch=created)
@pytest.mark.asyncio
async def test_pitch_survives_notification_failure(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A CEO-notification failure never fails pitch() — best-effort only."""
created = MagicMock()
created.id = uuid4()
svc = MagicMock()
svc.create = AsyncMock(return_value=created)
monkeypatch.setattr("roboco.services.pitch.get_pitch_service", lambda _s: svc)
notification_delivery = AsyncMock()
notification_delivery.notify_ceo_of_pitch.side_effect = RuntimeError("db down")
env = await _actions(
"product_owner", notification_delivery=notification_delivery
).pitch(
agent_id=uuid4(),
title="Widget",
slug="widget",
problem="people need widgets",
proposed_solution="build a widget service",
target_cells=["backend", "frontend"],
)
assert env.error is None
assert env.status == "proposed"
notification_delivery.notify_ceo_of_pitch.assert_awaited_once()