Skip to content

Commit 8775506

Browse files
committed
feat(cmo): hydrate approved queue with generated copy and executable x-cli commands
1 parent 54039e9 commit 8775506

File tree

3 files changed

+308
-2
lines changed

3 files changed

+308
-2
lines changed

ops/cmo-automation/README.md

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@ What this does today
1212
2) Analyze behavior and output baseline metrics
1313
3) Generate a conservative dry-run engagement queue
1414
4) Score the queue for risk/quality before any execution
15-
5) Build execution report from approved actions (dry-run default)
15+
5) Hydrate approved actions with copy + x-cli commands
16+
6) Build execution report from approved actions (dry-run default)
1617

1718
Current mode
1819
- Dry-run / assisted only.
@@ -26,6 +27,7 @@ Structure
2627
- scripts/generate_engagement_queue.py
2728
- scripts/review_engagement_queue.py
2829
- scripts/reconstruct_fiverr_playbook.py
30+
- scripts/hydrate_approved_queue.py
2931
- scripts/execute_approved_queue.py
3032
- reports/CMO-AUTOMATION-IMPLEMENTATION-PLAN.md
3133
- reports/CMO-CUTOVER-48H-RUNBOOK.md
@@ -40,7 +42,8 @@ Quick start
4042
- python3 scripts/generate_engagement_queue.py
4143
- python3 scripts/review_engagement_queue.py
4244
- python3 scripts/reconstruct_fiverr_playbook.py
43-
- python3 scripts/execute_approved_queue.py
45+
- python3 scripts/hydrate_approved_queue.py
46+
- python3 scripts/execute_approved_queue.py --input reports/cmo-hydrated-queue.json
4447

