mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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)` |
|
||||
|
||||
@@ -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.
|
||||
@@ -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 3–7 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")
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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's briefing so all work stays
|
||||
goal-aware.
|
||||
CEO-owned north star, brand voice, objectives, constraints, and
|
||||
operating policy. Injected into every agent'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's briefing so all work stays
|
||||
goal-aware.
|
||||
The CEO-owned north star, objectives, constraints, operating policy,
|
||||
and brand voice. Injected into every agent'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:
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
>;
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
*,
|
||||
|
||||
@@ -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]]:
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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?",
|
||||
)
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user