feat(marketing): HoM feature-spotlight X drafts + brand-voice charter (v0.18.0 B)

The Head of Marketing now markets features, not just releases: a default-off
x_feature_spotlight loop periodically spawns the HoM to investigate what shipped
(CHANGELOG, feature flags, docs/map, KB) and draft ONE held marketing post via
propose_feature_spotlight, reviewed in the X post queue.

- New x_feature source (distinct from x_post, fixing panel mislabeling) + a
  panel Feature-spotlight branch.
- brand_voice column on company_goals (migration 061, single head) as the
  CEO-editable voice source, surfaced in Settings and injected into the HoM
  briefing; a VOICE GUIDE baseline in head-marketing.md.
- propose_feature_spotlight verb (HoM-only), mirroring propose_roadmap.

Gated by x_feature_spotlight_enabled (default off; flag-off dormancy proven).
Also fixed two real bugs found mid-build: company_goals API schemas dropped
brand_voice on GET/PUT; the live charter UI is goals-tab.tsx, not the unmounted
company-goals-card.tsx. Full suite green (2935); migration single-head verified.
This commit is contained in:
Renn F
2026-07-04 07:34:40 +02:00
parent 1b992df3ad
commit da17c49f2d
44 changed files with 1502 additions and 57 deletions
+1
View File
@@ -10,6 +10,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
- **A2A is now a delivered inbox, not a write-only log.** The new `read_a2a` verb returns an agent's unread message bodies (atomic, own-sends excluded) and is granted to every delivery role, and the claim briefing's `list_unread_a2a` carries an incoming-only `last_message_preview` (single correlated query, no N+1). A peer's `dm` now actually reaches the recipient's reasoning instead of sitting unread — the gap that motivated retiring the channel/session backbone in the first place.
- **Fable-mode (default-off): opus-fable-playbook adoption.** `ROBOCO_FABLE_MODE_ENABLED` gates two additive levers that make the fleet behave more like Fable 5 on the existing model tiers. The doctrine layer composes the vendored behavioral doctrine (`agents/prompts/doctrine/fable.md`, from `github.com/rennf93/opus-fable-playbook` MIT, frontmatter stripped) into every agent's system prompt right after the universal base rules. The hook layer installs 5 vendored turn-discipline/honesty/verification scripts (`docker/scripts/fable-*.sh`) alongside RoboCo's own hooks on the Claude runtime, appended after (never replacing) the existing per-event entries; the grok runtime gets only the non-denying honesty-nudge hook in this V1 — a grok `PreToolUse`/`Stop` hook deny cancels the entire run, so denying hooks are deliberately not ported there yet. Off by default: the composed prompt, generated settings.json, and grok hooks are byte-for-byte unchanged when the flag is off. No new eval harness — watch the existing rework/spawn-waste dashboard instead.
- **X feature-spotlight marketing (default-off).** A second, independent sub-switch on top of the X engine: `ROBOCO_X_FEATURE_SPOTLIGHT_ENABLED` (needs `ROBOCO_X_ENGINE_ENABLED` too) spawns the Head of Marketing on a periodic interval (default 3 days, `ROBOCO_X_FEATURE_SPOTLIGHT_INTERVAL_SECONDS`) to investigate RoboCo's own shipped capabilities — CHANGELOG.md, the feature-flags ledger, docs/map, the charter, the knowledge base — and draft ONE spotlight for an under-publicized feature via a new `propose_feature_spotlight` do-tool (Head-of-Marketing-only), deduped against a `x_seen_features` ledger (migration 061) so a feature is never re-covered. The draft materializes as a `source=x_feature` held task — mirrors `x_post`/`x_reply` exactly, rendered in the same panel X Post Queue with its own "Feature spotlight" label/badge, reviewed/approved/rejected by the same `XPostService` — no new surface. The CEO's real voice now has a home: a new `company_goals.brand_voice` charter field (migration 061, panel-editable in Business → Goals) feeds `XEngine._voice_guide`, applied to ALL three draft kinds (release, reply, spotlight); falls back to a generic baseline until the CEO fills it in.
### Changed
+1 -1
View File
@@ -385,7 +385,7 @@ Agent backends are pluggable. `roboco/llm/providers/` defines an `AgentProvider`
**Cloud auth via FastAPI Users (default-off).** Lets the panel/API be safely exposed beyond localhost without touching the CEO's local no-login flow while off. Gated by `ROBOCO_CLOUD_AUTH_ENABLED` (+ `ROBOCO_CLOUD_AUTH_EMAIL` / `_PASSWORD` / `_SECRET` / `_COOKIE_MAX_AGE`; `Settings` fails loud at startup if the flag is on with no secret). Off: `get_agent_context` (`roboco/api/deps.py`) and the WS `_require_panel_token` gate (`roboco/api/websocket.py`) are byte-for-byte unchanged (header-trust). On: header-trust is dead for humans — any agent-role claim (`ceo` OR a privileged `main_pm`/`cell_pm`/board role) with no valid HMAC token or session cookie is 401, closing the header-spoof hole on the host-published `:8000` port for every role, not just `ceo` (real agents always carry a signed token, so they're unaffected); the agent-fleet HMAC path (and the orchestrator's `system` self-PATCH) keeps working unmodified in both modes; a valid session cookie authenticates as the single seeded CEO user. New `users` table (migration 058, `UserTable` in `roboco/db/tables.py`) backs FastAPI Users' `SQLAlchemyUserDatabase`; no registration router — `roboco/api/auth/seed.py` idempotently upserts exactly one row from env at startup (by primary key, so an email change renames the row instead of duplicating it). `roboco/api/auth/backend.py` wires a **cookie** transport (httponly, secure, samesite=lax) + a `JWTStrategy` subclass that binds each token to a fingerprint of the current `hashed_password`, so rotating the seeded password invalidates every prior session. Session lifetime is **sliding**: every authenticated request through `get_agent_context` re-mints + re-sets the cookie (`_slide_session_cookie`), so an active session never expires — only genuine inactivity past `cloud_auth_cookie_max_age` (default 30 days) logs out. `GET /api/auth/status` is always mounted (public); `/api/auth/login` + `/api/auth/logout` mount only when armed (`roboco/api/auth/routes.py`, mirroring `apply_guard`'s conditional mount). Panel: `(auth)/login/page.tsx` + `proxy.ts` (the Next 16 rename of `middleware.ts`; probes `/auth/status` over the docker-internal orchestrator URL, not through nginx, and fails open to "off" on any probe error/timeout) gate the `(dashboard)` group; `client.ts` adds `withCredentials` + a 401→`/login` redirect. nginx needs no changes (`/api/auth/*` rides the existing `/api/` proxy location) — but its own static `X-Agent-Token` injection (`ROBOCO_PANEL_AGENT_TOKEN`) is itself a valid HMAC credential that bypasses login when present, so a deployment arming cloud auth for real public exposure should leave that token unset (the two are alternative human-auth tiers, not layered).
**RoboCo X account (default-off).** The Head-of-Marketing voice on X (Twitter): drafts a post when a release publishes and drafts replies to meaningful mentions — but NOTHING auto-posts, every tweet is held in a panel queue for the CEO to edit/approve. Gated by `ROBOCO_X_ENGINE_ENABLED` (+ `_MENTIONS_INTERVAL_SECONDS` / `_MENTIONS_MAX_PER_CYCLE` / `_MENTIONS_MIN_ENGAGEMENT` / `_MAX_OPEN_POSTS` / `X_ACCOUNT_USER_ID`); inert without credentials regardless. Mirrors the `ReleaseManagerEngine` held-artifact shape: `XEngine` (`roboco/services/x_engine.py`) originates a held task (`source` `x_post` / `x_reply`, `confirmed_by_human=False`, Secretary-owned, skipped by every dispatcher) whose marker payload carries a **local-model-drafted** body (never a cloud LLM in the hot path), clamped to 280 chars. Release posts hook `ReleaseProposalService.approve`'s publish-success branch via a small `draft_release_post` seam; mentions ride a dedicated `_x_mentions_poll_loop` (no webhook infra exists) deduped by a `x_seen_mentions` ledger + per-cycle/open caps. The four OAuth 1.0a secrets live Fernet-encrypted in a singleton `x_credentials` row (migration 059, all-or-nothing set/clear, mirroring the git-token pattern; the API only ever returns `has_credentials`) — decryption is server-side only, agents never hold creds or egress. `XPostService.approve` (CEO-only route) is the ONLY caller of `x_client.post_tweet`: it posts under a Redis single-flight lock, **re-reads the committed task state inside the lock and commits COMPLETED before releasing** so a concurrent approve can't double-post, and is idempotent (an already-posted draft is a no-op). The hand-rolled OAuth 1.0a HMAC-SHA1 signer (`roboco/services/x_client.py`) adds no dependency; a `NullXClient` makes the unconfigured path a graceful no-op (research-engine posture). Panel: `x-post-queue.tsx` (editable draft + 280 counter, approve/reject) + `x-credentials-card.tsx` (4 write-only secret inputs).
**RoboCo X account (default-off).** The Head-of-Marketing voice on X (Twitter): drafts a post when a release publishes, drafts replies to meaningful mentions, and — a third, independent capability — periodically investigates RoboCo's own shipped features and drafts a spotlight for an under-publicized one. NOTHING auto-posts across any of the three; every tweet is held in a panel queue for the CEO to edit/approve. Gated by `ROBOCO_X_ENGINE_ENABLED` (+ `_MENTIONS_INTERVAL_SECONDS` / `_MENTIONS_MAX_PER_CYCLE` / `_MENTIONS_MIN_ENGAGEMENT` / `_MAX_OPEN_POSTS` / `X_ACCOUNT_USER_ID`); inert without credentials regardless. Mirrors the `ReleaseManagerEngine` held-artifact shape: `XEngine` (`roboco/services/x_engine.py`) originates a held task (`source` `x_post` / `x_reply` / `x_feature`, `confirmed_by_human=False`, Secretary-owned, skipped by every dispatcher) whose marker payload carries a body clamped to 280 chars. Release posts hook `ReleaseProposalService.approve`'s publish-success branch via a small `draft_release_post` seam; mentions ride a dedicated `_x_mentions_poll_loop` (no webhook infra exists) deduped by a `x_seen_mentions` ledger + per-cycle/open caps — both are **local-model-drafted** (never a cloud LLM in the hot path). The spotlight half is the one exception to "no agent spawn": gated by its own sub-switch `ROBOCO_X_FEATURE_SPOTLIGHT_ENABLED` (+ `_INTERVAL_SECONDS`, default 3 days) on top of `x_engine_enabled`, `_x_feature_spotlight_loop` opens a held PENDING exploration task (`source=x_feature_exploration`, team=Board, assigned to Head of Marketing, carrying a `x_seen_features` dedup-ledger snapshot marker) that `_dispatch_pm_work` routes (mirroring `ROADMAP_SOURCE`) to a one-shot real cloud-LLM spawn of the Head of Marketing — full read tools, investigates CHANGELOG.md/feature-flags/docs/map/charter/KB, calls the Head-of-Marketing-only `propose_feature_spotlight` do-tool exactly once, which marks the feature slug seen (`x_seen_features` table, migration 061) and materializes a brand-new `source=x_feature` held draft (completing the exploration task as a side effect — a deliberate asymmetry from `propose_roadmap`, which instead leaves its own task open). The four OAuth 1.0a secrets live Fernet-encrypted in a singleton `x_credentials` row (migration 059, all-or-nothing set/clear, mirroring the git-token pattern; the API only ever returns `has_credentials`) — decryption is server-side only, agents never hold creds or egress. `XPostService.approve` (CEO-only route) is the ONLY caller of `x_client.post_tweet`: it posts under a Redis single-flight lock, **re-reads the committed task state inside the lock and commits COMPLETED before releasing** so a concurrent approve can't double-post, and is idempotent (an already-posted draft is a no-op). The hand-rolled OAuth 1.0a HMAC-SHA1 signer (`roboco/services/x_client.py`) adds no dependency; a `NullXClient` makes the unconfigured path a graceful no-op (research-engine posture). All three draft kinds share one voice: `XEngine._voice_guide` reads the CEO-editable `company_goals.brand_voice` charter field (migration 061, panel-editable in Business → Goals) and appends it to a generic baseline (`_HOM_VOICE`) — the baseline alone until the CEO supplies a real sample. Panel: `x-post-queue.tsx` (editable draft + 280 counter, approve/reject, a `sourceMeta`-driven label/icon per source including "Feature spotlight") + `x-credentials-card.tsx` (4 write-only secret inputs).
**Board roadmap engine (default-off).** The Board originating strategic work: on a weekly interval (`ROBOCO_ROADMAP_ENGINE_ENABLED` + `_INTERVAL_SECONDS` / `_MIN_ITEMS_PER_CYCLE` / `_MAX_ITEMS_PER_CYCLE`) `RoadmapEngine` (`roboco/services/roadmap_engine.py`) opens ONE held **exploration** task (`source="board_roadmap"`, `confirmed_by_human=False`, PENDING, Product-Owner-assigned, `Team.BOARD`), deduped to one open cycle at a time. A dedicated one-shot `_dispatch_roadmap_exploration` spawns the Product Owner **solo** — deliberately NOT `_handle_board_assigned_task` (which would also spawn Head of Marketing and fire the Approve-&-Start handoff, both wrong for a PO-authored cycle) — reusing the `_board_dispatched` one-shot tracker + respawn breaker, and short-circuiting once the cycle is authored. The PO explores (read-only git, KB/RAG, metrics, releases, charter, optional web research) and makes ONE `propose_roadmap` call (a content verb gated to `product_owner` only, `_ROADMAP_ROLES`; wired through the do_server/Choreographer like `pitch`) authoring a **themed cycle** — a one-line goal + 3-7 item drafts — persisted as a `roadmap_cycle` marker on the exploration task (no table/migration). The CEO acts per-item in the panel roadmap queue (`roadmap-review-queue.tsx`; `/api/roadmap/cycles{,/items/{id}/approve,/items/{id}/reject}`, CEO-only): approve materializes that item as a BACKLOG task (`source="roadmap"`, no assignee — never auto-starts; normal PM activation picks it up) via `PrompterService.create_task_from_draft`, reject records a reason; when every item is terminal the exploration task completes (`RoadmapService`, idempotent per item). Dispatchers skip `board_roadmap` (never delivery work). `create_task_from_draft` honors a draft-declared `source` only from a `{prompter, roadmap}` whitelist — an LLM-authored draft can't impersonate a privileged origin.
+4
View File
@@ -551,6 +551,10 @@ foundation-check:
@$(MAKE) lifecycle
@git diff --exit-code -- docs/rag/lifecycle panel/lib/lifecycle.json agents/prompts/_generated/lifecycle-*.md \
|| (echo "Lifecycle artifacts are out of date. Run 'make lifecycle' and commit the diff." && exit 1)
@echo "==> verb tables up-to-date (renders + git diff)"
@uv run python scripts/regenerate_verb_tables.py
@git diff --exit-code -- agents/prompts/_generated/ ':!agents/prompts/_generated/lifecycle-*.md' \
|| (echo "Verb tables are out of date. Run 'uv run python scripts/regenerate_verb_tables.py' and commit the diff." && exit 1)
@echo "==> postgres enum parity (skip if no migrated DB)"
uv run python scripts/verify_postgres_enums.py
@echo "All foundation drift checks passed."
@@ -25,3 +25,4 @@
| `notify_ack` | `notify_ack(notification_id: UUID)` |
| `read_messages` | `read_messages()` |
| `read_a2a` | `read_a2a(see do_server)` |
| `propose_feature_spotlight` | `propose_feature_spotlight(feature_slug: str, feature_title: str, body: str)` |
+1
View File
@@ -226,6 +226,7 @@ real tools live in their agent_sdk drivers, not role_config.
| `notify_ack` | `notify_ack(notification_id: UUID)` |
| `read_messages` | `read_messages()` |
| `read_a2a` | `read_a2a(see do_server)` |
| `propose_feature_spotlight` | `propose_feature_spotlight(feature_slug: str, feature_title: str, body: str)` |
## auditor
@@ -21,7 +21,16 @@ You are the Head of Marketing. You handle external positioning, feature announce
- `escalate_to_ceo(task_id, reason)` for marketing decisions that need CEO sign-off (after `note(scope='decision', ...)`)
- `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)
- `i_am_idle()` when no strategic work waits
## MegaTasks (batched, sequenced work)
A **MegaTask** is one Intake chat that produced several tasks at once. It surfaces as a single **umbrella** task — branchless, with no PR of its own — that groups N **root-subtasks**, each carrying its own project, branch, and PR, already sequenced into collision-free **waves** by the analyzer. When a MegaTask umbrella reaches you for review, judge the **whole batch**, not one item: the positioning and launch story across all the items, each one's user value, and the wave plan recorded in the umbrella's description. Adjust or re-scope before you sign off — your review shapes the entire batch. Approving the umbrella (the CEO's Approve & Start) releases the held root-subtasks so the dependency-gate dispatches them wave by wave, and the Main PM coordinates each root-subtask down to its cell.
## Feature-spotlight cycle
Periodically, the X engine opens a held `x_feature_exploration` task and spawns you on it — your mandate to look at what RoboCo has actually shipped and tell people about it. Investigate CHANGELOG.md, the feature-flags ledger, docs/map/, the company charter, and the knowledge base, then pick ONE capability not already in the task's seen-features list — genuinely useful, currently real, and not yet publicized. Call `propose_feature_spotlight(feature_slug, feature_title, body)` **exactly once**, writing the body in your voice (see the VOICE GUIDE below), plain text, max 280 characters, no invented facts. This only drafts a held post — the CEO reviews, edits, approves, or rejects it from the X post queue, so your job ends at the draft, not the tweet. Then `i_am_idle()`; the next cycle is a fresh spawn on a new exploration task, not something you chase yourself.
## VOICE GUIDE
This section loads into every spawn of yours, regardless of task — it's the baseline voice behind anything you draft on RoboCo's behalf. A few rules, with the reasoning behind each: **confident, not hedgy** — you're announcing something that shipped and works, so say "RoboCo now does X," not "we think this might help with X"; **concise** — one post, one idea, and if a caveat doesn't fit, cut the caveat rather than add a sentence; **no emoji spam** — a single deliberate emoji (🚀 on a launch, say) is fine, three of them reads like a bot; **no hashtags unless truly apt**`#RoboCo` on every post is noise, a hashtag earns its place only when it plugs into a real, active conversation; **speak as "we"** — you represent the company, not a persona, so "we shipped..." not "I shipped..."; **plain text** — no markdown, no bullets, no thread, since X renders anything else as visibly broken; **one post** — every draft is a complete, standalone tweet, and if an idea needs a thread to land, it's the wrong feature to spotlight this cycle; **never invent facts** — every claim must trace back to something you actually found in CHANGELOG.md, the docs, or the codebase, no made-up metrics, no "customers love it," no capability the feature doesn't have yet.
The CEO's specific brand-voice sample or direction, when set, lives in the company charter (`brand_voice`) and is already part of your briefing — read it before drafting anything, and let it take precedence over the generic rules above where the two differ. If it hasn't been set yet, ask the CEO (via `dm` or your escalation chain) to add sample posts or a style description through Settings → Company Charter; until then, draft from the baseline above.
+9
View File
@@ -106,6 +106,15 @@ 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.
## 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:
1. Explore: CHANGELOG.md, the feature-flags ledger, docs/map/, the company charter (already in your briefing), and the knowledge base. You have full read access to the repository — use it directly.
2. Pick ONE feature not already in the task's seen-features list — genuinely useful, currently real, worth telling people about.
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.
## 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.
@@ -0,0 +1,45 @@
"""Add x_seen_features table + company_goals.brand_voice — feature-spotlight.
``x_seen_features`` is the feature-spotlight dedup ledger, keyed by feature
slug so a shipped capability is never spotlighted twice (mirrors
``x_seen_mentions``). ``company_goals.brand_voice`` is the CEO-authored
brand-voice sample/direction, a dedicated Text column mirroring
``north_star`` (not folded into the catch-all ``operating_policy`` JSON
blob). Additive and inert while ``ROBOCO_X_FEATURE_SPOTLIGHT_ENABLED`` is off.
Revision ID: 061_x_feature_spotlight
Revises: 060_drop_messaging
Create Date: 2026-07-04
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
revision = "061_x_feature_spotlight"
down_revision = "060_drop_messaging"
branch_labels: dict[str, str] | None = None
depends_on: dict[str, str] | None = None
def upgrade() -> None:
op.create_table(
"x_seen_features",
sa.Column("feature_slug", sa.String(length=128), primary_key=True),
sa.Column(
"seen_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text("now()"),
),
)
op.add_column(
"company_goals",
sa.Column("brand_voice", sa.Text(), nullable=False, server_default=""),
)
def downgrade() -> None:
op.drop_column("company_goals", "brand_voice")
op.drop_table("x_seen_features")
+57 -6
View File
@@ -20,6 +20,12 @@ The product / strategy / research / pitch slice covers the "company layer" above
| `roboco/services/roadmap_engine.py` | Dormant weekly engine: originates ONE held roadmap-exploration task for the Product Owner (default off) | 111 |
| `roboco/services/roadmap_service.py` | CEO's per-item approve/reject glue over a held roadmap cycle; approve materializes a BACKLOG task | 211 |
| `roboco/api/routes/roadmap.py` | CEO-only routes: list open cycles, approve/reject one item | 124 |
| `roboco/services/x_engine.py` | Dormant "engine 4": drafts X (Twitter) release posts (event hook), mention replies (poll), and — new — feature-spotlight explorations (dormant interval, spawns Head of Marketing), ALL held for CEO approval (default off) | 463 |
| `roboco/services/x_post_service.py` | CEO's approve/reject over a held X draft; approve posts via a Redis single-flight lock, idempotent | 223 |
| `roboco/services/x_client.py` | OAuth 1.0a HMAC-SHA1 X API client (`LiveXClient`) + `NullXClient` (no creds, never egresses) + `build_x_client` factory | 318 |
| `roboco/services/x_credentials.py` | Singleton Fernet-encrypted OAuth 1.0a credential CRUD; decrypts server-side only | 140 |
| `roboco/api/routes/x.py` | CEO-only routes: list open X posts, approve/reject one draft | 164 |
| `roboco/api/schemas/x.py` | `XPostResponse` + `XMentionRefModel` / `XFeatureRefModel` response shapes | 73 |
## Key Symbols
@@ -86,6 +92,18 @@ The product / strategy / research / pitch slice covers the "company layer" above
| `RoadmapService._maybe_complete_cycle` | staticmethod | roadmap_service.py:202 | Completes the exploration task once every item on it is terminal (approved/rejected) |
| `RoadmapItemResult` | dataclass | roadmap_service.py:37 | Outcome of one approve/reject call (status/item_id/materialized_task_id/detail) |
| `get_roadmap_engine` / `get_roadmap_service` | factory | roadmap_engine.py:109 / roadmap_service.py:209 | Session-bound constructors |
| `XEngine` | class | x_engine.py:150 | Dormant "engine 4": mirrors the release-manager "detect → originate a CEO-gated artifact → hold" shape across THREE responsibilities — release posts, mention replies, feature spotlights |
| `XEngine._voice_guide` | method | x_engine.py:173 | Baseline house-voice constant (`_HOM_VOICE`) plus the CEO's `company_goals.brand_voice` sample when set — feeds release/reply prompts AND is the mechanism the HoM identity file points to for its own drafting |
| `XEngine.draft_release_post` | method | x_engine.py:192 | Event-driven hook (called from `ReleaseProposalService.approve`'s publish-success branch); local-model-drafted, deduped per version, capped by `x_max_open_posts` |
| `XEngine.run_cycle` | method | x_engine.py:255 | Periodic mentions poll; no-op unless `x_engine_enabled` AND `x_replies_enabled`; filters bot-like/low-engagement mentions, dedupes by mention id (`XSeenMentionTable`) |
| `XEngine.open_feature_spotlight_exploration` | method | x_engine.py:337 | No-ops unless `x_engine_enabled` AND `x_feature_spotlight_enabled`, no creds, a cycle already open, the open-post cap reached, or project unresolvable; else opens ONE held PENDING exploration task for the Head of Marketing (`source=x_feature_exploration`) carrying a `x_seen_features` marker snapshot |
| `XEngine.materialize_feature_spotlight` | method | x_engine.py:433 | Called from the `propose_feature_spotlight` do-tool: marks the feature slug seen (`XSeenFeatureTable`), creates the held draft (`source=x_feature`, identical shape to a release/reply draft), completes the exploration task |
| `XPostService.approve` | method | x_post_service.py:77 | The ONLY caller of `x_client.post_tweet`; Redis single-flight lock, re-reads task under lock, idempotent on an already-posted draft |
| `XPostService.reject` | method | x_post_service.py:180 | Records the CEO's reason; cancels the held draft |
| `XClient` / `NullXClient` / `LiveXClient` | ABC/class | x_client.py:150 / 166 / 186 | `NullXClient.configured` is False (no creds) — drafting still runs (content nobody can post is a no-op upstream), just never originates; `LiveXClient` signs OAuth 1.0a HMAC-SHA1 |
| `build_x_client` | factory | x_client.py:306 | Returns `LiveXClient` when credentials decrypt, else `NullXClient` |
| `XCredentialsService.set_credentials` / `.get_decrypted` | method | x_credentials.py:61 / 116 | All-or-nothing Fernet-encrypted singleton credential set/clear; decrypts server-side only, never exposed to agents |
| `get_x_engine` | factory | x_engine.py:461 | Session-bound constructor (optional injected `XClient` for tests) |
## Data Flow
@@ -101,6 +119,8 @@ Two distinct flows originate work into the delivery lifecycle:
**Roadmap flow (dormant weekly originator, default off).** `Orchestrator._roadmap_engine_loop` returns immediately unless `roadmap_engine_enabled`; otherwise each `roadmap_interval_seconds` (default weekly) it opens a DB context and calls `RoadmapEngine.run_cycle`, which no-ops if a roadmap-source task is already open or the RoboCo project isn't resolvable, else opens ONE held PENDING exploration task (`source=board_roadmap`, `confirmed_by_human=False`) assigned to the Product Owner. The normal board one-shot dispatch (`_dispatch_roadmap_exploration`) spawns the PO, who explores the charter/releases/metrics/projects and calls the `propose_roadmap` do-tool exactly once with a themed goal + 3-7 item drafts (persisted as an `orchestration_markers` payload). The CEO reviews the cycle in the panel's Roadmap Review Queue and approves/rejects each item individually via `/api/roadmap/cycles/{id}/items/{id}/{approve,reject}``RoadmapService`; an approved item materializes as a BACKLOG task (`source=roadmap`) through `PrompterService.create_task_from_draft` — nothing auto-starts, normal PM activation takes it from BACKLOG. Once every item is terminal, the exploration task itself completes.
**X (Twitter) flow (three originators, one held queue, default off).** Unlike every other engine on this page, `XEngine` never spawns an agent for release posts or mention replies — `draft_release_post` (event hook off `ReleaseProposalService.approve`'s publish-success branch) and `run_cycle` (periodic mentions poll, `Orchestrator._x_mentions_poll_loop`) both draft via a raw local-model chat completion, never a cloud LLM. The feature-spotlight half is the exception: `Orchestrator._x_feature_spotlight_loop` (dormant unless BOTH `x_engine_enabled` AND `x_feature_spotlight_enabled`) opens a DB context each `x_feature_spotlight_interval_seconds` and calls `XEngine.open_feature_spotlight_exploration`, which no-ops on the usual guards (creds, one-open-cycle dedup, the shared `x_max_open_posts` cap, project resolvability) or else opens ONE held PENDING exploration task (`source=x_feature_exploration`) assigned to the Head of Marketing, carrying a snapshot of already-covered feature slugs (`x_seen_features` marker). The board dispatcher's `_dispatch_pm_work` special-cases this source (mirroring `ROADMAP_SOURCE`) to call `_dispatch_feature_spotlight_exploration`, a one-shot spawn of the real Head-of-Marketing agent (full read tools) who investigates CHANGELOG.md/feature-flags/docs/map/charter/KB and calls the `propose_feature_spotlight` do-tool exactly once; that verb materializes a brand-new held draft task (`source=x_feature`) and completes the exploration task as a side effect — a deliberate asymmetry from `propose_roadmap`, which instead writes a marker onto the SAME task and leaves it open. Every draft from all three paths — release, reply, spotlight — lands in the identical held-task shape (`TaskTable`, `confirmed_by_human=False`, `assigned_to=secretary-1`, body in `orchestration_markers.x_draft_body`) rendered by the panel's X Post Queue and acted on only by `XPostService.approve`/`.reject`; nothing here ever calls `x_client.post_tweet` itself. `XEngine._voice_guide` (a live `CompanyGoalsService.get()` read, never hardcoded) feeds a baseline house-voice constant plus the CEO's optional `brand_voice` charter sample into every one of the two local-model prompts, and the Head of Marketing's own identity prompt points it at the same charter field for its cloud-LLM-authored spotlight body.
**Read-only views.** `KanbanService` builds role-specific boards from `TaskTable` queries on demand for the kanban API; `CompanyGoalsService.get` is read by the briefing injector into every agent's `context_briefing`.
## Mermaid
@@ -144,6 +164,21 @@ flowchart TD
RSvc -->|approve| Backlog[BACKLOG task via PrompterService]
RSvc -->|all items terminal| Complete[exploration task completes]
end
subgraph XEngineFlow[dormant — x_engine_enabled]
RelHook[ReleaseProposalService.approve publish] --> XDraftRelease[XEngine.draft_release_post]
XMentLoop[Orchestrator._x_mentions_poll_loop] -->|interval| XRunCycle[XEngine.run_cycle]
XDraftRelease --> XChat[local-model chat, _voice_guide]
XRunCycle --> XChat
XSpotLoop["Orchestrator._x_feature_spotlight_loop (x_feature_spotlight_enabled)"] -->|interval, default 3d| XOpen[XEngine.open_feature_spotlight_exploration]
XOpen -->|held PENDING task| HoM[Head of Marketing spawn]
HoM -->|propose_feature_spotlight do-tool| XMat[XEngine.materialize_feature_spotlight]
XMat --> XQueue[(held X post/reply/feature draft)]
XDraftRelease --> XQueue
XRunCycle --> XQueue
CEO -->|approve/reject /api/x/posts| XSvc[XPostService]
XSvc -->|approve, single-flight lock| Tweet[(x_client.post_tweet)]
end
```
## Logical Tree
@@ -195,11 +230,26 @@ product-strategy-research-pitch
│ └── create_repo (POST /orgs/{org}/repos, auto_init)
├── roadmap_engine.py — RoadmapEngine (dormant, roadmap_engine_enabled)
│ └── run_cycle (one held exploration task for the Product Owner; one-open-cycle dedup)
── roadmap_service.py — RoadmapService
├── list_open_cycles
├── approve_item (materialize BACKLOG task, idempotent)
├── reject_item (record reason, idempotent)
└── _maybe_complete_cycle (completes exploration task once all items terminal)
── roadmap_service.py — RoadmapService
├── list_open_cycles
├── approve_item (materialize BACKLOG task, idempotent)
├── reject_item (record reason, idempotent)
└── _maybe_complete_cycle (completes exploration task once all items terminal)
├── x_engine.py — XEngine (dormant, x_engine_enabled)
│ ├── _voice_guide (baseline + CEO brand_voice charter sample, live DB read)
│ ├── draft_release_post (event hook; local-model; dedup per version)
│ ├── run_cycle (mentions poll; x_replies_enabled sub-switch; bot/engagement filter)
│ ├── open_feature_spotlight_exploration (x_feature_spotlight_enabled sub-switch; one-open-cycle dedup; seen-features marker)
│ ├── materialize_feature_spotlight (called from propose_feature_spotlight; marks seen, holds draft, completes exploration)
│ └── _originate_post (shared held-task origination, all three sources)
├── x_post_service.py — XPostService
│ ├── approve (single-flight lock, idempotent, only caller of x_client.post_tweet)
│ └── reject (record reason, cancel draft)
├── x_client.py — XClient ABC / NullXClient / LiveXClient
│ └── build_x_client (creds present → LiveXClient, else NullXClient)
└── x_credentials.py — XCredentialsService (singleton, Fernet-encrypted)
├── set_credentials (all-or-nothing)
└── get_decrypted (server-side only)
```
## Dependencies
@@ -244,7 +294,8 @@ product-strategy-research-pitch
- `prompter_live.py``get_project_service` for project lookup during intake.
- `dashboard.py``get_product_service` / `get_project_service` for dashboard views.
- `roadmap.py``GET /api/roadmap/cycles`, `POST /cycles/{id}/items/{id}/{approve,reject}` (CEO-only) → `get_roadmap_service`.
- **Orchestrator loop tick:** `_strategy_engine_loop` (orchestrator.py:6360) — created at `start()` (line 1010), cancelled in shutdown (line 1075); ticks every `strategy_engine_interval_seconds`, calls `StrategyEngine.run_cycle`. `_roadmap_engine_loop` (orchestrator.py:7462) — same lifecycle shape, ticks every `roadmap_interval_seconds` (default weekly), calls `RoadmapEngine.run_cycle`; `_dispatch_roadmap_exploration` (orchestrator.py:10284) spawns the Product Owner once per open exploration task.
- `x.py``GET /api/x/posts`, `POST /posts/{id}/{approve,reject}` (CEO-only) → `get_x_post_service`.
- **Orchestrator loop tick:** `_strategy_engine_loop` (orchestrator.py:6360) — created at `start()` (line 1010), cancelled in shutdown (line 1075); ticks every `strategy_engine_interval_seconds`, calls `StrategyEngine.run_cycle`. `_roadmap_engine_loop` (orchestrator.py:7462) — same lifecycle shape, ticks every `roadmap_interval_seconds` (default weekly), calls `RoadmapEngine.run_cycle`; `_dispatch_roadmap_exploration` (orchestrator.py:10284) spawns the Product Owner once per open exploration task. `_x_mentions_poll_loop` (orchestrator.py:7509) ticks every `x_mentions_interval_seconds`, calls `XEngine.run_cycle`. `_x_feature_spotlight_loop` (orchestrator.py:7571) — same lifecycle shape, dormant unless BOTH `x_engine_enabled` AND `x_feature_spotlight_enabled`, ticks every `x_feature_spotlight_interval_seconds` (default 3 days), calls `XEngine.open_feature_spotlight_exploration`; `_dispatch_feature_spotlight_exploration` (orchestrator.py:10424) spawns the Head of Marketing once per open exploration task — `_dispatch_pm_work` routes `source=x_feature_exploration` to it BEFORE the generic `_BOARD_AGENTS` check (mirroring the roadmap source's own early branch), so it never falls into the two-reviewer board-review gate.
- **MCP mount (orchestrator spawn):** `roboco-search` MCP mounted into Board/PM agent containers only when `research_enabled` (orchestrator.py:2914); the MCP server calls the `/api/research/*` routes.
- **Service-to-service:** `ProjectService` called by `WorkspaceService`, `GitService`, `PitchService`, `task`, `docs`, `cockpit`, `secretary`, gateway choreographer; `ProductService.project_for` called from gateway delegate path; `CompanyGoalsService.get` called by briefing injector.
- **No CLI / lifespan entry points** for this slice.
+9 -4
View File
@@ -19,6 +19,7 @@
- Escalate tasks to the CEO via `escalate_to_ceo(task_id, reason)`
- Communicate: `dm` (A2A), `notify` (ack-required signal)
- Propose a product via `pitch(title, slug, problem, proposed_solution, target_cells)` — queues for CEO approval, then auto-provisions
- Propose a feature spotlight via `propose_feature_spotlight(feature_slug, feature_title, body)` — periodic, one per exploration cycle, held for CEO approval in the X post queue
- Read project docs via `roboco_docs_read` / `roboco_docs_list`
- Research the market via `web_search` / `web_fetch` (when `ROBOCO_RESEARCH_ENABLED`)
- Search the knowledge base via `roboco_ask_mentor` / `roboco_kb_search`
@@ -36,17 +37,21 @@
| MCP server | Verbs you can call |
|-----------------------|--------------------|
| `roboco-flow` | `triage`, `escalate_to_ceo`, `i_am_idle` |
| `roboco-do` | `note`, `pitch`, `dm`, `notify`, `evidence` |
| `roboco-do` | `note`, `pitch`, `dm`, `notify`, `evidence`, `propose_feature_spotlight` |
| `roboco-docs` | `roboco_docs_read`, `roboco_docs_list` |
| `roboco-git-readonly` | `roboco_git_status`, `roboco_git_log`, `roboco_git_diff`, `roboco_git_branch_list` |
| `roboco-search` | `web_search`, `web_fetch` (only when `ROBOCO_RESEARCH_ENABLED`) |
| `roboco-optimal` | `roboco_ask_mentor`, `roboco_kb_search` |
Your flow surface is deliberately narrow: the Board steers and approves, it does not claim, create, or complete tasks. Unlike the Product Owner, you do **not** get `propose_roadmap` — that tool is Product-Owner-only.
Your flow surface is deliberately narrow: the Board steers and approves, it does not claim, create, or complete tasks. You still don't get the Product Owner's `propose_roadmap` — that stays Product-Owner-only — but you do get your own equivalent: `propose_feature_spotlight`, covered below.
## X (Twitter) Engine — Not a New Tool for You
## X (Twitter) Engine — Release Posts, Mentions, and Your Feature-Spotlight Cycle
The X engine (`ROBOCO_X_ENGINE_ENABLED`, default off) drafts release-announcement and mention-reply posts for the company's X account, in your marketing voice but it does **not** add anything to your tool surface above. Drafting is done by a direct local-model call from `XEngine` (`roboco/services/x_engine.py`), not by spawning you as an agent; every draft lands as a held task **owned by the Secretary** (`assigned_to=secretary-1`, `team=main_pm`), never assigned to you. The CEO reviews and approves/rejects each draft in the panel (`GET/POST /api/x/posts{,/{id}/approve,/reject}`, CEO-only) — nothing posts without that explicit per-post approval. If you want to review or influence a draft's content, ask the CEO directly (via `dm` or the escalation chain below) rather than expecting a task in your queue.
The X engine (`ROBOCO_X_ENGINE_ENABLED`, default off) posts on the company's X account in your marketing voice, but it reaches you two different ways depending on the draft kind.
Release-announcement and mention-reply posts are still not a tool call and still don't spawn you: `XEngine` (`roboco/services/x_engine.py`) drafts them directly via a local-model call, not by spawning you as an agent. Every one of these drafts lands as a held task **owned by the Secretary** (`assigned_to=secretary-1`, `team=main_pm`), never assigned to you. The CEO reviews and approves/rejects each in the panel (`GET/POST /api/x/posts{,/{id}/approve,/reject}`, CEO-only) — nothing posts without that explicit per-post approval. If you want to influence one of these drafts, ask the CEO directly (via `dm` or the escalation chain below) rather than expecting it in your queue.
Feature spotlights are different: they **are** a real tool call and they **do** spawn you. Gated by a second, independent switch (`ROBOCO_X_FEATURE_SPOTLIGHT_ENABLED`, also default off), the engine periodically opens a held `x_feature_exploration` task assigned to you — the one case where the X engine puts something in your own queue. When you're spawned on it, investigate what RoboCo has actually shipped (CHANGELOG.md, the feature-flags ledger, docs/map/, the company charter, the knowledge base), pick ONE under-publicized, currently-real capability not already in the task's seen-features list, and call `propose_feature_spotlight(feature_slug, feature_title, body)` **exactly once** — it drafts a held X post the same way the release/mention path does, then completes your exploration task. Call `i_am_idle()` next. The CEO reviews, edits, approves, or rejects the draft from the same X post queue — you never post anything yourself.
## Escalation
+20 -3
View File
@@ -224,6 +224,7 @@ function GoalsForm({ goals, refetch }: GoalsFormProps) {
const queryClient = useQueryClient();
const [northStar, setNorthStar] = useState<string | null>(null);
const [brandVoice, setBrandVoice] = useState<string | null>(null);
const [constraints, setConstraints] = useState<string | null>(null);
const [objectives, setObjectives] = useState<
Record<string, unknown>[] | null
@@ -231,6 +232,7 @@ function GoalsForm({ goals, refetch }: GoalsFormProps) {
const [policy, setPolicy] = useState<Record<string, unknown> | null>(null);
const northStarVal = northStar ?? goals.north_star ?? "";
const brandVoiceVal = brandVoice ?? goals.brand_voice ?? "";
const constraintsVal = constraints ?? (goals.constraints ?? []).join("\n");
const objectivesVal = objectives ?? goals.objectives ?? [];
const policyVal = policy ?? goals.operating_policy ?? {};
@@ -240,6 +242,7 @@ function GoalsForm({ goals, refetch }: GoalsFormProps) {
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["company-goals"] });
setNorthStar(null);
setBrandVoice(null);
setConstraints(null);
setObjectives(null);
setPolicy(null);
@@ -253,6 +256,7 @@ function GoalsForm({ goals, refetch }: GoalsFormProps) {
const handleSave = () => {
saveMutation.mutate({
north_star: northStarVal,
brand_voice: brandVoiceVal,
objectives: objectivesVal,
constraints: constraintsVal
.split("\n")
@@ -269,9 +273,9 @@ function GoalsForm({ goals, refetch }: GoalsFormProps) {
<CardHeader>
<CardTitle>Company Charter</CardTitle>
<CardDescription>
CEO-owned north star, objectives, constraints, and operating policy.
Injected into every agent&apos;s briefing so all work stays
goal-aware.
CEO-owned north star, brand voice, objectives, constraints, and
operating policy. Injected into every agent&apos;s briefing so all
work stays goal-aware.
</CardDescription>
</CardHeader>
<CardContent className="space-y-5">
@@ -288,6 +292,19 @@ function GoalsForm({ goals, refetch }: GoalsFormProps) {
/>
</div>
{/* Brand voice */}
<div className="space-y-2">
<Label htmlFor="brand-voice">Brand voice</Label>
<Textarea
id="brand-voice"
rows={3}
value={brandVoiceVal}
disabled={saving}
onChange={(e) => setBrandVoice(e.target.value)}
placeholder="Sample posts or a style description in your real voice — feeds every X (Twitter) draft and the Head of Marketing's spotlight posts…"
/>
</div>
{/* Constraints */}
<div className="space-y-2">
<Label htmlFor="constraints">Constraints (one per line)</Label>
@@ -31,6 +31,7 @@ export function CompanyGoalsCard() {
const [constraints, setConstraints] = useState<string | null>(null);
const [objectives, setObjectives] = useState<string | null>(null);
const [policy, setPolicy] = useState<string | null>(null);
const [brandVoice, setBrandVoice] = useState<string | null>(null);
const { data: goals, isLoading } = useQuery({
queryKey: ["company-goals"],
@@ -43,6 +44,7 @@ export function CompanyGoalsCard() {
objectives ?? JSON.stringify(goals?.objectives ?? [], null, 2);
const policyVal =
policy ?? JSON.stringify(goals?.operating_policy ?? {}, null, 2);
const brandVoiceVal = brandVoice ?? goals?.brand_voice ?? "";
const saveMutation = useMutation({
mutationFn: (update: CompanyGoalsUpdate) => companyGoalsApi.update(update),
@@ -52,6 +54,7 @@ export function CompanyGoalsCard() {
setConstraints(null);
setObjectives(null);
setPolicy(null);
setBrandVoice(null);
toast.success("Company charter updated");
},
onError: (error) => {
@@ -92,6 +95,7 @@ export function CompanyGoalsCard() {
.map((c) => c.trim())
.filter(Boolean),
operating_policy: parsedPolicy,
brand_voice: brandVoiceVal,
});
};
@@ -103,9 +107,9 @@ export function CompanyGoalsCard() {
Company Charter
</CardTitle>
<CardDescription>
The CEO-owned north star, objectives, constraints, and operating
policy. Injected into every agent&apos;s briefing so all work stays
goal-aware.
The CEO-owned north star, objectives, constraints, operating policy,
and brand voice. Injected into every agent&apos;s briefing so all
work stays goal-aware.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
@@ -120,6 +124,17 @@ export function CompanyGoalsCard() {
placeholder="The long-term vision in one or two sentences..."
/>
</div>
<div className="space-y-2">
<Label htmlFor="brand-voice">Brand voice</Label>
<Textarea
id="brand-voice"
rows={3}
value={brandVoiceVal}
disabled={isLoading}
onChange={(e) => setBrandVoice(e.target.value)}
placeholder="Sample posts or a style description for the Head of Marketing's drafts..."
/>
</div>
<div className="space-y-2">
<Label htmlFor="constraints">Constraints (one per line)</Label>
<Textarea
@@ -71,6 +71,26 @@ describe("XPostQueue", () => {
expect(screen.getByDisplayValue("RoboCo v0.17.0 just shipped!")).toBeInTheDocument();
});
it("renders a feature-spotlight draft with its own label and badge", async () => {
listPosts.mockResolvedValueOnce([
{
task_id: "x-3",
source: "x_feature",
title: "X feature spotlight: playbooks",
status: "pending",
body: "Did you know RoboCo curates playbooks from real task runs?",
char_count: 60,
feature: { slug: "playbooks", title: "Playbook curation" },
},
] as XPost[]);
render(withQueryClient(<XPostQueue />));
expect(await screen.findByText("Feature spotlight")).toBeInTheDocument();
expect(screen.queryByText("Mention reply")).not.toBeInTheDocument();
expect(screen.getByText(/Playbook curation/)).toBeInTheDocument();
});
it("disables only the row being approved, not every row's Approve", async () => {
render(withQueryClient(<XPostQueue />));
@@ -23,16 +23,17 @@ import {
} from "@/components/ui/dialog";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { AtSign, CheckCircle2, Rocket, XCircle } from "lucide-react";
import { AtSign, CheckCircle2, Rocket, Sparkles, XCircle } from "lucide-react";
import { toast } from "sonner";
const MAX_TWEET_CHARS = 280;
const _MIN_REASON_CHARS = 4;
function sourceMeta(source: XPost["source"]) {
return source === "x_post"
? { label: "Release post", icon: Rocket }
: { label: "Mention reply", icon: AtSign };
if (source === "x_post") return { label: "Release post", icon: Rocket };
if (source === "x_feature")
return { label: "Feature spotlight", icon: Sparkles };
return { label: "Mention reply", icon: AtSign };
}
function describeExecuteResult(result: XPostExecuteResult): string {
@@ -78,6 +79,11 @@ function XPostRow({
re: {post.mention.text}
</Badge>
)}
{post.feature && (
<Badge variant="outline" className="max-w-56 truncate">
feature: {post.feature.title}
</Badge>
)}
</div>
<Textarea
@@ -195,8 +201,9 @@ export function XPostQueue({ className }: { className?: string }) {
X Post Queue
</CardTitle>
<CardDescription>
Drafted release announcements (and mention replies, if enabled) land
here for you to edit, approve, or reject. Nothing posts on its own.
Drafted release announcements, feature spotlights, and mention
replies (if enabled) land here for you to edit, approve, or
reject. Nothing posts on its own.
</CardDescription>
</CardHeader>
<CardContent>
@@ -220,8 +227,9 @@ export function XPostQueue({ className }: { className?: string }) {
<Badge variant="secondary">{posts.length}</Badge>
</CardTitle>
<CardDescription>
Drafted release announcements and mention replies edit, approve
(posts to X), or reject. Nothing posts on its own.
Drafted release announcements, feature spotlights, and mention
replies edit, approve (posts to X), or reject. Nothing posts on
its own.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
@@ -62,6 +62,8 @@ const FLAG_DESCRIPTIONS: Record<string, string> = {
"Draft release-announcement posts for the X (Twitter) account — every draft is held in a queue for you to edit, approve, or reject; nothing posts automatically, and it stays inert until you set credentials in the X card below.",
x_replies_enabled:
"Also poll X mentions and draft replies (still held for your approval — nothing auto-replies). Off by default: reading mentions needs a paid X API tier, so leave this off if you only want release posts.",
x_feature_spotlight_enabled:
"Periodically spawn the Head of Marketing to investigate RoboCo's shipped features and draft a spotlight post for an under-publicized capability — held in the same X post queue below for your approval. Needs x_engine_enabled and X credentials.",
roadmap_engine_enabled:
"Weekly: the Product Owner explores the company's projects and proposes a themed cycle of 3-7 roadmap items — you approve or reject each one individually; approved items land in the backlog and nothing auto-starts.",
fable_mode_enabled:
+6 -1
View File
@@ -5,6 +5,7 @@ export interface CompanyGoals {
objectives: Record<string, unknown>[];
constraints: string[];
operating_policy: Record<string, unknown>;
brand_voice: string;
updated_at?: string | null;
updated_by?: string | null;
}
@@ -12,7 +13,11 @@ export interface CompanyGoals {
export type CompanyGoalsUpdate = Partial<
Pick<
CompanyGoals,
"north_star" | "objectives" | "constraints" | "operating_policy"
| "north_star"
| "objectives"
| "constraints"
| "operating_policy"
| "brand_voice"
>
>;
+7 -1
View File
@@ -13,15 +13,21 @@ export interface XMentionRef {
text: string;
}
export interface XFeatureRef {
slug: string;
title: string;
}
export interface XPost {
task_id: string;
source: "x_post" | "x_reply";
source: "x_post" | "x_reply" | "x_feature";
title: string;
status: string;
body: string;
char_count: number;
release_version?: string | null;
mention?: XMentionRef | null;
feature?: XFeatureRef | null;
reject_reason?: string | null;
}
+22
View File
@@ -25,6 +25,7 @@ from roboco.api.schemas.v1.do import (
NotifyRequest,
PitchRequest,
ProgressRequest,
ProposeFeatureSpotlightRequest,
ProposeRoadmapRequest,
PRUpdateRequest,
ReadMessagesRequest,
@@ -154,6 +155,27 @@ async def do_propose_roadmap(
return envelope_to_response(env, request)
@router.post("/propose_feature_spotlight")
@guard_deco.rate_limit(requests=20, window=60)
@guard_deco.max_request_size(size_bytes=65536)
@guard_deco.custom_validation(secret_exfil_validator)
@guard_deco.content_type_filter(["application/json"])
@guard_deco.behavior_analysis(_RUNAWAY_RULES)
async def do_propose_feature_spotlight(
request: Request,
body: ProposeFeatureSpotlightRequest,
x_agent_id: _AgentIdHeader,
actions: _ContentActionsDep,
) -> dict:
env = await actions.propose_feature_spotlight(
agent_id=x_agent_id,
feature_slug=body.feature_slug,
feature_title=body.feature_title,
body=body.body,
)
return envelope_to_response(env, request)
@router.post("/dm")
@guard_deco.rate_limit(requests=60, window=60)
@guard_deco.max_request_size(size_bytes=65536)
+3
View File
@@ -11,6 +11,7 @@ from roboco.api.deps import CurrentAgentContext, DbSession, require_ceo_role
from roboco.api.schemas.x import (
XCredentialsSetRequest,
XCredentialsStatus,
XFeatureRefModel,
XMentionRefModel,
XPostApproveRequest,
XPostExecuteResponse,
@@ -43,6 +44,7 @@ def _status_value(task: "TaskTable") -> str:
def _to_response(task: "TaskTable") -> XPostResponse:
body = markers.get_x_draft_body(task) or task.description or ""
mention = markers.get_x_mention_ref(task)
feature = markers.get_x_feature_ref(task)
return XPostResponse(
task_id=str(task.id),
source=task.source,
@@ -52,6 +54,7 @@ def _to_response(task: "TaskTable") -> XPostResponse:
char_count=len(body),
release_version=markers.get_x_release_version(task),
mention=XMentionRefModel(**mention) if mention else None,
feature=XFeatureRefModel(**feature) if feature else None,
reject_reason=markers.get_x_reject_reason(task),
)
+2
View File
@@ -12,6 +12,7 @@ class CompanyGoalsResponse(BaseModel):
objectives: list[dict[str, Any]]
constraints: list[str]
operating_policy: dict[str, Any]
brand_voice: str = ""
updated_at: str | None = None
updated_by: str | None = None
@@ -23,3 +24,4 @@ class CompanyGoalsUpdate(BaseModel):
objectives: list[dict[str, Any]] | None = Field(default=None)
constraints: list[str] | None = Field(default=None)
operating_policy: dict[str, Any] | None = Field(default=None)
brand_voice: str | None = Field(default=None)
+9
View File
@@ -128,6 +128,15 @@ class ProposeRoadmapRequest(BaseModel):
items: list[RoadmapItemInput] = Field(..., min_length=1)
class ProposeFeatureSpotlightRequest(BaseModel):
"""Head of Marketing's feature-spotlight draft: a picked feature + a
ready-to-post body."""
feature_slug: str = Field(..., min_length=1, max_length=128)
feature_title: str = Field(..., min_length=1)
body: str = Field(..., min_length=1)
class DmRequest(BaseModel):
recipient: str # agent slug
text: str = Field(..., min_length=1)
+11 -2
View File
@@ -15,17 +15,26 @@ class XMentionRefModel(BaseModel):
text: str
class XFeatureRefModel(BaseModel):
"""The shipped feature a held spotlight draft covers."""
slug: str
title: str
class XPostResponse(BaseModel):
"""One held draft (release post or mention reply) awaiting the CEO."""
"""One held draft (release post, mention reply, or feature spotlight)
awaiting the CEO."""
task_id: str
source: str # "x_post" | "x_reply"
source: str # "x_post" | "x_reply" | "x_feature"
title: str
status: str
body: str
char_count: int
release_version: str | None = None
mention: XMentionRefModel | None = None
feature: XFeatureRefModel | None = None
reject_reason: str | None = None
+18
View File
@@ -866,6 +866,24 @@ class Settings(BaseSettings):
gt=0,
description="Per-request timeout for outbound X API HTTP calls.",
)
x_feature_spotlight_enabled: bool = Field(
default=False,
description=(
"Sub-switch for the feature-spotlight half of the X engine. OFF by "
"default: even with x_engine_enabled on, the engine drafts only "
"release-announcement posts (and mention replies if enabled) — it does "
"not spawn the Head of Marketing to investigate shipped capabilities. "
"Unlike the local-model-only release/reply drafts, this spawns a real "
"cloud-LLM agent per cycle, so it is a deliberate, costlier opt-in."
),
)
x_feature_spotlight_interval_seconds: int = Field(
default=259200, # 3 days — tunable; marketing cadence is a CEO call, not a
# technical constant. Sits between the 30-min mentions poll
# and the weekly roadmap cycle.
ge=3600,
description="Seconds between feature-spotlight exploration cycles.",
)
# Board roadmap engine — weekly, the Product Owner explores the company's
# projects and proposes a themed cycle of roadmap items; the CEO approves
+18
View File
@@ -1652,6 +1652,11 @@ class CompanyGoalsTable(Base):
operating_policy: Mapped[dict[str, Any]] = mapped_column(
JSON, nullable=False, default=dict
)
# CEO-authored brand-voice sample/direction — a first-class, long charter
# field (like north_star), not folded into the catch-all operating_policy
# JSON blob, so it gets the same discoverability. Feeds XEngine._voice_guide
# and the Head of Marketing's briefing; empty until the CEO sets it.
brand_voice: Mapped[str] = mapped_column(Text, nullable=False, default="")
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(UTC),
@@ -2254,3 +2259,16 @@ class XSeenMentionTable(Base):
seen_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(UTC), nullable=False
)
class XSeenFeatureTable(Base):
"""Dedup ledger for feature-spotlight drafts — one row per feature slug the
engine has ever turned into a held spotlight. Prevents re-covering the same
shipped capability on a later cycle. Never pruned by task terminal state."""
__tablename__ = "x_seen_features"
feature_slug: Mapped[str] = mapped_column(String(128), primary_key=True)
seen_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(UTC), nullable=False
)
@@ -39,6 +39,8 @@ X_RELEASE_VERSION = "x_release_version"
X_MENTION_REF = "x_mention_ref"
X_REJECT_REASON = "x_reject_reason"
X_POSTED_TWEET_ID = "x_posted_tweet_id"
X_FEATURE_REF = "x_feature_ref"
X_SEEN_FEATURES = "x_seen_features"
ROADMAP_CYCLE = "roadmap_cycle"
@@ -186,6 +188,24 @@ def set_x_posted_tweet_id(task: HasMarkers, tweet_id: str) -> None:
set_marker(task, X_POSTED_TWEET_ID, tweet_id)
def get_x_feature_ref(task: HasMarkers) -> dict[str, Any] | None:
val = get_marker(task, X_FEATURE_REF)
return val if isinstance(val, dict) else None
def set_x_feature_ref(task: HasMarkers, ref: dict[str, Any]) -> None:
set_marker(task, X_FEATURE_REF, ref)
def get_x_seen_features(task: HasMarkers) -> list[str]:
val = get_marker(task, X_SEEN_FEATURES, [])
return [str(s) for s in val] if isinstance(val, list) else []
def set_x_seen_features(task: HasMarkers, slugs: list[str]) -> None:
set_marker(task, X_SEEN_FEATURES, [str(s) for s in slugs])
# --- board roadmap cycle ---------------------------------------------------
# The themed cycle (goal + item drafts) the Product Owner authors via
# ``propose_roadmap`` onto the exploration task the roadmap engine opened.
+128
View File
@@ -75,6 +75,7 @@ from roboco.services.task import (
RELEASE_MANAGER_SOURCE,
ROADMAP_SOURCE,
SELF_HEAL_SOURCE,
X_FEATURE_EXPLORATION_SOURCE,
X_SOURCES,
)
@@ -770,6 +771,7 @@ class AgentOrchestrator:
self._release_manager_task: asyncio.Task | None = None
self._x_mentions_task: asyncio.Task | None = None
self._roadmap_engine_task: asyncio.Task | None = None
self._x_feature_spotlight_task: asyncio.Task | None = None
# Provider registry: maps a ModelProvider to a dedicated AgentProvider
# backend. Only providers needing a non-Claude-Code runtime are
# registered (currently GROK, which speaks the OpenAI protocol). Agents
@@ -946,6 +948,9 @@ class AgentOrchestrator:
self._release_manager_task = asyncio.create_task(self._release_manager_loop())
self._x_mentions_task = asyncio.create_task(self._x_mentions_poll_loop())
self._roadmap_engine_task = asyncio.create_task(self._roadmap_engine_loop())
self._x_feature_spotlight_task = asyncio.create_task(
self._x_feature_spotlight_loop()
)
logger.info(
"Orchestrator started",
@@ -1042,6 +1047,7 @@ class AgentOrchestrator:
self._release_manager_task,
self._x_mentions_task,
self._roadmap_engine_task,
self._x_feature_spotlight_task,
):
await self._cancel_background_task(task)
@@ -7562,6 +7568,35 @@ Start by:
await get_roadmap_engine(db).run_cycle()
await db.commit()
async def _x_feature_spotlight_loop(self) -> None:
"""X engine: on an interval, open ONE held feature-spotlight exploration
for the Head of Marketing.
Dormant by default returns immediately unless BOTH x_engine_enabled and
x_feature_spotlight_enabled, so a standard deployment (or one running only
release posts / mention replies) never spawns HoM for this.
"""
if not (settings.x_engine_enabled and settings.x_feature_spotlight_enabled):
return
interval = settings.x_feature_spotlight_interval_seconds
while self._running:
try:
await asyncio.sleep(interval)
await self._run_x_feature_spotlight_cycle()
except asyncio.CancelledError:
break
except Exception:
logger.exception("x-feature-spotlight cycle failed")
async def _run_x_feature_spotlight_cycle(self) -> None:
"""One feature-spotlight pass: run the engine, commit. Testable w/o sleep."""
from roboco.db import get_db_context
from roboco.services.x_engine import get_x_engine
async with get_db_context() as db:
await get_x_engine(db).open_feature_spotlight_exploration()
await db.commit()
async def _load_dep_update_set(self, db: Any) -> list[Any]:
"""Projects with a ``dep_update_command`` + a git_url, one per
(repo, command).
@@ -10386,6 +10421,41 @@ Start now: evidence(task_id="{task_id}")
spawned_by="_dispatch_roadmap_exploration",
)
async def _dispatch_feature_spotlight_exploration(
self, task: dict[str, Any]
) -> None:
"""One-shot Head-of-Marketing spawn to investigate + author a spotlight.
Simpler than _dispatch_roadmap_exploration: no "already authored" marker
pre-check is needed here, because a successful propose_feature_spotlight()
completes this task atomically (it stops matching the PENDING fetch on the
next tick) unlike the roadmap cycle, which stays open across the CEO's
per-item decisions and needs the marker check to avoid re-spawning the PO
after authoring. Reuses the same one-shot _board_dispatched tracker +
respawn breaker every other board dispatch uses.
"""
task_id = str(task.get("id"))
hom_slug = "head-marketing"
if self._is_agent_active(hom_slug):
return
key = (hom_slug, task_id)
if key in self._board_dispatched:
return
if await self._pm_respawn_should_gate(hom_slug, task):
return
self._board_dispatched.add(key)
logger.info(
"Spawning Head of Marketing for feature-spotlight exploration",
task_id=task_id,
)
await self.spawn_agent(
agent_id=hom_slug,
task_id=task["id"],
initial_prompt=self._build_feature_spotlight_prompt(task),
git_context=self._task_git_context(task),
spawned_by="_dispatch_feature_spotlight_exploration",
)
def _board_review_complete(self, task_id: str) -> bool:
"""True once EVERY board reviewer has reviewed and gone idle.
@@ -10597,6 +10667,13 @@ Start now: evidence(task_id="{task_id}")
# spawn Head of Marketing and fire the Approve & Start
# handoff, both wrong for a roadmap cycle).
await self._dispatch_roadmap_exploration(task)
elif task.get("source") == X_FEATURE_EXPLORATION_SOURCE:
# HoM-solo (mirrors the ROADMAP_SOURCE branch above) —
# bypasses the two-reviewer board-review gate; never rides
# _handle_board_assigned_task (that would also spawn the
# Product Owner and fire the Approve & Start handoff, both
# wrong for a feature-spotlight cycle).
await self._dispatch_feature_spotlight_exploration(task)
elif self._resolve_agent_slug(assigned_to) in self._BOARD_AGENTS:
await self._handle_board_assigned_task(task, assigned_to)
else:
@@ -11105,6 +11182,12 @@ Never `commit`, never write code, never run `git`. PMs coordinate.
# belt-and-suspenders parity with the other held-artifact sources.
if task.get("source") == ROADMAP_SOURCE:
continue
# A feature-spotlight exploration cycle is Board work (HoM's
# one-shot dispatch owns it via _dispatch_pm_work) — never dev work.
# X_FEATURE_SOURCE (the materialized draft) is already covered by
# the X_SOURCES check above; this is the exploration task itself.
if task.get("source") == X_FEATURE_EXPLORATION_SOURCE:
continue
# A self-heal fix task held for the CEO's Approve-&-Start is not dev
# work yet — it must not route to its assigned_to as a dev before
# the CEO approves it.
@@ -12739,6 +12822,51 @@ involved in this cycle.
Do NOT claim, plan, delegate, or attempt to start any of the items yourself
that is not your job here, and the gateway will reject those verbs.
"""
def _build_feature_spotlight_prompt(self, task: dict[str, Any]) -> str:
"""Prompt for the Head of Marketing's one-shot feature-spotlight cycle."""
task_id = task.get("id", "unknown")
markers_dict = task.get("orchestration_markers") or {}
seen = markers_dict.get(_markers.X_SEEN_FEATURES) or []
seen_line = ", ".join(seen) if seen else "(none yet — this is the first cycle)"
return f"""\
You are the Head of Marketing. It's time for your periodic feature-spotlight cycle.
TASK: {task_id}
RoboCo markets its own capabilities, not just releases. Investigate what the
company has actually shipped and draft ONE marketing post about a genuinely
useful, under-publicized capability something a user or prospect would not
already know from the last release announcement.
ALREADY COVERED do not repeat: {seen_line}
== WHAT TO DO ==
1. triage() see your board-level context.
2. Investigate (read-only, you have full repo read access): CHANGELOG.md (what
has actually shipped), the feature-flags ledger (panel/src/components/
settings/feature-flags-card.tsx and roboco/services/settings.py's
FEATURE_FLAGS the enumerated subsystems), docs/map/ (the exhaustive
codebase map each slice's Purpose section is marketing-readable), the
company charter (already in your briefing), and the knowledge base
(roboco_ask_mentor / roboco_kb_search).
3. Pick ONE feature that is real, currently shipped (or shipped behind a flag
the CEO can enable), not in the already-covered list above, and worth
telling people about.
4. Draft ONE post in your voice (see your identity's VOICE GUIDE) — plain
text, no markdown, no thread, max 280 characters, and never invent a
capability that doesn't exist.
5. propose_feature_spotlight(feature_slug="<a short stable slug>",
feature_title="<human-readable feature name>", body="<the post>")
call this EXACTLY ONCE.
6. i_am_idle() once proposed. The CEO reviews, edits, approves, or rejects
the draft in the X post queue; nothing posts without that explicit
approval.
Do NOT claim, plan, delegate, or attempt to post anything yourself that is
not your job here, and the gateway will reject those.
"""
def _build_marketing_prompt(self, task: dict[str, Any]) -> str:
+4
View File
@@ -27,6 +27,7 @@ _EMPTY: dict[str, Any] = {
"objectives": [],
"constraints": [],
"operating_policy": {},
"brand_voice": "",
"updated_at": None,
"updated_by": None,
}
@@ -61,6 +62,8 @@ class CompanyGoalsService(BaseService):
row.constraints = data["constraints"]
if "operating_policy" in data:
row.operating_policy = data["operating_policy"]
if "brand_voice" in data:
row.brand_voice = data["brand_voice"]
if updated_by is not None:
row.updated_by = updated_by
await self.session.flush()
@@ -73,6 +76,7 @@ class CompanyGoalsService(BaseService):
"objectives": row.objectives or [],
"constraints": row.constraints or [],
"operating_policy": row.operating_policy or {},
"brand_voice": row.brand_voice or "",
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
"updated_by": str(row.updated_by) if row.updated_by else None,
}
@@ -28,6 +28,7 @@ from roboco.services.content_notes import content_type_for_role
from roboco.services.gateway.commit_validator import validate_commit_message
from roboco.services.gateway.envelope import Envelope
from roboco.services.gateway.evidence_builder import build_evidence_for_task
from roboco.services.x_client import MAX_TWEET_CHARS
if TYPE_CHECKING:
from uuid import UUID
@@ -307,6 +308,10 @@ _PITCH_ROLES: frozenset[str] = frozenset({"product_owner", "head_marketing"})
# spec's non-goals).
_ROADMAP_ROLES: frozenset[str] = frozenset({"product_owner"})
# Feature spotlights are HoM-authored — the Product Owner stays out of this
# cycle (mirrors _ROADMAP_ROLES's PO-only symmetry, reversed).
_FEATURE_SPOTLIGHT_ROLES: frozenset[str] = frozenset({"head_marketing"})
# Text fields on a roadmap item draft, with their anti-soup minimum length.
_ROADMAP_ITEM_TEXT_FIELDS: tuple[tuple[str, int], ...] = (
("title", 5),
@@ -1206,6 +1211,100 @@ class ContentActions:
},
)
@classmethod
def _reject_feature_spotlight_fields(
cls, feature_slug: str, feature_title: str, body: str
) -> Envelope | None:
"""Soup + 280-char validation for a spotlight draft's free-text fields,
collapsed into one caller-side check (keeps propose_feature_spotlight's
return-statement count under the xenon/PLR0911 budget)."""
if rej := cls._reject_soup(feature_slug, field="feature_slug", min_chars=2):
return rej
if rej := cls._reject_soup(feature_title, field="feature_title", min_chars=4):
return rej
if rej := cls._reject_soup(body, field="body", min_chars=8):
return rej
if len(body) > MAX_TWEET_CHARS:
return Envelope.invalid_state(
message=(
f"body is {len(body)} chars, over the {MAX_TWEET_CHARS}-char "
"tweet limit"
),
remediate="shorten the post to 280 characters or fewer",
context_briefing={},
)
return None
async def propose_feature_spotlight(
self,
*,
agent_id: UUID,
feature_slug: str,
feature_title: str,
body: str,
) -> Envelope:
"""Head of Marketing authors ONE feature-spotlight draft.
Validates role, field lengths, the 280-char tweet limit, and that the
feature hasn't already been covered, then materializes the held X-queue
draft and completes the caller's exploration task. One call per cycle.
"""
role = await self._caller_role(agent_id)
if role not in _FEATURE_SPOTLIGHT_ROLES:
return Envelope.not_authorized(
message=(
f"role {role!r} cannot propose a feature spotlight; only the "
"Head of Marketing does"
),
remediate="this verb is Head-of-Marketing-only",
context_briefing={},
)
if rej := self._reject_feature_spotlight_fields(
feature_slug, feature_title, body
):
return rej
from roboco.services.task import get_task_service
from roboco.services.x_engine import get_x_engine
task_svc = get_task_service(self.task.session)
explorations = await task_svc.list_open_feature_explorations()
task = next((t for t in explorations if t.assigned_to == agent_id), None)
if task is None:
return Envelope.invalid_state(
message="no open feature-spotlight exploration task assigned to you",
remediate=(
"propose_feature_spotlight only runs against an active "
"exploration spawned by the X engine; wait for the next cycle"
),
context_briefing={},
)
engine = get_x_engine(self.task.session)
if await engine.is_feature_seen(feature_slug):
return Envelope.invalid_state(
message=f"feature {feature_slug!r} was already covered",
remediate=(
"pick a different, not-yet-covered feature — see the "
"seen-features list in your briefing"
),
context_briefing={},
)
new_task = await engine.materialize_feature_spotlight(
exploration_task=task,
feature_slug=feature_slug,
feature_title=feature_title,
body=body,
)
return Envelope.ok(
status="feature_spotlight_proposed",
task_id=str(new_task.id),
next="i_am_idle() — the CEO reviews the draft in the X post queue",
context_briefing={
"feature_slug": feature_slug,
"feature_title": feature_title,
},
)
async def dm(
self,
*,
+9 -1
View File
@@ -20,6 +20,7 @@ _MENTION_EXCERPT_CAP = 280
_NOTIFICATION_BODY_CAP = 500
_HANDOFF_CONTENT_CAP = 800
_NORTH_STAR_CAP = 600
_BRAND_VOICE_CAP = 600
_A2A_PREVIEW_CAP = 200
@@ -54,7 +55,10 @@ class EvidenceRepo:
objectives = row.objectives or []
constraints = row.constraints or []
operating_policy = row.operating_policy or {}
if not any((north_star, objectives, constraints, operating_policy)):
brand_voice = row.brand_voice or ""
if not any(
(north_star, objectives, constraints, operating_policy, brand_voice)
):
return None
return {
# north_star is free Text — cap it for the briefing (the full
@@ -63,6 +67,10 @@ class EvidenceRepo:
"objectives": objectives[:BRIEFING_LIST_CAP],
"constraints": constraints[:BRIEFING_LIST_CAP],
"operating_policy": operating_policy,
# Same free-Text/singleton-charter shape as north_star — the CEO's
# brand-voice sample, so any full-briefing consumer (XEngine's HoM
# spawn included) sees it without a second read.
"brand_voice": brand_voice[:_BRAND_VOICE_CAP],
}
async def list_unread_a2a(self, agent_id: UUID) -> list[dict[str, Any]]:
+7 -1
View File
@@ -116,6 +116,12 @@ _PRODUCT_OWNER_DO = (
"propose_roadmap",
)
# Head of Marketing only (mirrors _PRODUCT_OWNER_DO's propose_roadmap grant).
_HEAD_MARKETING_DO = (
*_BOARD_DO,
"propose_feature_spotlight",
)
_AUDITOR_FLOW = spec.intents_for_role(spec.Role.AUDITOR)
# Auditor reads, does not chat or escalate. notify_list/get for inbox visibility;
# no ack (silent observer — wouldn't ack notifications).
@@ -208,7 +214,7 @@ ROLE_CONFIGS: dict[str, RoleConfig] = {
"head_marketing": RoleConfig(
role="head_marketing",
flow_tools=_HEAD_MARKETING_FLOW,
do_tools=_BOARD_DO,
do_tools=_HEAD_MARKETING_DO,
allows_write=False,
allows_subagent=True,
description="Marketing oversight; escalates to CEO.",
+1
View File
@@ -64,6 +64,7 @@ FEATURE_FLAGS: tuple[tuple[str, str], ...] = (
("routing_strict", "Strict model routing (fail-closed on a disabled provider)"),
("x_engine_enabled", "X (Twitter) engine"),
("x_replies_enabled", "X mention replies (needs a paid X API tier)"),
("x_feature_spotlight_enabled", "X feature-spotlight marketing"),
("roadmap_engine_enabled", "Board roadmap engine"),
("fable_mode_enabled", "Fable-mode doctrine + hooks"),
)
+38 -10
View File
@@ -555,12 +555,28 @@ DEP_UPDATE_SOURCE = "dep_update"
RELEASE_MANAGER_SOURCE = "release_manager"
# Source tags for the X (Twitter) engine's held posts: a drafted release
# announcement (x_post) or a drafted mention reply (x_reply). Like
# release_manager these are NEVER dispatched — held for the CEO
# (confirmed_by_human=False) and acted on only by the x routes + post service.
# announcement (x_post), a drafted mention reply (x_reply), or a drafted
# feature spotlight (x_feature). Like release_manager these are NEVER
# dispatched — held for the CEO (confirmed_by_human=False) and acted on only
# by the x routes + post service.
X_POST_SOURCE = "x_post"
X_REPLY_SOURCE = "x_reply"
X_SOURCES = (X_POST_SOURCE, X_REPLY_SOURCE)
# Source tag for the Head of Marketing's held feature-spotlight investigation:
# a PENDING task the X engine opens for HoM to explore shipped capabilities and
# author one spotlight via propose_feature_spotlight(). Like ROADMAP_SOURCE (and
# unlike X_SOURCES below) it IS dispatched — one-shot HoM spawn — but never rides
# the delivery lifecycle; a successful call completes it and materializes the
# actual draft under X_FEATURE_SOURCE.
X_FEATURE_EXPLORATION_SOURCE = "x_feature_exploration"
# Source tag for a materialized feature-spotlight draft — the HoM-authored,
# fully-formed post. Added to X_SOURCES so it inherits every existing consumer
# (XPostService, list_open_x_posts, the PM-dispatcher's held-source skip) for
# free.
X_FEATURE_SOURCE = "x_feature"
X_SOURCES = (X_POST_SOURCE, X_REPLY_SOURCE, X_FEATURE_SOURCE)
# Source tag for a board roadmap exploration cycle: a PENDING task the roadmap
# engine opens for the Product Owner to author a themed cycle of roadmap item
@@ -1411,6 +1427,19 @@ class TaskService(BaseService):
)
return list(result.scalars().all())
async def list_open_feature_explorations(self) -> list[TaskTable]:
"""Non-terminal feature-spotlight exploration tasks — the one-open-cycle
dedup + propose_feature_spotlight's task lookup. Ordered oldest-first."""
result = await self.session.execute(
select(TaskTable)
.where(
TaskTable.source == X_FEATURE_EXPLORATION_SOURCE,
TaskTable.status.notin_([TaskStatus.COMPLETED, TaskStatus.CANCELLED]),
)
.order_by(TaskTable.created_at)
)
return list(result.scalars().all())
async def list_external_pr_reviews_awaiting_decision(self) -> list[TaskTable]:
"""Completed external-PR reviews still awaiting the CEO's decision.
@@ -7681,9 +7710,10 @@ class TaskService(BaseService):
CEO opens the gate. The hold is scoped to self-heal: ordinary delegated
subtasks default to ``confirmed_by_human=False`` (the PM delegated them,
which is itself the authorization to start) and MUST still be offered.
The same hold covers ``x_post``/``x_reply`` CEO-gated X drafts owned
by the Secretary, who has no ``give_me_work`` verb, but held here too
for the same belt-and-suspenders reason.
The same hold covers every ``X_SOURCES`` member (``x_post``/``x_reply``/
``x_feature``) CEO-gated X drafts owned by the Secretary, who has no
``give_me_work`` verb, but held here too for the same belt-and-suspenders
reason.
Ordered by sequence asc, then priority asc, then created_at asc so
earlier-sequence tasks win.
@@ -7694,9 +7724,7 @@ class TaskService(BaseService):
TaskTable.assigned_to == agent_id,
TaskTable.status == TaskStatus.PENDING,
or_(
TaskTable.source.notin_(
(SELF_HEAL_SOURCE, X_POST_SOURCE, X_REPLY_SOURCE)
),
TaskTable.source.notin_((SELF_HEAL_SOURCE, *X_SOURCES)),
TaskTable.confirmed_by_human.is_(True),
),
)
+138 -7
View File
@@ -24,15 +24,19 @@ from __future__ import annotations
from typing import TYPE_CHECKING, cast
import httpx
from sqlalchemy import select
from roboco.config import settings
from roboco.db.tables import XSeenMentionTable
from roboco.db.tables import XSeenFeatureTable, XSeenMentionTable
from roboco.foundation import identity as _foundation
from roboco.foundation.policy.content import markers
from roboco.models.base import Complexity, TaskNature, TaskStatus, TaskType, Team
from roboco.services.base import BaseService
from roboco.services.company_goals import get_company_goals_service
from roboco.services.project import get_project_service
from roboco.services.task import (
X_FEATURE_EXPLORATION_SOURCE,
X_FEATURE_SOURCE,
X_POST_SOURCE,
X_REPLY_SOURCE,
TaskCreateRequest,
@@ -52,6 +56,7 @@ if TYPE_CHECKING:
from sqlalchemy.ext.asyncio import AsyncSession
from roboco.db.tables import ProjectTable, TaskTable
from roboco.services.task import TaskService
_CHAT_TIMEOUT_SECONDS = 60.0
_MIN_MENTION_CHARS = 3
@@ -76,19 +81,19 @@ def _fallback_release_body(version: str, highlights: list[str]) -> str:
return f"RoboCo v{version} is out: {lead}"
def _release_prompt(version: str, highlights: list[str]) -> str:
def _release_prompt(version: str, highlights: list[str], voice: str) -> str:
bullets = "\n".join(f"- {h}" for h in highlights[:5]) or "- routine improvements"
return (
f"{_HOM_VOICE}\n\n"
f"{voice}\n\n"
f"Draft ONE tweet (max 280 characters) announcing that RoboCo "
f"v{version} just shipped. Lead with the most user-visible change.\n\n"
f"Highlights:\n{bullets}\n"
)
def _reply_prompt(mention: XMention) -> str:
def _reply_prompt(mention: XMention, voice: str) -> str:
return (
f"{_HOM_VOICE}\n\n"
f"{voice}\n\n"
"Draft ONE reply tweet (max 280 characters) to this mention. Be "
"helpful and on-brand; do not invent facts about RoboCo.\n\n"
f'Mention: "{mention.text}"\n'
@@ -132,6 +137,16 @@ def _is_meaningful(mention: XMention, min_engagement: int) -> bool:
return engagement >= min_engagement
_FEATURE_EXPLORATION_TITLE = "X feature-spotlight exploration"
_FEATURE_EXPLORATION_DESCRIPTION = (
"Investigate RoboCo's own shipped capabilities — CHANGELOG.md, the "
"feature-flags ledger, docs/map, the company charter, and the knowledge "
"base — and pick ONE under-publicized feature not already covered (see "
"the seen-features list on this task). Draft ONE marketing post via "
"propose_feature_spotlight()."
)
class XEngine(BaseService):
"""Draft release posts (event hook) + mention replies (poll), both held."""
@@ -155,6 +170,23 @@ class XEngine(BaseService):
slug = (settings.self_heal_project_slug or "roboco-api").strip()
return await get_project_service(self.session).get_by_slug(slug)
async def _voice_guide(self) -> str:
"""Baseline house style plus the CEO's brand-voice sample, when set.
The CEO-supplied sample lives in the company charter (``company_goals.
brand_voice``, panel-editable) a live DB read, never hardcoded, so an
edit takes effect on the next draft with no deploy. Falls back to the
baseline constant alone when unset, so this is additive, not required.
"""
charter = await get_company_goals_service(self.session).get()
brand_voice = (charter.get("brand_voice") or "").strip()
if not brand_voice:
return _HOM_VOICE
return (
f"{_HOM_VOICE}\n\n"
f"Additional brand-voice direction from the CEO:\n{brand_voice}"
)
# ---- release posts (event-driven hook) --------------------------------
async def draft_release_post(
@@ -206,8 +238,9 @@ class XEngine(BaseService):
return task
async def _draft_release_body(self, version: str, highlights: list[str]) -> str:
voice = await self._voice_guide()
try:
draft = await _chat(_release_prompt(version, highlights))
draft = await _chat(_release_prompt(version, highlights, voice))
except Exception as exc:
self.log.warning(
"x-engine: local-model draft failed (fallback template)",
@@ -280,8 +313,9 @@ class XEngine(BaseService):
return task
async def _draft_reply_body(self, mention: XMention) -> str:
voice = await self._voice_guide()
try:
draft = await _chat(_reply_prompt(mention))
draft = await _chat(_reply_prompt(mention, voice))
except Exception as exc:
self.log.warning(
"x-engine: local-model reply draft failed (fallback template)",
@@ -298,6 +332,76 @@ class XEngine(BaseService):
self.session.add(XSeenMentionTable(mention_id=mention_id))
await self.session.flush()
# ---- feature spotlight (periodic HoM investigation) --------------------
async def open_feature_spotlight_exploration(self) -> TaskTable | None:
"""Originate ONE held exploration task for the Head of Marketing, or None.
No-ops when the flags are off, no X credentials are configured (drafting
content nobody can ever post is pointless mirrors the release/mentions
guard), a cycle is already open, the shared open-post cap is reached, or
the RoboCo project isn't resolvable. Never authors content itself — HoM
does, via propose_feature_spotlight() once the dispatcher spawns it.
"""
if not (settings.x_engine_enabled and settings.x_feature_spotlight_enabled):
return None
client = await self._client()
if not client.configured:
return None
task_svc = get_task_service(self.session)
if await task_svc.list_open_feature_explorations():
return None # one open cycle at a time
if len(await task_svc.list_open_x_posts()) >= settings.x_max_open_posts:
return None # respect the shared held-draft cap
project = await self._roboco_project()
if project is None or project.id is None:
self.log.warning(
"x-engine: RoboCo project not resolvable; skipping feature spotlight"
)
return None
return await self._originate_feature_exploration(
task_svc, cast("UUID", project.id)
)
async def _originate_feature_exploration(
self, task_svc: TaskService, project_id: UUID
) -> TaskTable:
seen = await self._seen_feature_slugs()
task = await task_svc.create(
TaskCreateRequest(
title=_FEATURE_EXPLORATION_TITLE,
description=_FEATURE_EXPLORATION_DESCRIPTION,
acceptance_criteria=[
"propose_feature_spotlight() is called once with an "
"under-publicized, not-yet-covered feature"
],
team=Team.BOARD,
assigned_to=_foundation.AGENTS["head-marketing"].uuid,
created_by=_foundation.AGENTS["system"].uuid,
task_type=TaskType.ADMINISTRATIVE,
nature=TaskNature.NON_TECHNICAL,
estimated_complexity=Complexity.LOW,
project_id=project_id,
status=TaskStatus.PENDING,
source=X_FEATURE_EXPLORATION_SOURCE,
confirmed_by_human=False, # HELD; board-dispatched, not delivery
)
)
markers.set_x_seen_features(task, seen)
await self.session.flush()
self.log.info(
"feature-spotlight exploration opened (Head of Marketing)",
task_id=str(task.id),
)
return task
async def _seen_feature_slugs(self) -> list[str]:
result = await self.session.execute(select(XSeenFeatureTable.feature_slug))
return list(result.scalars().all())
async def is_feature_seen(self, feature_slug: str) -> bool:
return await self.session.get(XSeenFeatureTable, feature_slug) is not None
# ---- shared origination -------------------------------------------------
async def _originate_post(
@@ -326,6 +430,33 @@ class XEngine(BaseService):
await self.session.flush()
return task
async def materialize_feature_spotlight(
self,
*,
exploration_task: TaskTable,
feature_slug: str,
feature_title: str,
body: str,
) -> TaskTable:
"""Complete a HoM-authored spotlight: mark the feature seen, create the
held draft (identical shape to _originate_post), complete the exploration
task. Called only from the propose_feature_spotlight content verb."""
self.session.add(XSeenFeatureTable(feature_slug=feature_slug))
task = await self._originate_post(
title=f"X post: feature spotlight — {feature_title}",
body=_clamp_tweet(body),
source=X_FEATURE_SOURCE,
project_id=cast("UUID", exploration_task.project_id),
)
markers.set_x_feature_ref(task, {"slug": feature_slug, "title": feature_title})
exploration_task.status = TaskStatus.COMPLETED
await self.session.flush()
self.log.info(
"x-engine: feature spotlight drafted (held for CEO)",
feature_slug=feature_slug,
)
return task
def get_x_engine(session: AsyncSession, client: XClient | None = None) -> XEngine:
"""Build an XEngine for ``session`` (optional injected client for tests)."""
@@ -39,6 +39,19 @@ async def test_ceo_can_update_and_persist(db_session: Any) -> None:
assert again.north_star == "Win the market"
@pytest.mark.asyncio
async def test_ceo_can_update_and_persist_brand_voice(db_session: Any) -> None:
ceo = _agent(AgentRole.CEO)
resp = await update_company_goals(
CompanyGoalsUpdate(brand_voice="Confident, dry wit."), db_session, ceo
)
assert resp.brand_voice == "Confident, dry wit."
# Persisted and readable by a non-CEO agent (round-trips through the
# Pydantic response schema, not just the service-layer dict).
again = await get_company_goals(db_session, _agent(AgentRole.QA))
assert again.brand_voice == "Confident, dry wit."
@pytest.mark.asyncio
async def test_non_ceo_cannot_update() -> None:
# The CEO check fires before any DB access, so a dummy session suffices.
+8
View File
@@ -38,3 +38,11 @@ def test_x_engine_flag_registered_in_feature_flags() -> None:
def test_x_engine_flag_validates_as_bool() -> None:
validate_setting("x_engine_enabled", "true")
def test_x_feature_spotlight_disabled_by_default() -> None:
assert Settings().x_feature_spotlight_enabled is False
def test_x_feature_spotlight_flag_registered_in_feature_flags() -> None:
assert "x_feature_spotlight_enabled" in [key for key, _ in FEATURE_FLAGS]
@@ -0,0 +1,179 @@
"""roboco.services.gateway.content_actions.propose_feature_spotlight — HoM-gated
feature-spotlight draft authoring."""
from __future__ import annotations
from typing import Any
from unittest.mock import AsyncMock, MagicMock
from uuid import uuid4
import pytest
from roboco.services.gateway.content_actions import ContentActions, ContentActionsDeps
class _FakeTask:
"""Minimal stand-in for the ORM TaskTable row — carries just what
``propose_feature_spotlight`` touches."""
def __init__(self, *, assigned_to: Any, task_id: Any = None) -> None:
self.id = task_id or uuid4()
self.assigned_to = assigned_to
self.project_id = uuid4()
def _actions(role: str) -> ContentActions:
task = MagicMock()
agent = MagicMock()
agent.role = role
task.agent_for = AsyncMock(return_value=agent)
task.session = MagicMock()
deps = ContentActionsDeps(
task=task,
git=MagicMock(),
a2a=MagicMock(),
journal=MagicMock(),
workspace=MagicMock(),
notifications=MagicMock(),
)
return ContentActions(deps)
def _valid_kwargs(**overrides: Any) -> dict[str, Any]:
kwargs: dict[str, Any] = {
"feature_slug": "org-memory",
"feature_title": "Organizational Memory Loop",
"body": "Did you know RoboCo agents learn from every completed task?",
}
kwargs.update(overrides)
return kwargs
@pytest.mark.asyncio
async def test_propose_feature_spotlight_forbidden_for_product_owner() -> None:
env = await _actions("product_owner").propose_feature_spotlight(
agent_id=uuid4(), **_valid_kwargs()
)
assert env.error == "not_authorized"
@pytest.mark.asyncio
async def test_propose_feature_spotlight_forbidden_for_developer() -> None:
env = await _actions("developer").propose_feature_spotlight(
agent_id=uuid4(), **_valid_kwargs()
)
assert env.error == "not_authorized"
@pytest.mark.asyncio
async def test_propose_feature_spotlight_rejects_short_slug() -> None:
env = await _actions("head_marketing").propose_feature_spotlight(
agent_id=uuid4(), **_valid_kwargs(feature_slug="a")
)
assert env.error == "invalid_state"
@pytest.mark.asyncio
async def test_propose_feature_spotlight_rejects_short_title() -> None:
env = await _actions("head_marketing").propose_feature_spotlight(
agent_id=uuid4(), **_valid_kwargs(feature_title="ab")
)
assert env.error == "invalid_state"
@pytest.mark.asyncio
async def test_propose_feature_spotlight_rejects_short_body() -> None:
env = await _actions("head_marketing").propose_feature_spotlight(
agent_id=uuid4(), **_valid_kwargs(body="short")
)
assert env.error == "invalid_state"
@pytest.mark.asyncio
async def test_propose_feature_spotlight_rejects_over_280_chars() -> None:
env = await _actions("head_marketing").propose_feature_spotlight(
agent_id=uuid4(), **_valid_kwargs(body="z" * 281)
)
assert env.error == "invalid_state"
@pytest.mark.asyncio
async def test_propose_feature_spotlight_no_open_exploration_is_invalid_state(
monkeypatch: pytest.MonkeyPatch,
) -> None:
task_svc = MagicMock()
task_svc.list_open_feature_explorations = AsyncMock(return_value=[])
monkeypatch.setattr("roboco.services.task.get_task_service", lambda _s: task_svc)
env = await _actions("head_marketing").propose_feature_spotlight(
agent_id=uuid4(), **_valid_kwargs()
)
assert env.error == "invalid_state"
@pytest.mark.asyncio
async def test_propose_feature_spotlight_ignores_exploration_assigned_to_another_agent(
monkeypatch: pytest.MonkeyPatch,
) -> None:
other_agent = uuid4()
exploration = _FakeTask(assigned_to=other_agent)
task_svc = MagicMock()
task_svc.list_open_feature_explorations = AsyncMock(return_value=[exploration])
monkeypatch.setattr("roboco.services.task.get_task_service", lambda _s: task_svc)
env = await _actions("head_marketing").propose_feature_spotlight(
agent_id=uuid4(), **_valid_kwargs()
)
assert env.error == "invalid_state"
@pytest.mark.asyncio
async def test_propose_feature_spotlight_rejects_already_seen_feature(
monkeypatch: pytest.MonkeyPatch,
) -> None:
agent_id = uuid4()
exploration = _FakeTask(assigned_to=agent_id)
task_svc = MagicMock()
task_svc.list_open_feature_explorations = AsyncMock(return_value=[exploration])
monkeypatch.setattr("roboco.services.task.get_task_service", lambda _s: task_svc)
engine = MagicMock()
engine.is_feature_seen = AsyncMock(return_value=True)
monkeypatch.setattr("roboco.services.x_engine.get_x_engine", lambda _s: engine)
env = await _actions("head_marketing").propose_feature_spotlight(
agent_id=agent_id, **_valid_kwargs()
)
assert env.error == "invalid_state"
engine.materialize_feature_spotlight.assert_not_called()
@pytest.mark.asyncio
async def test_propose_feature_spotlight_materializes_new_draft_task(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Happy path — deliberately asymmetric vs. propose_roadmap: the returned
task_id is the NEW materialized draft's id, never the exploration task's."""
agent_id = uuid4()
exploration = _FakeTask(assigned_to=agent_id)
task_svc = MagicMock()
task_svc.list_open_feature_explorations = AsyncMock(return_value=[exploration])
monkeypatch.setattr("roboco.services.task.get_task_service", lambda _s: task_svc)
materialized = _FakeTask(assigned_to=agent_id)
assert materialized.id != exploration.id
engine = MagicMock()
engine.is_feature_seen = AsyncMock(return_value=False)
engine.materialize_feature_spotlight = AsyncMock(return_value=materialized)
monkeypatch.setattr("roboco.services.x_engine.get_x_engine", lambda _s: engine)
env = await _actions("head_marketing").propose_feature_spotlight(
agent_id=agent_id, **_valid_kwargs()
)
assert env.error is None
assert env.status == "feature_spotlight_proposed"
assert env.task_id == str(materialized.id)
assert env.task_id != str(exploration.id)
engine.materialize_feature_spotlight.assert_awaited_once_with(
exploration_task=exploration,
feature_slug="org-memory",
feature_title="Organizational Memory Loop",
body="Did you know RoboCo agents learn from every completed task?",
)
+39 -1
View File
@@ -55,7 +55,11 @@ async def test_company_goals_none_when_no_row() -> None:
@pytest.mark.asyncio
async def test_company_goals_none_when_empty_charter() -> None:
row = SimpleNamespace(
north_star="", objectives=[], constraints=[], operating_policy={}
north_star="",
objectives=[],
constraints=[],
operating_policy={},
brand_voice="",
)
assert await _repo_with_goals_row(row).company_goals() is None
@@ -67,6 +71,7 @@ async def test_company_goals_compact_dict_when_set() -> None:
objectives=[{"metric": "NPS", "target": 50}],
constraints=["AGPL"],
operating_policy={"autonomy_level": "assisted"},
brand_voice="Confident, dry wit.",
)
goals = await _repo_with_goals_row(row).company_goals()
assert goals == {
@@ -74,9 +79,42 @@ async def test_company_goals_compact_dict_when_set() -> None:
"objectives": [{"metric": "NPS", "target": 50}],
"constraints": ["AGPL"],
"operating_policy": {"autonomy_level": "assisted"},
"brand_voice": "Confident, dry wit.",
}
@pytest.mark.asyncio
async def test_company_goals_none_when_only_brand_voice_unset() -> None:
"""A charter with substantive content but no brand_voice must still
surface brand_voice is additive, not required."""
row = SimpleNamespace(
north_star="Win the market",
objectives=[],
constraints=[],
operating_policy={},
brand_voice="",
)
goals = await _repo_with_goals_row(row).company_goals()
assert goals is not None
assert goals["brand_voice"] == ""
@pytest.mark.asyncio
async def test_company_goals_surfaces_when_only_brand_voice_set() -> None:
"""The inverse: brand_voice alone (everything else empty) must still
surface it counts toward the "charter has content" check."""
row = SimpleNamespace(
north_star="",
objectives=[],
constraints=[],
operating_policy={},
brand_voice="Speak as 'we'.",
)
goals = await _repo_with_goals_row(row).company_goals()
assert goals is not None
assert goals["brand_voice"] == "Speak as 'we'."
@pytest.mark.asyncio
async def test_constructor_stores_db_session() -> None:
fake_db = MagicMock()
@@ -0,0 +1,18 @@
"""propose_feature_spotlight is a Head-of-Marketing-only manifest grant
(mirrors propose_roadmap's PO-only symmetry, reversed)."""
from __future__ import annotations
from roboco.services.gateway.role_config import get_role_config
def test_head_marketing_gets_propose_feature_spotlight() -> None:
assert "propose_feature_spotlight" in get_role_config("head_marketing").do_tools
def test_product_owner_does_not_get_propose_feature_spotlight() -> None:
assert "propose_feature_spotlight" not in get_role_config("product_owner").do_tools
def test_developer_does_not_get_propose_feature_spotlight() -> None:
assert "propose_feature_spotlight" not in get_role_config("developer").do_tools
@@ -0,0 +1,144 @@
"""Feature-spotlight exploration dispatch — Head-of-Marketing-solo, never the
two-reviewer board-review gate, never the dev/PM delivery dispatchers.
"""
from __future__ import annotations
from typing import Any, cast
from unittest.mock import AsyncMock, MagicMock, patch
from uuid import uuid4
import pytest
from roboco.runtime.orchestrator import AgentOrchestrator
from roboco.services.task import X_FEATURE_EXPLORATION_SOURCE
def _make_orch() -> AgentOrchestrator:
orch = AgentOrchestrator.__new__(AgentOrchestrator)
cast("Any", orch)._pm_respawn_tracker = {}
cast("Any", orch)._schedule_respawn_persist = lambda *_a, **_k: None
orch._instances = {}
orch._board_dispatched = set()
return orch
def _feature_task(
*, orchestration_markers: dict[str, Any] | None = None
) -> dict[str, Any]:
return {
"id": str(uuid4()),
"status": "pending",
"team": "board",
"title": "X feature-spotlight exploration",
"description": "Investigate shipped capabilities and propose a spotlight.",
"assigned_to": "head-marketing",
"source": X_FEATURE_EXPLORATION_SOURCE,
"orchestration_markers": orchestration_markers,
}
@pytest.mark.asyncio
async def test_feature_spotlight_dispatch_spawns_only_head_marketing() -> None:
"""A feature-spotlight exploration task must spawn the Head of Marketing
alone the Product Owner is not part of this cycle."""
orch = _make_orch()
task = _feature_task()
with (
patch.object(orch, "_is_agent_active", return_value=False),
patch.object(orch, "_task_git_context", return_value=None),
patch.object(orch, "spawn_agent", new=AsyncMock()) as spawn,
):
await orch._dispatch_feature_spotlight_exploration(task)
spawn.assert_awaited_once()
calls = list(spawn.await_args_list)
assert calls[0].kwargs["agent_id"] == "head-marketing"
assert calls[0].kwargs["task_id"] == task["id"]
@pytest.mark.asyncio
async def test_feature_spotlight_dispatch_is_one_shot() -> None:
"""Re-ticking a still-pending exploration must NOT respawn — board roles
have no progression verb, so a respawn would just loop."""
orch = _make_orch()
task = _feature_task()
with (
patch.object(orch, "_is_agent_active", return_value=False),
patch.object(orch, "_task_git_context", return_value=None),
patch.object(orch, "spawn_agent", new=AsyncMock()) as spawn,
):
await orch._dispatch_feature_spotlight_exploration(task)
await orch._dispatch_feature_spotlight_exploration(task)
spawn.assert_awaited_once()
@pytest.mark.asyncio
async def test_feature_spotlight_dispatch_skips_active_hom() -> None:
orch = _make_orch()
task = _feature_task()
with (
patch.object(orch, "_is_agent_active", return_value=True),
patch.object(orch, "spawn_agent", new=AsyncMock()) as spawn,
):
await orch._dispatch_feature_spotlight_exploration(task)
spawn.assert_not_awaited()
@pytest.mark.asyncio
async def test_dispatch_pm_work_routes_feature_source_away_from_board() -> None:
"""An x_feature_exploration task must ride the dedicated feature-spotlight
dispatcher, never the two-reviewer ``_handle_board_assigned_task`` (which
would also spawn the Product Owner and fire the Approve & Start handoff
both wrong here), nor the roadmap dispatcher, nor plain PM handling."""
task = _feature_task()
stub = MagicMock()
stub._fetch_tasks = AsyncMock(return_value=[task])
stub._is_task_handled_this_tick = MagicMock(return_value=False)
stub._resolve_agent_slug = MagicMock(return_value="head-marketing")
stub._BOARD_AGENTS = frozenset({"product-owner", "head-marketing"})
stub._dispatch_roadmap_exploration = AsyncMock()
stub._dispatch_feature_spotlight_exploration = AsyncMock()
stub._handle_board_assigned_task = AsyncMock()
stub._handle_pm_assigned_task = AsyncMock()
stub._route_unassigned_pm_task = AsyncMock()
client: Any = MagicMock()
await AgentOrchestrator._dispatch_pm_work(cast("AgentOrchestrator", stub), client)
stub._dispatch_feature_spotlight_exploration.assert_awaited_once()
stub._dispatch_roadmap_exploration.assert_not_awaited()
stub._handle_board_assigned_task.assert_not_awaited()
stub._handle_pm_assigned_task.assert_not_awaited()
@pytest.mark.asyncio
async def test_feature_spotlight_tasks_are_never_routed_by_dev_dispatch() -> None:
tasks = [_feature_task()]
stub = MagicMock()
stub._fetch_tasks = AsyncMock(return_value=tasks)
stub._is_task_handled_this_tick = MagicMock(return_value=False)
stub._dev_dispatch_one = AsyncMock()
client: Any = MagicMock()
await AgentOrchestrator._dispatch_dev_work(cast("AgentOrchestrator", stub), client)
stub._dev_dispatch_one.assert_not_awaited()
def test_feature_spotlight_prompt_names_real_verbs_and_seen_features() -> None:
"""The prompt must steer HoM to its real verbs (triage /
propose_feature_spotlight / i_am_idle) and render the seen-features marker,
with a friendly fallback when the list is empty."""
orch = _make_orch()
prompt = orch._build_feature_spotlight_prompt(
_feature_task(orchestration_markers={"x_seen_features": ["org-memory"]})
)
assert "triage()" in prompt
assert "propose_feature_spotlight(" in prompt
assert "i_am_idle()" in prompt
assert "org-memory" in prompt
empty_prompt = orch._build_feature_spotlight_prompt(_feature_task())
assert "none yet" in empty_prompt.lower()
@@ -0,0 +1,60 @@
"""The feature-spotlight orchestrator loop is fully dormant unless BOTH the X
engine and the feature-spotlight sub-switch are enabled (both default off).
With either flag off, ``_x_feature_spotlight_loop`` must return immediately
no sleep, no HTTP, no DB, no Head-of-Marketing spawn so a standard
deployment (or one running only release posts / mention replies) behaves
exactly as today.
"""
from __future__ import annotations
import asyncio
import types
from typing import cast
import pytest
from roboco.config import settings as cfg
from roboco.runtime.orchestrator import AgentOrchestrator
@pytest.mark.asyncio
async def test_x_feature_spotlight_loop_returns_immediately_when_disabled(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(cfg, "x_engine_enabled", False)
monkeypatch.setattr(cfg, "x_feature_spotlight_enabled", False)
stub = cast("AgentOrchestrator", types.SimpleNamespace(_running=True))
# Gated off -> returns at once. If the gate were missing it would sleep the
# full interval and this wait_for would time out.
await asyncio.wait_for(
AgentOrchestrator._x_feature_spotlight_loop(stub), timeout=1.0
)
@pytest.mark.asyncio
async def test_x_feature_spotlight_loop_dormant_when_only_subswitch_disabled(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""x_engine_enabled on but the feature-spotlight sub-switch off: still
dormant the engine still runs release posts/mention replies via their
own loops, unaffected."""
monkeypatch.setattr(cfg, "x_engine_enabled", True)
monkeypatch.setattr(cfg, "x_feature_spotlight_enabled", False)
stub = cast("AgentOrchestrator", types.SimpleNamespace(_running=True))
await asyncio.wait_for(
AgentOrchestrator._x_feature_spotlight_loop(stub), timeout=1.0
)
@pytest.mark.asyncio
async def test_x_feature_spotlight_loop_dormant_when_only_engine_disabled(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The subswitch alone is not enough — x_engine_enabled must also be on."""
monkeypatch.setattr(cfg, "x_engine_enabled", False)
monkeypatch.setattr(cfg, "x_feature_spotlight_enabled", True)
stub = cast("AgentOrchestrator", types.SimpleNamespace(_running=True))
await asyncio.wait_for(
AgentOrchestrator._x_feature_spotlight_loop(stub), timeout=1.0
)
@@ -53,6 +53,7 @@ def _make_orchestrator() -> AgentOrchestrator:
"_release_manager_task",
"_x_mentions_task",
"_roadmap_engine_task",
"_x_feature_spotlight_task",
):
setattr(orch, attr, None)
return orch
@@ -28,6 +28,7 @@ async def test_get_returns_empty_defaults_when_unset(db_session: Any) -> None:
assert goals["objectives"] == []
assert goals["constraints"] == []
assert goals["operating_policy"] == {}
assert goals["brand_voice"] == ""
@pytest.mark.asyncio
@@ -68,3 +69,23 @@ async def test_upsert_is_singleton_and_partial(db_session: Any) -> None:
# Exactly one row exists (singleton), found at the canonical id.
row = await db_session.get(CompanyGoalsTable, SINGLETON_ID)
assert row is not None
@pytest.mark.asyncio
async def test_brand_voice_roundtrips(db_session: Any) -> None:
svc = get_company_goals_service(db_session)
await svc.upsert({"brand_voice": "Confident, dry wit, no exclamation points."})
goals = await svc.get()
assert goals["brand_voice"] == "Confident, dry wit, no exclamation points."
@pytest.mark.asyncio
async def test_brand_voice_untouched_by_partial_upsert(db_session: Any) -> None:
svc = get_company_goals_service(db_session)
await svc.upsert({"brand_voice": "Speak as 'we'."})
# A later partial upsert that omits brand_voice must leave it unchanged —
# the same partial-update contract north_star/constraints already have.
await svc.upsert({"north_star": "Ship a delightful product"})
goals = await svc.get()
assert goals["brand_voice"] == "Speak as 'we'."
assert goals["north_star"] == "Ship a delightful product"
+231 -6
View File
@@ -13,13 +13,20 @@ from unittest.mock import AsyncMock
import pytest
from roboco.config import settings as cfg
from roboco.db.tables import AgentTable, ProjectTable
from roboco.db.tables import AgentTable, ProjectTable, XSeenFeatureTable
from roboco.foundation import identity as _foundation
from roboco.foundation.policy.content import markers
from roboco.models.base import AgentRole, AgentStatus, Team
from roboco.models.base import TaskStatus as TS
from roboco.services import x_engine as x_engine_module
from roboco.services.task import X_POST_SOURCE, X_REPLY_SOURCE, get_task_service
from roboco.services.company_goals import get_company_goals_service
from roboco.services.task import (
X_FEATURE_EXPLORATION_SOURCE,
X_FEATURE_SOURCE,
X_POST_SOURCE,
X_REPLY_SOURCE,
get_task_service,
)
from roboco.services.x_client import MAX_TWEET_CHARS, XClient, XMention, XPostResult
from sqlalchemy import select
@@ -28,6 +35,7 @@ if TYPE_CHECKING:
SYSTEM_UUID = _foundation.AGENTS["system"].uuid
SECRETARY_UUID = _foundation.AGENTS["secretary-1"].uuid
HOM_UUID = _foundation.AGENTS["head-marketing"].uuid
SLUG = "roboco"
ONE = 1
TWO = 2
@@ -73,9 +81,10 @@ class _NullClient(XClient):
async def _seed(session: AsyncSession) -> None:
for uuid, slug, role in (
(SYSTEM_UUID, "system", AgentRole.SYSTEM),
(SECRETARY_UUID, "secretary-1", AgentRole.SECRETARY),
for uuid, slug, role, team in (
(SYSTEM_UUID, "system", AgentRole.SYSTEM, None),
(SECRETARY_UUID, "secretary-1", AgentRole.SECRETARY, None),
(HOM_UUID, "head-marketing", AgentRole.HEAD_MARKETING, Team.BOARD),
):
if await session.get(AgentTable, uuid) is None:
session.add(
@@ -84,7 +93,7 @@ async def _seed(session: AsyncSession) -> None:
name=slug,
slug=slug,
role=role,
team=None,
team=team,
status=AgentStatus.ACTIVE,
model_config={},
system_prompt="x",
@@ -438,3 +447,219 @@ async def test_engine_never_calls_post_tweet(
await engine.run_cycle()
await engine.draft_release_post(version=_VERSION, highlights=[])
assert client.posted == []
# --------------------------------------------------------------------------- #
# Feature-spotlight exploration (Head of Marketing)
# --------------------------------------------------------------------------- #
@pytest.mark.asyncio
async def test_feature_spotlight_disabled_creates_no_exploration(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
await _seed(db_session)
monkeypatch.setattr(cfg, "x_engine_enabled", False)
engine = x_engine_module.XEngine(db_session, client=_FakeClient())
task = await engine.open_feature_spotlight_exploration()
assert task is None
assert await get_task_service(db_session).list_open_feature_explorations() == []
@pytest.mark.asyncio
async def test_feature_spotlight_subswitch_off_creates_no_exploration(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
"""x_engine_enabled on but x_feature_spotlight_enabled off: no exploration —
the engine still drafts release posts/mention replies via the other paths."""
await _seed(db_session)
_enable(monkeypatch, x_feature_spotlight_enabled=False)
engine = x_engine_module.XEngine(db_session, client=_FakeClient())
task = await engine.open_feature_spotlight_exploration()
assert task is None
assert await get_task_service(db_session).list_open_feature_explorations() == []
@pytest.mark.asyncio
async def test_feature_spotlight_no_credentials_creates_no_exploration(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
await _seed(db_session)
_enable(monkeypatch, x_feature_spotlight_enabled=True)
engine = x_engine_module.XEngine(db_session, client=_NullClient())
task = await engine.open_feature_spotlight_exploration()
assert task is None
assert await get_task_service(db_session).list_open_feature_explorations() == []
@pytest.mark.asyncio
async def test_feature_spotlight_dedupe_one_open_cycle(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
await _seed(db_session)
_enable(monkeypatch, x_feature_spotlight_enabled=True)
engine = x_engine_module.XEngine(db_session, client=_FakeClient())
first = await engine.open_feature_spotlight_exploration()
second = await engine.open_feature_spotlight_exploration()
assert first is not None
assert second is None
open_cycles = await get_task_service(db_session).list_open_feature_explorations()
assert len(open_cycles) == ONE
cycle = open_cycles[0]
assert cycle.status == TS.PENDING
assert cycle.confirmed_by_human is False # HELD; board-dispatched only
assert cycle.assigned_to == HOM_UUID
assert cycle.team == Team.BOARD
assert cycle.source == X_FEATURE_EXPLORATION_SOURCE
assert "spotlight" in cycle.title.lower()
@pytest.mark.asyncio
async def test_feature_spotlight_respects_open_post_cap(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
await _seed(db_session)
_enable(monkeypatch, x_feature_spotlight_enabled=True, x_max_open_posts=1)
_mock_local_model(monkeypatch, "shipped!")
engine = x_engine_module.XEngine(db_session, client=_FakeClient())
# Fill the shared open-post cap with an unrelated release draft first.
await engine.draft_release_post(version="1.0.0", highlights=[])
task = await engine.open_feature_spotlight_exploration()
assert task is None
assert await get_task_service(db_session).list_open_feature_explorations() == []
@pytest.mark.asyncio
async def test_feature_spotlight_unresolvable_project_no_cycle(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
await _seed(db_session)
_enable(monkeypatch, x_feature_spotlight_enabled=True)
monkeypatch.setattr(cfg, "self_heal_project_slug", "no-such-project")
engine = x_engine_module.XEngine(db_session, client=_FakeClient())
task = await engine.open_feature_spotlight_exploration()
assert task is None
assert await get_task_service(db_session).list_open_feature_explorations() == []
@pytest.mark.asyncio
async def test_feature_spotlight_exploration_carries_seen_features_marker(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
await _seed(db_session)
db_session.add(XSeenFeatureTable(feature_slug="old-feature-1"))
db_session.add(XSeenFeatureTable(feature_slug="old-feature-2"))
await db_session.flush()
_enable(monkeypatch, x_feature_spotlight_enabled=True)
engine = x_engine_module.XEngine(db_session, client=_FakeClient())
task = await engine.open_feature_spotlight_exploration()
assert task is not None
assert set(markers.get_x_seen_features(task)) == {
"old-feature-1",
"old-feature-2",
}
@pytest.mark.asyncio
async def test_materialize_feature_spotlight_holds_draft_and_completes_exploration(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
await _seed(db_session)
_enable(monkeypatch, x_feature_spotlight_enabled=True)
engine = x_engine_module.XEngine(db_session, client=_FakeClient())
exploration = await engine.open_feature_spotlight_exploration()
assert exploration is not None
draft = await engine.materialize_feature_spotlight(
exploration_task=exploration,
feature_slug="org-memory",
feature_title="Organizational Memory Loop",
body="Did you know RoboCo agents learn from every completed task?",
)
assert draft.source == X_FEATURE_SOURCE
assert draft.assigned_to == SECRETARY_UUID
assert draft.confirmed_by_human is False
assert draft.status == TS.PENDING
ref = markers.get_x_feature_ref(draft)
assert ref is not None
assert ref["slug"] == "org-memory"
assert ref["title"] == "Organizational Memory Loop"
body = markers.get_x_draft_body(draft)
assert body is not None
assert "RoboCo" in body
# The exploration task itself is completed as a side effect...
assert exploration.status == TS.COMPLETED
# ...and therefore excluded from the open-cycle list on the next query.
still_open = await get_task_service(db_session).list_open_feature_explorations()
assert still_open == []
@pytest.mark.asyncio
async def test_materialize_feature_spotlight_marks_feature_seen(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
await _seed(db_session)
_enable(monkeypatch, x_feature_spotlight_enabled=True)
engine = x_engine_module.XEngine(db_session, client=_FakeClient())
exploration = await engine.open_feature_spotlight_exploration()
assert exploration is not None
assert await engine.is_feature_seen("sandboxed-dev-db") is False
await engine.materialize_feature_spotlight(
exploration_task=exploration,
feature_slug="sandboxed-dev-db",
feature_title="Sandboxed Dev DB/Redis",
body="Every agent now gets a throwaway Postgres + Redis sandbox.",
)
assert await engine.is_feature_seen("sandboxed-dev-db") is True
@pytest.mark.asyncio
async def test_materialize_feature_spotlight_enforces_280_chars(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
await _seed(db_session)
_enable(monkeypatch, x_feature_spotlight_enabled=True)
engine = x_engine_module.XEngine(db_session, client=_FakeClient())
exploration = await engine.open_feature_spotlight_exploration()
assert exploration is not None
draft = await engine.materialize_feature_spotlight(
exploration_task=exploration,
feature_slug="runaway-body",
feature_title="Runaway Body",
body="z" * 500, # a runaway HoM-authored draft
)
body = markers.get_x_draft_body(draft)
assert body is not None
assert len(body) <= MAX_TWEET_CHARS
# --------------------------------------------------------------------------- #
# Voice guide (feeds release/reply prompts + the HoM identity's briefing claim)
# --------------------------------------------------------------------------- #
@pytest.mark.asyncio
async def test_voice_guide_falls_back_when_brand_voice_unset(
db_session: AsyncSession,
) -> None:
await get_company_goals_service(db_session).upsert({"brand_voice": ""})
engine = x_engine_module.XEngine(db_session, client=_FakeClient())
voice = await engine._voice_guide()
assert voice == x_engine_module._HOM_VOICE
@pytest.mark.asyncio
async def test_voice_guide_appends_brand_voice_when_set(
db_session: AsyncSession,
) -> None:
await get_company_goals_service(db_session).upsert(
{"brand_voice": "Dry wit, never an exclamation point."}
)
engine = x_engine_module.XEngine(db_session, client=_FakeClient())
voice = await engine._voice_guide()
assert x_engine_module._HOM_VOICE in voice
assert "Dry wit, never an exclamation point." in voice
+34 -1
View File
@@ -24,7 +24,7 @@ from roboco.models.base import (
from roboco.models.base import TaskNature as TN
from roboco.models.base import TaskStatus as TS
from roboco.models.base import TaskType as TT
from roboco.services.task import X_POST_SOURCE, X_REPLY_SOURCE
from roboco.services.task import X_FEATURE_SOURCE, X_POST_SOURCE, X_REPLY_SOURCE
from roboco.services.x_client import XClient, XMention, XPostResult
from roboco.services.x_post_service import (
XPostBodyTooLongError,
@@ -315,3 +315,36 @@ async def test_list_open_posts_excludes_terminal(db_session: AsyncSession) -> No
ids = {t.id for t in open_posts}
assert open_task.id in ids
assert rejected_task.id not in ids
@pytest.mark.asyncio
async def test_approve_posts_feature_spotlight_draft(
db_session: AsyncSession,
) -> None:
"""The feature-spotlight source needs zero service changes: it rides the
same generic approve path as x_post/x_reply."""
task = await _seed_draft(db_session, source=X_FEATURE_SOURCE)
client = _StubClient()
with (
patch("roboco.services.x_post_service.build_x_client", return_value=client),
patch.object(XPostService, "_acquire_lock", AsyncMock(return_value="tok")),
patch.object(XPostService, "_release_lock", AsyncMock(return_value=None)),
):
result = await _svc(db_session).approve(_id(task))
assert result is not None
assert result.status == "posted"
assert result.tweet_id == "999"
assert client.calls == ["Draft body"]
await db_session.refresh(task)
assert task.status == TS.COMPLETED
assert markers.get_x_posted_tweet_id(task) == "999"
@pytest.mark.asyncio
async def test_list_open_posts_includes_feature_spotlight_source(
db_session: AsyncSession,
) -> None:
task = await _seed_draft(db_session, source=X_FEATURE_SOURCE)
open_posts = await _svc(db_session).list_open_posts()
ids = {t.id for t in open_posts}
assert task.id in ids