4548
Outputs
4649
- data/latest.json
@@ -49,6 +52,8 @@ Outputs
4952
- data/engagement-queue.json
5053
- reports/cmo-queue-review.json
5154
- reports/cmo-queue-review.md
55+
- reports/cmo-hydrated-queue.json
56+
- reports/cmo-hydrated-queue.md
5257
- reports/cmo-execution-report.json
5358
- reports/cmo-execution-report.md
5459

Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
1+
#!/usr/bin/env python3
2+
import argparse
3+
import json
4+
import os
5+
import re
6+
import subprocess
7+
from datetime import datetime, timezone
8+
from pathlib import Path
9+
from typing import Callable
10+
11+
ROOT = Path(__file__).resolve().parents[1]
12+
DEFAULT_REVIEW = ROOT / "reports" / "cmo-queue-review.json"
13+
POLICY = ROOT / "config" / "operating_policy.json"
14+
OUT_JSON = ROOT / "reports" / "cmo-hydrated-queue.json"
15+
OUT_MD = ROOT / "reports" / "cmo-hydrated-queue.md"
16+
17+
Resolver = Callable[[str | None, str], dict | None]
18+
19+
20+
def load_json(path: Path):
21+
if not path.exists():
22+
raise SystemExit(f"Missing required input: {path}")
23+
return json.loads(path.read_text())
24+
25+
26+
def load_credential_mapping() -> None:
27+
mapping = {
28+
"X_API_KEY": os.getenv("X_API_KEY") or os.getenv("TWITTER_API_KEY"),
29+
"X_API_SECRET": os.getenv("X_API_SECRET") or os.getenv("TWITTER_API_SECRET"),
30+
"X_BEARER_TOKEN": os.getenv("X_BEARER_TOKEN") or os.getenv("TWITTER_BEARER_TOKEN"),
31+
"X_ACCESS_TOKEN": os.getenv("X_ACCESS_TOKEN") or os.getenv("TWITTER_ACCESS_TOKEN"),
32+
"X_ACCESS_TOKEN_SECRET": os.getenv("X_ACCESS_TOKEN_SECRET") or os.getenv("TWITTER_ACCESS_SECRET"),
33+
}
34+
missing = [k for k, v in mapping.items() if not v]
35+
if missing:
36+
raise SystemExit(f"Missing required credentials: {', '.join(missing)}")
37+
os.environ.update(mapping)
38+
39+
40+
def run_json(cmd: list[str]):
41+
p = subprocess.run(cmd, capture_output=True, text=True)
42+
if p.returncode != 0:
43+
raise RuntimeError(f"Command failed: {' '.join(cmd)}\nSTDERR:\n{p.stderr}")
44+
return json.loads(p.stdout)
45+
46+
47+
def normalize_text(text: str, limit: int = 100) -> str:
48+
clean = re.sub(r"\s+", " ", (text or "")).strip()
49+
if len(clean) <= limit:
50+
return clean
51+
return clean[: limit - 1].rstrip() + "…"
52+
53+
54+
def build_root_text(account: str, role: str) -> str:
55+
ideas = {
56+
"founder": "Building AI products is mostly distribution math + feedback loops. Own both, ship faster.",
57+
"brand": "Good AI products win when onboarding is instant, outcomes are measurable, and support feels human.",
58+
"product-agent": "Agent workflows improve when memory, orchestration, and evals are designed as one system.",
59+
}
60+
base = ideas.get(role, ideas["brand"])
61+
return f"{base} #{account}"[:278]
62+
63+
64+
def safe_founder_reply(target_user: str, excerpt: str) -> str:
65+
return (
66+
f"@{target_user} Good signal here. I care less about hype and more about repeatable distribution + retention. "
67+
f"{excerpt}"
68+
)[:278]
69+
70+
71+
def build_reply_text(account: str, role: str, target_user: str, source_text: str) -> str:
72+
excerpt = normalize_text(source_text, limit=80)
73+
if role == "founder":
74+
return safe_founder_reply(target_user, excerpt)
75+
if role == "product-agent":
76+
return (
77+
f"@{target_user} Nice thread. Curious what your eval loop looks like once this is in production. "
78+
f"{excerpt}"
79+
)[:278]
80+
return (
81+
f"@{target_user} Solid point. We see the same pattern in shipping: clear UX + measurable outcomes compound. "
82+
f"{excerpt}"
83+
)[:278]
84+
85+
86+
def contains_denylist(text: str, keywords: list[str]) -> bool:
87+
t = (text or "").lower()
88+
return any(k.lower() in t for k in keywords)
89+
90+
91+
def default_resolver(target_user: str | None, account: str) -> dict | None:
92+
account_queries = {
93+
"TheCesarCross": "AI founders OR agentic workflow OR product distribution",
94+
"sovren_software": "AI automation OR workflow systems OR developer tools",
95+
"mrhaven_agent": "agent memory OR orchestration OR evals",
96+
}
97+
98+
if target_user:
99+
query = f"from:{target_user} -is:retweet"
100+
else:
101+
query = account_queries.get(account, "AI products OR automation")
102+
103+
data = run_json(["x-cli", "-j", "tweet", "search", query, "--max", "10"])
104+
if not isinstance(data, list) or not data:
105+
return None
106+
107+
for t in data:
108+
if not isinstance(t, dict):
109+
continue
110+
tweet_id = t.get("id")
111+
text = t.get("text")
112+
author = ((t.get("author") or {}).get("username") if isinstance(t.get("author"), dict) else None)
113+
if tweet_id and text:
114+
return {"id": str(tweet_id), "text": text, "author": author}
115+
return None
116+
117+
118+
def hydrate_single_action(action: dict, policy: dict, resolver: Resolver) -> dict:
119+
out = dict(action)
120+
account = out.get("account", "")
121+
role = policy.get("account_strategy", {}).get(account, {}).get("role", "brand")
122+
founder_keywords = policy.get("founder_denylist", {}).get("keywords", [])
123+
124+
if out.get("action") == "root_post":
125+
post_text = build_root_text(account, role)
126+
if role == "founder" and contains_denylist(post_text, founder_keywords):
127+
out["hydration_status"] = "blocked"
128+
out["hydration_reason"] = "founder_denylist_hit"
129+
return out
130+
131+
out["post_text"] = post_text
132+
out["x_cli_command"] = ["x-cli", "-j", "tweet", "post", post_text]
133+
out["hydration_status"] = "hydrated"
134+
return out
135+
136+
if out.get("action") == "reply":
137+
candidate = resolver(out.get("target_user"), account)
138+
if not candidate:
139+
out["hydration_status"] = "blocked"
140+
out["hydration_reason"] = "no_candidate_tweet"
141+
return out
142+
143+
target_user = out.get("target_user") or candidate.get("author") or "builder"
144+
reply_text = build_reply_text(account, role, target_user, candidate.get("text", ""))
145+
146+
if role == "founder" and contains_denylist(reply_text, founder_keywords):
147+
out["hydration_status"] = "blocked"
148+
out["hydration_reason"] = "founder_denylist_hit"
149+
return out
150+
151+
tweet_id = str(candidate["id"])
152+
out["target_user"] = target_user
153+
out["target_tweet_id"] = tweet_id
154+
out["reply_text"] = reply_text
155+
out["execution_mode"] = "quote_workaround"
156+
out["x_cli_command"] = ["x-cli", "-j", "tweet", "quote", tweet_id, reply_text]
157+
out["hydration_status"] = "hydrated"
158+
return out
159+
160+
out["hydration_status"] = "blocked"
161+
out["hydration_reason"] = "unsupported_action_type"
162+
return out
163+
164+
165+
def hydrate_review(review: dict, policy: dict, resolver: Resolver) -> dict:
166+
hydrated = dict(review)
167+
hydrated_accounts = {}
168+
169+
total = 0
170+
ready = 0
171+
blocked = 0
172+
173+
for account, payload in review.get("accounts", {}).items():
174+
approved = payload.get("approved_actions", [])
175+
hydrated_approved = []
176+
for action in approved:
177+
total += 1
178+
h = hydrate_single_action(action, policy, resolver)
179+
hydrated_approved.append(h)
180+
if h.get("hydration_status") == "hydrated":
181+
ready += 1
182+
else:
183+
blocked += 1
184+
185+
new_payload = dict(payload)
186+
new_payload["approved_actions"] = hydrated_approved
187+
hydrated_accounts[account] = new_payload
188+
189+
hydrated["accounts"] = hydrated_accounts
190+
hydrated["hydrated_at"] = datetime.now(timezone.utc).isoformat()
191+
hydrated["hydration_summary"] = {
192+
"total_approved": total,
193+
"hydrated": ready,
194+
"blocked": blocked,
195+
}
196+
return hydrated
197+
198+
199+
def write_reports(hydrated: dict) -> None:
200+
OUT_JSON.write_text(json.dumps(hydrated, indent=2))
201+
202+
s = hydrated.get("hydration_summary", {})
203+
lines = [
204+
"# CMO Hydrated Queue",
205+
f"Hydrated at: {hydrated.get('hydrated_at')}",
206+
f"- total approved: {s.get('total_approved', 0)}",
207+
f"- hydrated: {s.get('hydrated', 0)}",
208+
f"- blocked: {s.get('blocked', 0)}",
209+
"",
210+
]
211+
212+
for account, payload in hydrated.get("accounts", {}).items():
213+
actions = payload.get("approved_actions", [])
214+
ok = sum(1 for a in actions if a.get("hydration_status") == "hydrated")
215+
no = len(actions) - ok
216+
lines.append(f"## @{account} hydrated={ok} blocked={no}")
217+
for a in actions:
218+
lines.append(
219+
f"- {a.get('action')} target={a.get('target_user')} status={a.get('hydration_status')} reason={a.get('hydration_reason')}"
220+
)
221+
lines.append("")
222+
223+
OUT_MD.write_text("\n".join(lines))
224+
225+
226+
def parse_args() -> argparse.Namespace:
227+
parser = argparse.ArgumentParser(description="Hydrate approved queue actions with copy and executable x-cli commands")
228+
parser.add_argument("--input", default=str(DEFAULT_REVIEW), help="Path to cmo-queue-review.json")
229+
return parser.parse_args()
230+
231+
232+
def main() -> None:
233+
args = parse_args()
234+
load_credential_mapping()
235+
review = load_json(Path(args.input))
236+
policy = load_json(POLICY)
237+
hydrated = hydrate_review(review, policy, default_resolver)
238+
write_reports(hydrated)
239+
print(str(OUT_JSON))
240+
print(str(OUT_MD))
241+
242+
243+
if __name__ == "__main__":
244+
main()
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import sys
2+
import unittest
3+
from pathlib import Path
4+
5+
SCRIPTS = Path(__file__).resolve().parents[1] / "scripts"
6+
sys.path.insert(0, str(SCRIPTS))
7+
8+
import hydrate_approved_queue # noqa: E402
9+
10+
11+
class HydrateApprovedQueueTests(unittest.TestCase):
12+
def test_hydrate_root_post_adds_text_and_command(self):
13+
action = {"account": "sovren_software", "action": "root_post", "priority": "high"}
14+
policy = {
15+
"account_strategy": {"sovren_software": {"role": "brand"}},
16+
"founder_denylist": {"keywords": []},
17+
}
18+
19+
out = hydrate_approved_queue.hydrate_single_action(action, policy, resolver=lambda *_: None)
20+
21+
self.assertEqual("hydrated", out["hydration_status"])
22+
self.assertTrue(out["post_text"])
23+
self.assertEqual("x-cli", out["x_cli_command"][0])
24+
self.assertEqual("post", out["x_cli_command"][3])
25+
26+
def test_hydrate_reply_uses_quote_workaround_with_candidate(self):
27+
action = {"account": "TheCesarCross", "action": "reply", "target_user": "alice"}
28+
policy = {
29+
"account_strategy": {"TheCesarCross": {"role": "founder"}},
30+
"founder_denylist": {"keywords": ["giveaway"]},
31+
}
32+
33+
def resolver(target_user, account):
34+
return {"id": "12345", "text": "Shipping useful agent products this week."}
35+
36+
out = hydrate_approved_queue.hydrate_single_action(action, policy, resolver=resolver)
37+
38+
self.assertEqual("hydrated", out["hydration_status"])
39+
self.assertEqual("12345", out["target_tweet_id"])
40+
self.assertEqual(["x-cli", "-j", "tweet", "quote"], out["x_cli_command"][0:4])
41+
self.assertIn("@alice", out["reply_text"])
42+
43+
def test_hydrate_reply_blocks_without_candidate(self):
44+
action = {"account": "mrhaven_agent", "action": "reply", "target_user": "nobody"}
45+
policy = {
46+
"account_strategy": {"mrhaven_agent": {"role": "product-agent"}},
47+
"founder_denylist": {"keywords": []},
48+
}
49+
50+
out = hydrate_approved_queue.hydrate_single_action(action, policy, resolver=lambda *_: None)
51+
52+
self.assertEqual("blocked", out["hydration_status"])
53+
self.assertEqual("no_candidate_tweet", out["hydration_reason"])
54+
55+
56+
if __name__ == "__main__":
57+
unittest.main()

0 commit comments

Comments
 (0)