From eb470dfb3356a97e07a12cc30f7cb4cd23052ecc Mon Sep 17 00:00:00 2001 From: Renn F Date: Tue, 28 Jul 2026 23:52:04 +0200 Subject: [PATCH] fix(x): Barfly drafts become standalone link-posts X 403s programmatic replies into conversations that don't mention the account (every non-Enterprise tier), which is Barfly's entire discovery surface - so its drafts now carry commentary plus the conversation's /i/web/status/ URL instead of a reply target. Handles are stripped (mentions in plain posts are rejected too), the 280 budget accounts for the URL, the exploration prompt asks for standalone commentary, and the reject->redraft path re-appends a dropped link. --- CLAUDE.md | 2 +- roboco/services/barfly_engine.py | 10 ++++-- roboco/services/x_engine.py | 51 ++++++++++++++++++++++++---- tests/unit/services/test_x_engine.py | 39 +++++++++++++++++++-- 4 files changed, 90 insertions(+), 12 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 33d42310..7f1c56fd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -428,7 +428,7 @@ Agent backends are pluggable. `roboco/llm/providers/` defines an `AgentProvider` **Board Program registry (per-program default-off).** Twelve new programs plus the two pre-existing origination cycles (`roadmap`/Printer, `x_feature`/spotlight — migrated onto the registry byte-for-byte, their own flags kept working as legacy aliases) all now ride one generic engine instead of bespoke per-engine loops. `BoardProgram` (`roboco/foundation/policy/board_programs.py`) is a frozen registry entry — `key`, `role` (the solo explorer), `trigger` (`cron`/`metric`/`event`), `source` (the exploration task's `tasks.source` marker), `default_interval_seconds`, `max_items_per_cycle`, `scope` (`project`/`org`) — for all fourteen entries in `PROGRAMS`. `BoardProgramEngine` (`roboco/services/board_programs.py`) runs the uniform lifecycle TRIGGER → EXPLORE → PROPOSE → DECIDE → MATERIALIZE → LEARN: the orchestrator's `_board_program_loop` ticks `run_due_programs` on a floor interval (the shortest registered cadence, clamped 300s-3600s), which for each enabled CRON program checks the `board_program_cycles` dedup ledger (migration 087 — one row per cycle, auto-closes when its exploration task goes terminal) and `program_due`, then originates via a per-program `_ORIGINATORS` callable (each program's own engine's `run_cycle`) and records the cycle row; `open_program_cycle` is the same path minus the cron-due check, used by the CEO's panel "run now" and by event/metric triggers. **Arming has no master flag**: `program_armed` (the single chokepoint every origination path routes through) reads a per-program settings-store row (`board_program.{key}.enabled`) falling back to the legacy boot flag only for `roadmap`/`x_feature` — every new program is settings-store-only, defaulting OFF until the CEO flips its toggle. **Scope + dual polarity**: `projects.board_programs` (migration 088, nullable jsonb) — a `scope="project"` program (it reads one repo: Pest Control, Spackle, Dogfood, Mirror) needs an affirmative per-project opt-in (`"pest_control"`; null/absent = out) before a cycle even opens (`_scope_gate`); a `scope="org"` program (it reads the org's process/market: Printer, Scales, Periscope, Megaphone, Barfly, War Room, Coroner, Librarian, Sentinel, x_feature) runs org-wide by default and is excluded per-project only by the opposite-polarity `"!key"` entry — one pure helper, `project_participates`, implements both. Panel: the project settings page's budget/ops card exposes both forms as checkboxes. **LEARN**: `BoardProgramEngine.record_decision` accrues each CEO approve/reject onto the cycle row's `decisions` jsonb; `prior_cycle_context` renders the last two closed cycles ("proposed N, approved N; rejected: X — reason") back into the next cycle's exploration prompt, closing the amnesia the old roadmap/spotlight engines had. Dispatchers skip every program source (`_is_non_dev_dispatch_source` / `_dispatch_board_program_exploration`'s dict-dispatch table) exactly like `board_roadmap` before; every exploration is a solo one-shot spawn reusing the `_board_dispatched` tracker + respawn breaker, bypassing the two-reviewer board-review gate. Panel: the Board Programs page (Business section, `board-programs-card.tsx` → `GET /api/board-programs`, `POST /api/board-programs/{key}/run-now`, CEO-only) lists every entry's live enablement/cadence/last-cycle summary/opted-in projects with a toggle and a "run now" button. -The fourteen programs: **Printer**/`roadmap` (PO, weekly cron, org) — the pre-existing roadmap cycle, now LEARN-fed and Periscope-briefed; `propose_roadmap` → backlog tasks. **Pest Control**/`pest_control` (PO, weekly cron + a rework-rate-spike metric accelerator, project-scoped) — hunts latent bugs in the findings ledger/rework hotspots/`ponytail:` debt; `propose_bug_hunt` (evidence-required) → ≤5 backlog tasks. **Spackle**/`spackle` (PO, biweekly cron, project-scoped) — audits half-shipped surface area (routes with no panel, flags with no docs); `propose_gap_fill` → ≤5 backlog tasks. **Scales**/`scales` (PO, monthly cron, org) — reviews the live backlog against the charter; `propose_rebalance` → a held per-item plan whose approval MUTATES the live task in place (reprioritize or cancel), never creates one. **Dogfood**/`dogfood` (PO, event — release-publish hook or CEO run-now, project-scoped) — walks the product as a user; the one program whose spawn also mounts the Playwright MCP, task-scoped via `AgentOrchestrator._is_dogfood_spawn` (not a role-wide grant); `propose_friction_fixes` (walked-path evidence required) → ≤5 backlog tasks. **Periscope**/`periscope` (HoM, weekly cron, org) — market/competitor research with mandatory `source_url` citations; `propose_market_brief` → a held `ceo_report`, no task, and feeds forward into Printer's prompt. **Megaphone**/`megaphone` (HoM, 3-day cron, org) — the standing editorial calendar off shipped-task/CHANGELOG digests; `propose_editorial_post` → the existing X held-draft queue. **Mirror**/`mirror` (HoM, quarterly cron, project-scoped) — audits README/docs-site/website messaging against shipped reality; `propose_messaging_fixes` → ≤5 backlog docs tasks. **Barfly**/`barfly` (HoM, 2-day cron, org) — replies to adjacent X conversations from a screened candidate list (`injection_guard.screen_external_text`, candidate-id-bound so a reply can't target an invented tweet); `propose_conversation_replies` → held X drafts. **War Room**/`war_room` (HoM, event — release-publish hook `WarRoomEngine.open_for_release` or CEO run-now, org) — plans a 2-6 post campaign with strictly-ascending `publish_after` timestamps (guidance only, nothing auto-schedules); `propose_campaign` → held X drafts as one batch. **Coroner**/`coroner` (Auditor, event only — task bounced `revision_count>=3`, cancelled after work started, or budget-blocked, wired at `TaskService`'s bounce/cancel chokepoints + the orchestrator's budget-block path, org) — one autopsy at a time, no cron; `propose_postmortem` (`process_change.kind` one of `playbook`/`prompt_fix`/`conventions_rule`/`other`) → a held process-change item, or drafts straight into the pending-playbook queue when `kind='playbook'`. **Librarian**/`librarian` (Auditor, biweekly cron, org) — mines journals/learnings for undrafted repeated patterns; `propose_playbook_drafts` (the Auditor's one exception to "curates but doesn't draft") → 1-3 real DRAFT playbooks via `PlaybookService` directly, landing in the same curation queue a later Auditor spawn reviews. **Sentinel**/`sentinel` (Auditor, weekly cron, org) — waiver/findings/conventions/budget drift watch; `propose_quality_report` → a held `ceo_report`, no task. **Feature spotlight**/`x_feature` (HoM — unchanged, see "RoboCo X account" below). Every artifact is HELD; the CEO is the only path to materialization — nothing auto-starts, auto-posts, or auto-merges. +The fourteen programs: **Printer**/`roadmap` (PO, weekly cron, org) — the pre-existing roadmap cycle, now LEARN-fed and Periscope-briefed; `propose_roadmap` → backlog tasks. **Pest Control**/`pest_control` (PO, weekly cron + a rework-rate-spike metric accelerator, project-scoped) — hunts latent bugs in the findings ledger/rework hotspots/`ponytail:` debt; `propose_bug_hunt` (evidence-required) → ≤5 backlog tasks. **Spackle**/`spackle` (PO, biweekly cron, project-scoped) — audits half-shipped surface area (routes with no panel, flags with no docs); `propose_gap_fill` → ≤5 backlog tasks. **Scales**/`scales` (PO, monthly cron, org) — reviews the live backlog against the charter; `propose_rebalance` → a held per-item plan whose approval MUTATES the live task in place (reprioritize or cancel), never creates one. **Dogfood**/`dogfood` (PO, event — release-publish hook or CEO run-now, project-scoped) — walks the product as a user; the one program whose spawn also mounts the Playwright MCP, task-scoped via `AgentOrchestrator._is_dogfood_spawn` (not a role-wide grant); `propose_friction_fixes` (walked-path evidence required) → ≤5 backlog tasks. **Periscope**/`periscope` (HoM, weekly cron, org) — market/competitor research with mandatory `source_url` citations; `propose_market_brief` → a held `ceo_report`, no task, and feeds forward into Printer's prompt. **Megaphone**/`megaphone` (HoM, 3-day cron, org) — the standing editorial calendar off shipped-task/CHANGELOG digests; `propose_editorial_post` → the existing X held-draft queue. **Mirror**/`mirror` (HoM, quarterly cron, project-scoped) — audits README/docs-site/website messaging against shipped reality; `propose_messaging_fixes` → ≤5 backlog docs tasks. **Barfly**/`barfly` (HoM, 2-day cron, org) — engages adjacent X conversations from a screened candidate list (`injection_guard.screen_external_text`, candidate-id-bound so a draft can't target an invented tweet); `propose_conversation_replies` → held X drafts, materialized as **standalone link-posts** (commentary + the conversation's `/i/web/status/` URL, @handles stripped) — never threaded replies, since X's 2026-02-23 policy 403s programmatic replies into conversations that don't @mention the account on every non-Enterprise API tier (the same policy the mentions-poll `x_reply` drafts DO satisfy — they thread via `in_reply_to_tweet_id`; note a URL-bearing post costs $0.20 vs $0.015 on pay-per-use). Every post outcome (posted / post_failed) writes an `x_post.*` audit row at the `XPostService._post` chokepoint. **War Room**/`war_room` (HoM, event — release-publish hook `WarRoomEngine.open_for_release` or CEO run-now, org) — plans a 2-6 post campaign with strictly-ascending `publish_after` timestamps (guidance only, nothing auto-schedules); `propose_campaign` → held X drafts as one batch. **Coroner**/`coroner` (Auditor, event only — task bounced `revision_count>=3`, cancelled after work started, or budget-blocked, wired at `TaskService`'s bounce/cancel chokepoints + the orchestrator's budget-block path, org) — one autopsy at a time, no cron; `propose_postmortem` (`process_change.kind` one of `playbook`/`prompt_fix`/`conventions_rule`/`other`) → a held process-change item, or drafts straight into the pending-playbook queue when `kind='playbook'`. **Librarian**/`librarian` (Auditor, biweekly cron, org) — mines journals/learnings for undrafted repeated patterns; `propose_playbook_drafts` (the Auditor's one exception to "curates but doesn't draft") → 1-3 real DRAFT playbooks via `PlaybookService` directly, landing in the same curation queue a later Auditor spawn reviews. **Sentinel**/`sentinel` (Auditor, weekly cron, org) — waiver/findings/conventions/budget drift watch; `propose_quality_report` → a held `ceo_report`, no task. **Feature spotlight**/`x_feature` (HoM — unchanged, see "RoboCo X account" below). Every artifact is HELD; the CEO is the only path to materialization — nothing auto-starts, auto-posts, or auto-merges. **Obsidian vault V1+V2 (default-off).** The org's human-readable memory palace as a rebuildable DB projection — tasks, journals, and A2A digests as wikilinked markdown — gated by `ROBOCO_OBSIDIAN_VAULT_ENABLED` + `ROBOCO_VAULT_PATH` (default off, but both compose files arm it `true`). `VaultWriter` (`roboco/services/vault_writer.py`) is a pure, DB-free materializer under `RoboCo/{Tasks/,Journals/,A2A,Agents,Archive//Tasks/,Reports}/`; every note carries a stable `aliases: []` so a title rename (or an archival move) never breaks a `[[id8|title]]` cross-link, and private journals are excluded. Four best-effort event seams (`TaskService.create`'s materialize-on-create, `TaskService`'s status-transition frontmatter touch, `JournalService`, `A2AService`) patch/append on the relevant transition — a vault write failure never blocks the real action; materialize-on-create means a task's note exists from the moment it's created, not just at curation/rebuild. `python -m roboco.vault rebuild` re-projects every entity from the DB (preserving an existing task's Auditor-authored `## Narrative`, archive-aware so an old terminal task lands directly in `Archive//`) and materializes the shipped `.obsidian/` config (Dataview, Kanban, graph groups) + `RoboCo/_meta/` dashboards + `.base` Bases views from `roboco/vault_assets/`; `relocate ` moves the tree, grafting `RoboCo/` into an existing personal vault without touching its own config. The Auditor gets a one-shot `curate_vault(task_id, narrative)` do-tool, spawned by the orchestrator on each completed root task, writing the `## Narrative` section a deterministic write otherwise leaves as a placeholder. A second, independently-gated `ROBOCO_VAULT_INTAKE_ENABLED` watcher (`VaultIntakeEngine`) turns `#roboco`-tagged notes under the vault's inbox folder into PENDING, Product-Owner-assigned board-review drafts (`source=vault_note`) — the identical board-review path a chat-confirmed draft takes, never straight into delivery. Extraction runs on the local model with a deterministic fallback; the note body is screened through `foundation/policy/injection_guard.screen_external_text` (the same untrusted-content envelope `XEngine` applies to X mentions — flags an injection-pattern line inline, never removes content) before it reaches the prompt or the fallback. Deduped per `(vault-relative path, content hash)` via `vault_seen_notes`, so an edit re-qualifies a previously-seen note — the same hashing convention (every RoboCo feedback callout stripped first, `foundation/policy/vault_notes.py`) is now shared with the KB engine below. V2 adds three things on top: a **drift janitor** (`services/vault_janitor.py`, `_vault_janitor_loop`) hourly-ticked but gated by a `RoboCo/_meta/.janitor_state.json` state file so real work (a daily changed-task re-projection + random-sample drift check + archival pass, each capped at 200/cycle and per-item isolated so one bad row never wedges the sweep) and a weekly org-report (`vault_report_enabled`, default true — `VaultWriter.write_org_report` from `MetricsService`/`UsageService`, best-effort CEO notification) each fire exactly once per elapsed period regardless of loop/restart cadence; **archival** (`vault_archive_days`, default 30, `0`=off) moving old terminal tasks' notes into `RoboCo/Archive//Tasks//` during the sweep, alias links making the move free and the shipped Dataview/graph assets `Archive/`-aware; and **KB ingest** (`vault_kb_enabled`, default false — NAS compose arms it `true`, registry compose leaves it `false`) embedding the CEO's own `RoboCo/Notes/` (config `vault_kb_dirs`, csv, load-time-validated against traversal/overlap with reserved projection dirs) into a new `IndexType.VAULT_NOTES` corpus via `_vault_kb_loop` (`services/vault_kb_engine.py`, default 900s), with every note re-checked for symlink/path-escape at read time and screened through the injection guard as a hard GATE (a flagged note is quarantined — skipped, logged, callout-marked, never embedded) rather than the intake watcher's screen-and-still-process posture — reaching `roboco_kb_search`, `MentorService`'s default domain, and `EvidenceRepo.similar_memory` (claim-time briefings, relevance-floored, labeled `vault_note`) so the CEO's own writing finally becomes fleet-retrievable institutional memory. diff --git a/roboco/services/barfly_engine.py b/roboco/services/barfly_engine.py index 2c037c70..d9e70a92 100644 --- a/roboco/services/barfly_engine.py +++ b/roboco/services/barfly_engine.py @@ -73,9 +73,13 @@ def _exploration_description(candidate_count: int) -> str: "Review the screened candidate X conversations already gathered for " "you on this task — search results where RoboCo is relevant but " "unmentioned. Pick up to " - f"{candidate_count} worth replying to and draft replies via " - "propose_conversation_replies(). Only reply to a REAL candidate " - "already on this task — never invent a tweet." + f"{candidate_count} worth engaging and draft commentary via " + "propose_conversation_replies(). Only target a REAL candidate " + "already on this task — never invent a tweet. Each draft posts as a " + "STANDALONE post carrying a link to the conversation (X forbids " + "programmatic replies into unmentioning threads), so write " + "commentary that stands on its own with no @handles — assume the " + "reader sees your post first and the linked thread second." ) diff --git a/roboco/services/x_engine.py b/roboco/services/x_engine.py index 53ab566b..e3274061 100644 --- a/roboco/services/x_engine.py +++ b/roboco/services/x_engine.py @@ -168,10 +168,20 @@ def _hom_voice(product_name: str) -> str: def _clamp_tweet(text: str) -> str: """Collapse whitespace and hard-enforce the 280-char limit.""" + return _clamp_to(text, MAX_TWEET_CHARS) + + +def _clamp_to(text: str, limit: int) -> str: collapsed = " ".join(text.split()) - if len(collapsed) <= MAX_TWEET_CHARS: + if len(collapsed) <= limit: return collapsed - return collapsed[: MAX_TWEET_CHARS - 1].rstrip() + "…" + return collapsed[: limit - 1].rstrip() + "…" + + +def _strip_handles(text: str) -> str: + """Drop @-prefixes: X (2026-02-28) rejects @mentions inside plain posts on + non-Enterprise API tiers, and Barfly link-posts post plain, not as replies.""" + return re.sub(r"@(\w)", r"\1", text) def _fallback_release_body(version: str, product_name: str) -> str: @@ -1233,16 +1243,27 @@ class XEngine(BaseService): reply_body: str, rationale: str, ) -> TaskTable: - """Materialize ONE Barfly-authored reply draft through the shared + """Materialize ONE Barfly-authored draft through the shared ``_originate_post`` chokepoint (source=X_BARFLY_SOURCE) — called once per approved-shape item from the ``propose_conversation_replies`` content verb (N per cycle, unlike ``materialize_feature_spotlight``'s single call). Does NOT touch ``exploration_task``'s own status — the verb completes the exploration once, after every item in the batch - has materialized.""" + has materialized. + + The draft is a standalone LINK-POST (commentary + the conversation + URL), never a threaded reply: X's 2026-02-23 policy 403s programmatic + replies into conversations that don't mention the account — Barfly's + entire discovery surface. The ``/i/web/status/`` URL form needs no + author handle, and handles are stripped from the commentary (plain + posts with @mentions are rejected on our tier too).""" + link = f"https://x.com/i/web/status/{candidate.get('id')}" + commentary = _clamp_to( + _strip_handles(reply_body), MAX_TWEET_CHARS - len(link) - 1 + ) task = await self._originate_post( - title=f"X reply: conversation {candidate.get('id')}", - body=_clamp_tweet(reply_body), + title=f"X link-post: conversation {candidate.get('id')}", + body=f"{commentary} {link}", source=X_BARFLY_SOURCE, project_id=cast("UUID", exploration_task.project_id), ) @@ -1443,6 +1464,7 @@ class XEngine(BaseService): task_id=str(post_task.id), ) return None + body = self._restore_barfly_link(post_task, body) task = await self._originate_post( title=post_task.title or "X post revision", body=body, @@ -1492,6 +1514,23 @@ class XEngine(BaseService): builder = _REDRAFT_CONTEXT_BUILDERS.get(post_task.source) return builder(post_task) if builder is not None else "" + def _restore_barfly_link(self, post_task: TaskTable, body: str) -> str: + """A barfly redraft must keep its conversation link — the local + model's revision may drop or mangle the URL the link-post exists to + carry. Re-append the canonical link (and re-strip handles) whenever + it's missing; non-barfly sources pass through untouched.""" + if post_task.source != X_BARFLY_SOURCE: + return body + tweet_id = (markers.get_barfly_reply_ref(post_task) or {}).get("tweet_id") + if not tweet_id: + return body + link = f"https://x.com/i/web/status/{tweet_id}" + stripped = _strip_handles(body) + if link in stripped: + return stripped + commentary = _clamp_to(stripped, MAX_TWEET_CHARS - len(link) - 1) + return f"{commentary} {link}" + def _carry_redraft_markers(self, new_task: TaskTable, post_task: TaskTable) -> None: """Copy the rejected draft's source-specific reference marker onto the redraft so it renders identically in the panel queue/history and is diff --git a/tests/unit/services/test_x_engine.py b/tests/unit/services/test_x_engine.py index 92d30a75..0afb98db 100644 --- a/tests/unit/services/test_x_engine.py +++ b/tests/unit/services/test_x_engine.py @@ -1214,7 +1214,37 @@ async def test_materialize_barfly_reply_holds_draft_through_originate_post( assert ref["author_handle"] == "someone" assert ref["rationale"] == "Directly answers their question." body = markers.get_x_draft_body(draft) - assert body == "That's exactly what request_sandbox() gives you." + assert body == ( + "That's exactly what request_sandbox() gives you. " + "https://x.com/i/web/status/111" + ) + + +@pytest.mark.asyncio +async def test_materialize_barfly_reply_strips_handles( + db_session: AsyncSession, +) -> None: + """Link-posts are plain posts: @handles are stripped (X rejects mentions + in plain posts on non-Enterprise tiers).""" + await _seed(db_session) + project = ( + await db_session.execute(select(ProjectTable).where(ProjectTable.slug == SLUG)) + ).scalar_one() + engine = x_engine_module.XEngine(db_session, client=_FakeClient()) + + class _Exploration: + project_id = project.id + + draft = await engine.materialize_barfly_reply( + exploration_task=cast("TaskTable", _Exploration()), + candidate=_BARFLY_CANDIDATE, + reply_body="Great point @someone — @roboco_ai does this.", + rationale="whatever", + ) + body = markers.get_x_draft_body(draft) + assert body is not None + assert "@" not in body + assert body.endswith("https://x.com/i/web/status/111") @pytest.mark.asyncio @@ -1426,7 +1456,12 @@ async def test_redraft_from_rejection_barfly_carries_reply_ref( ref = markers.get_barfly_reply_ref(redraft) assert ref is not None assert ref["tweet_id"] == "111" - assert markers.get_x_draft_body(redraft) == "A sharper reply." + # The redraft re-appends the conversation link the link-post exists to + # carry (the revision model was mocked without it). + assert ( + markers.get_x_draft_body(redraft) + == "A sharper reply. https://x.com/i/web/status/111" + ) @pytest.mark.asyncio