delimit-cli 3.14.42 → 3.14.44

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,666 +0,0 @@
1
- """Automated social media — authentic engagement at scale.
2
-
3
- Posts are value-first: tips, changelogs, governance insights.
4
- Never spam, never generic marketing. Every post teaches something.
5
-
6
- Multi-account support: credentials stored per handle in
7
- ~/.delimit/secrets/twitter-<handle>.json (e.g. twitter-delimit_ai.json).
8
- Legacy twitter-full.json is treated as the default account.
9
- """
10
- import json
11
- import logging
12
- import os
13
- import random
14
- import uuid
15
- from pathlib import Path
16
- from datetime import datetime, timezone
17
-
18
- logger = logging.getLogger("delimit.ai.social")
19
-
20
- SECRETS_DIR = Path.home() / ".delimit" / "secrets"
21
- SOCIAL_LOG = Path.home() / ".delimit" / "social_log.jsonl"
22
- DRAFTS_FILE = Path.home() / ".delimit" / "social_drafts.jsonl"
23
- ACCOUNTS_FILE = SECRETS_DIR / "twitter-accounts.json"
24
-
25
- # Platform-specific tone guidelines for draft generation
26
- # Used by AI agents when drafting content — included in draft metadata
27
- # so the agent (or human reviewer) can adjust tone before posting.
28
- PLATFORM_TONE = {
29
- "twitter": {
30
- "max_length": 280,
31
- "tone": "confident technical brand, direct, professional",
32
- "identity": "@delimit_ai — verified, professional account",
33
- "conversion_targets": [
34
- "action: delimit-ai/delimit-action (GitHub Marketplace)",
35
- "mcp: delimit-mcp-server (Claude Code, Cursor, Gemini CLI)",
36
- "vscode: Delimit VS Code extension (Marketplace)",
37
- "install: npx delimit-cli setup",
38
- ],
39
- "rules": [
40
- "ALWAYS POSITIVE — celebrate wins, share progress, show momentum. Never complain, never air gaps or insecurities publicly",
41
- "Frame every metric as a win: '400 clones in 2 weeks' NOT '400 clones but 0 stars'",
42
- "If there's a problem to solve, handle it internally — don't tweet about it",
43
- "Confident technical brand voice — direct, clear, authoritative",
44
- "NOT a Reddit voice — no rough edges, no typos, no 'typing on phone' energy",
45
- "Proper capitalization and punctuation — we're a product account",
46
- "Lead with the insight, not the product",
47
- "One clear point per tweet",
48
- "No em dashes or en dashes — most people can't type them, so they look AI-generated. Use commas, periods, or hyphens instead",
49
- "Short punchy lines with line breaks for rhythm",
50
- "Include a link or install command when relevant",
51
- "Quote tweets: position Delimit relative to the quoted content, not as a generic response",
52
- "Replies: be genuinely helpful or insightful, not just 'cool project!'",
53
- ],
54
- },
55
- "reddit": {
56
- "max_length": 500,
57
- "tone": "proud builder, genuinely helpful, never salesy",
58
- "identity": "u/delimitdev — we're openly building Delimit, not hiding it",
59
- "conversion_targets": [
60
- "action: delimit-ai/delimit-action (GitHub Marketplace)",
61
- "mcp: delimit-mcp-server (Claude Code, Cursor, Gemini CLI)",
62
- "vscode: Delimit VS Code extension (Marketplace)",
63
- ],
64
- "subreddit_angles": {
65
- "r/vibecoding": {
66
- "angle": "founder was a non-technical vibe coder who kept breaking things — built Delimit as the safety net",
67
- "narrative": "'I was in your shoes' — fellow builder sharing what they made, not selling",
68
- "best_for": "threads about stability, breaking things, fear of AI-introduced bugs, QA loops",
69
- "conversion": "action or vscode",
70
- },
71
- "r/ClaudeAI": {
72
- "angle": "MCP power user — shared context across models, agent governance",
73
- "narrative": "deep practitioner who's built tooling on top of Claude's ecosystem",
74
- "best_for": "threads about MCP, multi-model workflows, context loss, agent tooling",
75
- "conversion": "mcp",
76
- },
77
- "r/devops": {
78
- "angle": "CI/CD governance — catch breaking API changes before merge",
79
- "narrative": "team that ships a GitHub Action for API compatibility checks",
80
- "best_for": "threads about API versioning, breaking changes, CI pipelines, OpenAPI",
81
- "conversion": "action",
82
- },
83
- "r/programming": {
84
- "angle": "developer tooling builder — 27 breaking change types, one YAML line",
85
- "narrative": "technical founder sharing what the engine detects and why",
86
- "best_for": "threads about API design, backward compatibility, schema evolution",
87
- "conversion": "action",
88
- },
89
- },
90
- "rules": [
91
- "We post as u/delimitdev — no need to hide that we build Delimit",
92
- "Mention Delimit ONLY when it genuinely helps the person's problem",
93
- "Be proud of our work, not promotional — 'we built X to solve this' not 'check out X!'",
94
- "NEVER post unsolicited promos — only mention our tools in direct response to a real problem",
95
- "If Delimit isn't relevant to the thread, just be helpful with no mention at all",
96
- "Confident builder energy — proud of what we've shipped, not cocky or boastful",
97
- "NEVER use bullet points or numbered lists — instant AI tell",
98
- "Keep it to 2-3 sentences max, not multi-paragraph essays",
99
- "Write like you're typing on your phone, not writing a blog post",
100
- "Use contractions, sentence fragments, rough edges",
101
- "One sharp point per comment — don't cover three topics",
102
- "ALWAYS POSITIVE — celebrate wins, share progress, show momentum. Never complain or air gaps publicly",
103
- "NEVER commiserate or self-deprecate — sound like someone who's figured it out",
104
- "A typo or two is fine — too polished = flagged as AI by mods",
105
- "NEVER use em dashes (—), en dashes (–), or fancy punctuation — use commas, 'and', or hyphens instead",
106
- "NEVER use semicolons, ellipsis (…), or curly quotes — type like a human on a phone keyboard",
107
- "NEVER structure responses with headers, bold text, or formatted lists",
108
- "r/devops mods actively flag LLM-generated content (learned 2026-03-27)",
109
- ],
110
- },
111
- "linkedin": {
112
- "max_length": 1300,
113
- "tone": "professional, concise, insight-driven",
114
- "rules": [
115
- "Lead with a hook question or surprising stat",
116
- "Keep paragraphs to 1-2 lines",
117
- "End with a clear CTA or question",
118
- ],
119
- },
120
- }
121
-
122
- # Content templates — each provides genuine value
123
- CONTENT_TEMPLATES = {
124
- "tip": [
125
- "Tip: You can detect {count} types of breaking API changes with one line of YAML:\n\n- uses: delimit-ai/delimit-action@v1\n with:\n spec: api/openapi.yaml\n\nNo config needed. Advisory mode by default.",
126
- "Did you know? When you switch from Claude Code to Codex, you lose all context. With a shared ledger, say \"what's on the ledger?\" in any assistant and pick up exactly where you left off.",
127
- "API governance tip: The 3 most common breaking changes we catch:\n\n1. Endpoint removed without deprecation\n2. Required field added to request body\n3. Response field type changed\n\nAll detectable before merge.",
128
- "Quick tip: Run `npx delimit-cli doctor` in any project to check your governance setup. It checks for policies, specs, workflows, and git config in seconds.",
129
- "Pro tip: Use policy presets to match your team's risk tolerance:\n\n{bullet} strict — all violations are errors\n{bullet} default — balanced\n{bullet} relaxed — warnings only\n\n`npx delimit-cli init --preset strict`",
130
- ],
131
- "changelog": [
132
- "Just shipped: {feature}\n\n{detail}\n\nUpdate: npx delimit-cli@latest setup",
133
- ],
134
- "insight": [
135
- "We analyzed {count} API changes this week. {percent}% were breaking. The most common? {top_change}.\n\nAutomate this check: delimit.ai",
136
- "Hot take: In 2 years, unmanaged AI agents touching production code will be as unacceptable as unmanaged SSH keys.\n\nGovernance isn't optional. It's infrastructure.",
137
- "The problem with AI coding assistants isn't capability — it's context loss. Every time you switch models, you start from zero. That's the real productivity killer.",
138
- ],
139
- "engagement": [
140
- "What's the worst API breaking change you've shipped to production? We've seen some creative ones.",
141
- "How many AI coding assistants does your team use? We're seeing teams average 2-3, with context scattered across all of them.",
142
- "What's your API governance process today? Manual review? CI check? Nothing? (No judgment — that's why we built this.)",
143
- ],
144
- }
145
-
146
-
147
- def _resolve_creds_path(account: str = "") -> Path | None:
148
- """Resolve credentials file for a given account handle.
149
-
150
- Lookup order:
151
- 1. ~/.delimit/secrets/twitter-<account>.json (per-handle)
152
- 2. ~/.delimit/secrets/twitter-full.json (legacy default)
153
- """
154
- if account:
155
- per_handle = SECRETS_DIR / f"twitter-{account}.json"
156
- if per_handle.exists():
157
- return per_handle
158
- # Legacy fallback
159
- legacy = SECRETS_DIR / "twitter-full.json"
160
- if legacy.exists():
161
- return legacy
162
- return None
163
-
164
-
165
- def get_twitter_client(account: str = ""):
166
- """Get authenticated Twitter client via tweepy for a specific account.
167
-
168
- Returns:
169
- Tuple of (client, handle, error). On success error is None.
170
- On failure client and handle are None and error is a non-empty string
171
- that distinguishes between "not configured" and "auth failed".
172
-
173
- Args:
174
- account: Twitter handle (without @). Empty string = default account.
175
- """
176
- acct_label = account or "default"
177
- creds_path = _resolve_creds_path(account)
178
- if not creds_path:
179
- configured = list_twitter_accounts()
180
- if configured:
181
- handles = [a["handle"] for a in configured]
182
- return None, None, (
183
- f"Account '{acct_label}' is not configured. "
184
- f"Configured accounts: {handles}. "
185
- f"Place credentials in ~/.delimit/secrets/twitter-{account}.json"
186
- )
187
- return None, None, (
188
- f"No Twitter accounts configured. "
189
- f"Place credentials in ~/.delimit/secrets/twitter-<handle>.json"
190
- )
191
- try:
192
- import tweepy
193
- creds = json.loads(creds_path.read_text())
194
- client = tweepy.Client(
195
- consumer_key=creds["consumer_key"],
196
- consumer_secret=creds["consumer_secret"],
197
- access_token=creds["access_token"],
198
- access_token_secret=creds["access_token_secret"],
199
- )
200
- handle = creds.get("handle", account or "delimit_ai")
201
- return client, handle, None
202
- except KeyError as e:
203
- msg = (
204
- f"Account '{acct_label}' is configured ({creds_path.name}) "
205
- f"but missing credential field {e}"
206
- )
207
- logger.error(msg)
208
- return None, None, msg
209
- except ImportError:
210
- msg = "tweepy is not installed. Run: pip install tweepy"
211
- logger.error(msg)
212
- return None, None, msg
213
- except json.JSONDecodeError as e:
214
- msg = (
215
- f"Account '{acct_label}' credentials file ({creds_path.name}) "
216
- f"contains invalid JSON: {e}"
217
- )
218
- logger.error(msg)
219
- return None, None, msg
220
- except Exception as e:
221
- msg = (
222
- f"Account '{acct_label}' is configured ({creds_path.name}) "
223
- f"but authentication failed: {e}"
224
- )
225
- logger.error(msg, exc_info=True)
226
- return None, None, msg
227
-
228
-
229
- def list_twitter_accounts() -> list[dict]:
230
- """List all configured Twitter accounts, deduplicated by handle.
231
-
232
- When multiple credential files resolve to the same handle,
233
- the per-handle file (twitter-<handle>.json) wins over legacy files.
234
- """
235
- accounts = []
236
- seen_handles: set[str] = set()
237
- if not SECRETS_DIR.exists():
238
- return accounts
239
- for f in sorted(SECRETS_DIR.glob("twitter-*.json")):
240
- name = f.stem # e.g. "twitter-delimit_ai"
241
- if name == "twitter-accounts":
242
- continue
243
- # Skip legacy twitter-full.json in this pass (handled below)
244
- if name == "twitter-full":
245
- continue
246
- try:
247
- creds = json.loads(f.read_text())
248
- handle = creds.get("handle", name.removeprefix("twitter-"))
249
- if handle in seen_handles:
250
- continue
251
- seen_handles.add(handle)
252
- accounts.append({"handle": handle, "file": f.name})
253
- except (json.JSONDecodeError, ValueError):
254
- pass
255
- # Include legacy twitter-full.json only if its handle is not already covered
256
- legacy = SECRETS_DIR / "twitter-full.json"
257
- if legacy.exists():
258
- try:
259
- creds = json.loads(legacy.read_text())
260
- handle = creds.get("handle", "default")
261
- if handle not in seen_handles:
262
- seen_handles.add(handle)
263
- accounts.append({"handle": handle, "file": "twitter-full.json", "default": True})
264
- except (json.JSONDecodeError, ValueError):
265
- pass
266
- return accounts
267
-
268
-
269
- def post_tweet(text: str, account: str = "", quote_tweet_id: str = "",
270
- reply_to_id: str = "") -> dict:
271
- """Post a tweet via the Twitter API.
272
-
273
- Args:
274
- text: Tweet text content.
275
- account: Twitter handle (without @) to post from. Empty = default.
276
- quote_tweet_id: Tweet ID to quote. Creates a quote tweet.
277
- reply_to_id: Tweet ID to reply to. Creates a reply.
278
- """
279
- client, handle, init_error = get_twitter_client(account)
280
- if not client:
281
- # Always return the specific error from get_twitter_client.
282
- # Previous code fell through to a misleading "not found" message
283
- # when init_error was empty, even though the account was configured.
284
- if init_error:
285
- return {"error": init_error}
286
- # Fallback: should not be reachable, but be explicit
287
- return {"error": f"Failed to initialize Twitter client for account '{account or 'default'}'. "
288
- f"Check credentials in ~/.delimit/secrets/twitter-{account or 'full'}.json"}
289
- try:
290
- kwargs = {"text": text}
291
- if quote_tweet_id:
292
- kwargs["quote_tweet_id"] = quote_tweet_id
293
- if reply_to_id:
294
- kwargs["in_reply_to_tweet_id"] = reply_to_id
295
- result = client.create_tweet(**kwargs)
296
- tweet_id = result.data["id"]
297
- log_post("twitter", text, tweet_id, handle=handle,
298
- quote_tweet_id=quote_tweet_id, reply_to_id=reply_to_id)
299
- return {
300
- "posted": True,
301
- "id": tweet_id,
302
- "handle": handle,
303
- "url": f"https://x.com/{handle}/status/{tweet_id}",
304
- "type": "quote_tweet" if quote_tweet_id else "reply" if reply_to_id else "tweet",
305
- }
306
- except Exception as e:
307
- return {"error": str(e), "handle": handle}
308
-
309
-
310
- def generate_post(category: str = "", custom: str = "") -> dict:
311
- """Generate a post. If custom is provided, use that. Otherwise pick from templates."""
312
- if custom:
313
- return {"text": custom, "category": "custom"}
314
-
315
- if not category or category not in CONTENT_TEMPLATES:
316
- category = random.choice(list(CONTENT_TEMPLATES.keys()))
317
-
318
- templates = CONTENT_TEMPLATES[category]
319
- template = random.choice(templates)
320
-
321
- # Fill in template variables with realistic data
322
- text = template.format(
323
- count=27,
324
- percent=random.randint(15, 35),
325
- top_change=random.choice([
326
- "endpoint removed",
327
- "type changed",
328
- "required field added",
329
- ]),
330
- feature="(specify feature name)",
331
- detail="(specify feature details)",
332
- bullet="\u2022",
333
- )
334
-
335
- return {"text": text, "category": category}
336
-
337
-
338
- def get_post_history(limit: int = 20, platform: str = "",
339
- user: str = "", subreddit: str = "") -> list:
340
- """Get recent post history from the JSONL log.
341
-
342
- Args:
343
- limit: Max entries to return.
344
- platform: Filter by platform (e.g. "twitter", "reddit").
345
- user: Filter by Reddit user we replied to (replying_to_user field).
346
- subreddit: Filter by subreddit (e.g. "r/vibecoding").
347
- """
348
- if not SOCIAL_LOG.exists():
349
- return []
350
- posts = []
351
- for line in reversed(SOCIAL_LOG.read_text().splitlines()):
352
- if not line.strip():
353
- continue
354
- try:
355
- entry = json.loads(line)
356
- except (json.JSONDecodeError, ValueError):
357
- continue
358
- # Apply filters
359
- if platform and entry.get("platform") != platform:
360
- continue
361
- if user and user.lower() not in (entry.get("replying_to_user") or "").lower():
362
- continue
363
- if subreddit and subreddit.lower() not in (entry.get("subreddit") or "").lower():
364
- continue
365
- posts.append(entry)
366
- if len(posts) >= limit:
367
- break
368
- return posts
369
-
370
-
371
- def log_post(platform: str, text: str, post_id: str = "", handle: str = "",
372
- quote_tweet_id: str = "", reply_to_id: str = "",
373
- subreddit: str = "", thread_url: str = "",
374
- thread_title: str = "", replying_to_user: str = "",
375
- conversion_target: str = ""):
376
- """Log a social media post to the JSONL log.
377
-
378
- For Reddit comments, include subreddit, thread context, and the user
379
- being replied to so we can recall full conversation threads later.
380
- """
381
- SOCIAL_LOG.parent.mkdir(parents=True, exist_ok=True)
382
- entry = {
383
- "ts": datetime.now(timezone.utc).isoformat(),
384
- "platform": platform,
385
- "handle": handle,
386
- "text": text[:500] if platform == "reddit" else text[:200],
387
- "post_id": post_id,
388
- }
389
- if quote_tweet_id:
390
- entry["quote_tweet_id"] = quote_tweet_id
391
- if reply_to_id:
392
- entry["reply_to_id"] = reply_to_id
393
- # Reddit-specific fields
394
- if subreddit:
395
- entry["subreddit"] = subreddit
396
- if thread_url:
397
- entry["thread_url"] = thread_url
398
- if thread_title:
399
- entry["thread_title"] = thread_title
400
- if replying_to_user:
401
- entry["replying_to_user"] = replying_to_user
402
- if conversion_target:
403
- entry["conversion_target"] = conversion_target
404
- with open(SOCIAL_LOG, "a") as f:
405
- f.write(json.dumps(entry) + "\n")
406
-
407
-
408
- def should_post_today() -> bool:
409
- """Check if we've hit the daily posting limit.
410
-
411
- Limit is configurable via DELIMIT_DAILY_TWEETS env var (default 8).
412
- Uses US Eastern Time for day boundaries since the posting schedule
413
- targets 9am/3pm ET.
414
- """
415
- from zoneinfo import ZoneInfo
416
-
417
- daily_limit = int(os.environ.get("DELIMIT_DAILY_TWEETS", "8"))
418
- et_now = datetime.now(ZoneInfo("America/New_York"))
419
- today_et = et_now.strftime("%Y-%m-%d")
420
- history = get_post_history(100)
421
- today_posts = []
422
- for p in history:
423
- ts_str = p.get("ts", "")
424
- if ts_str:
425
- try:
426
- ts = datetime.fromisoformat(ts_str).astimezone(ZoneInfo("America/New_York"))
427
- if ts.strftime("%Y-%m-%d") == today_et:
428
- today_posts.append(p)
429
- except (ValueError, TypeError):
430
- continue
431
- return len(today_posts) < daily_limit
432
-
433
-
434
- # ═════════════════════════════════════════════════════════════════════
435
- # DRAFT MODE — Queue content for review before posting
436
- # ═════════════════════════════════════════════════════════════════════
437
-
438
-
439
- def get_platform_tone(platform: str = "twitter") -> dict:
440
- """Return tone guidelines for a platform.
441
-
442
- AI agents should call this before drafting content to get
443
- platform-specific rules for voice, length, and formatting.
444
- """
445
- return PLATFORM_TONE.get(platform, PLATFORM_TONE.get("twitter", {}))
446
-
447
-
448
- def save_draft(text: str, platform: str = "twitter", account: str = "",
449
- quote_tweet_id: str = "", reply_to_id: str = "",
450
- conversion_target: str = "", thread_url: str = "",
451
- context: str = "") -> dict:
452
- """Save a social media post as a draft for later approval.
453
-
454
- Returns the draft entry with a unique draft_id and platform tone guidelines.
455
-
456
- Args:
457
- conversion_target: For Reddit — "action", "mcp", "vscode", or "" (no promo, just helpful).
458
- thread_url: URL of the Reddit thread being replied to.
459
- context: WHY this post should be made — strategic reasoning shown in the email.
460
- """
461
- DRAFTS_FILE.parent.mkdir(parents=True, exist_ok=True)
462
- draft_id = uuid.uuid4().hex[:12]
463
- tone = get_platform_tone(platform)
464
- entry = {
465
- "draft_id": draft_id,
466
- "text": text,
467
- "platform": platform,
468
- "account": account,
469
- "quote_tweet_id": quote_tweet_id,
470
- "reply_to_id": reply_to_id,
471
- "conversion_target": conversion_target,
472
- "thread_url": thread_url,
473
- "context": context,
474
- "status": "pending",
475
- "timestamp": datetime.now(timezone.utc).isoformat(),
476
- }
477
- # Check tone violations
478
- warnings = []
479
- if tone.get("max_length") and len(text) > tone["max_length"]:
480
- warnings.append(f"Text exceeds {platform} max length ({len(text)}/{tone['max_length']})")
481
- # Fancy AI punctuation checks — applies to ALL platforms
482
- # Most people can't type em dashes, curly quotes, etc. on a keyboard, so they look AI-generated
483
- _fancy_chars = {
484
- "\u2014": "em dash",
485
- "\u2013": "en dash",
486
- "\u2026": "ellipsis (...)",
487
- "\u201c": "curly left quote",
488
- "\u201d": "curly right quote",
489
- "\u2018": "curly left single quote",
490
- "\u2019": "curly right single quote",
491
- }
492
- _found_fancy = [name for char, name in _fancy_chars.items() if char in text]
493
- if _found_fancy:
494
- warnings.append(f"AI TELL WARNING: Fancy punctuation detected: {', '.join(_found_fancy)} — use plain keyboard characters only")
495
- # Negativity check — applies to ALL platforms
496
- _lower_text = text.lower()
497
- _negative_patterns = [
498
- "zero stars", "no stars", "0 stars", "nobody cared",
499
- "no one noticed", "nobody noticed", "crickets",
500
- "the challenge is", "the problem is", "the hard part is",
501
- "but zero", "but no one", "but nobody",
502
- "struggling to", "failing to", "can't seem to",
503
- "not working", "isn't working",
504
- ]
505
- if any(p in _lower_text for p in _negative_patterns):
506
- warnings.append("NEGATIVITY WARNING: Post sounds negative or self-defeating. Reframe as a win or celebration. If there's a problem, handle it internally — don't tweet about it.")
507
- if platform == "reddit":
508
- if any(line.strip().startswith(("- ", "* ", "1.", "2.", "3.")) for line in text.split("\n")):
509
- warnings.append("REDDIT WARNING: Contains bullet/numbered lists — high risk of mod removal as AI content")
510
- if text.count("\n\n") >= 3:
511
- warnings.append("REDDIT WARNING: Multi-paragraph essay format — shorten to 2-3 sentences")
512
- if "**" in text:
513
- warnings.append("REDDIT WARNING: Contains bold formatting — too polished for Reddit")
514
- if ";" in text:
515
- warnings.append("REDDIT WARNING: Contains semicolon — too formal, use a comma or period instead")
516
- # Self-deprecating / commiserating tone check
517
- _lower = text.lower()
518
- _commiserate_patterns = [
519
- "same issue here", "i've been hitting", "i struggle with",
520
- "yeah i have this", "me too", "same problem",
521
- "i've been dealing with", "drives me nuts too",
522
- "i'm stuck on", "can't figure out", "been struggling",
523
- ]
524
- if any(p in _lower for p in _commiserate_patterns):
525
- warnings.append("REDDIT WARNING: Self-deprecating/commiserating tone detected — rewrite with confident practitioner voice")
526
- # Unsolicited promo check — mention Delimit only when genuinely helpful
527
- _promo_patterns = [
528
- "check out", "you should try", "give it a try",
529
- "we just launched", "just shipped", "shameless plug",
530
- "i'd recommend delimit", "you need delimit",
531
- ]
532
- _mentions_delimit = "delimit" in _lower
533
- _is_salesy = any(p in _lower for p in _promo_patterns)
534
- if _mentions_delimit and _is_salesy:
535
- warnings.append("REDDIT WARNING: Looks like an unsolicited promo — mention Delimit only in direct response to a real problem, never as a pitch")
536
- if _mentions_delimit and not conversion_target:
537
- warnings.append("REDDIT NOTE: Mentions Delimit but no conversion_target set — specify 'action', 'mcp', or 'vscode' so the email shows the funnel intent")
538
- if warnings:
539
- entry["tone_warnings"] = warnings
540
- with open(DRAFTS_FILE, "a") as f:
541
- f.write(json.dumps(entry) + "\n")
542
- return entry
543
-
544
-
545
- def store_draft_message_id(draft_id: str, message_id: str) -> bool:
546
- """Store the outbound notification Message-ID on a draft record.
547
-
548
- This enables In-Reply-To header matching for auto-approval via the
549
- inbox polling daemon (Consensus 116).
550
-
551
- Args:
552
- draft_id: The 12-char hex draft ID.
553
- message_id: The Message-ID header from the sent notification email.
554
-
555
- Returns:
556
- True if the draft was found and updated, False otherwise.
557
- """
558
- all_entries = _load_all_drafts()
559
- for entry in all_entries:
560
- if entry.get("draft_id") == draft_id:
561
- entry["notification_message_id"] = message_id
562
- _rewrite_drafts(all_entries)
563
- return True
564
- return False
565
-
566
-
567
- def list_drafts(status: str = "pending") -> list[dict]:
568
- """List drafts filtered by status (pending, approved, rejected)."""
569
- if not DRAFTS_FILE.exists():
570
- return []
571
- drafts = []
572
- for line in DRAFTS_FILE.read_text().splitlines():
573
- if not line.strip():
574
- continue
575
- try:
576
- entry = json.loads(line)
577
- if entry.get("status") == status:
578
- drafts.append(entry)
579
- except (json.JSONDecodeError, ValueError):
580
- pass
581
- return drafts
582
-
583
-
584
- def _rewrite_drafts(all_entries: list[dict]) -> None:
585
- """Rewrite the drafts file with updated entries."""
586
- DRAFTS_FILE.parent.mkdir(parents=True, exist_ok=True)
587
- with open(DRAFTS_FILE, "w") as f:
588
- for entry in all_entries:
589
- f.write(json.dumps(entry) + "\n")
590
-
591
-
592
- def _load_all_drafts() -> list[dict]:
593
- """Load all draft entries from the JSONL file."""
594
- if not DRAFTS_FILE.exists():
595
- return []
596
- entries = []
597
- for line in DRAFTS_FILE.read_text().splitlines():
598
- if not line.strip():
599
- continue
600
- try:
601
- entries.append(json.loads(line))
602
- except (json.JSONDecodeError, ValueError):
603
- pass
604
- return entries
605
-
606
-
607
- def approve_draft(draft_id: str) -> dict:
608
- """Approve a draft — marks it approved and emails the final text to the founder.
609
-
610
- Auto-posting via Twitter API is disabled. Founder posts manually from their device.
611
- """
612
- all_entries = _load_all_drafts()
613
- target = None
614
- for entry in all_entries:
615
- if entry.get("draft_id") == draft_id:
616
- target = entry
617
- break
618
- if not target:
619
- return {"error": f"Draft '{draft_id}' not found"}
620
- if target.get("status") != "pending":
621
- return {"error": f"Draft '{draft_id}' is already {target.get('status')}"}
622
-
623
- # Mark approved but do NOT auto-post — email to founder for manual posting
624
- target["status"] = "approved"
625
- target["approved_at"] = datetime.now(timezone.utc).isoformat()
626
- _rewrite_drafts(all_entries)
627
-
628
- # Email the approved text for manual posting
629
- try:
630
- from ai.notify import send_email
631
- qt = target.get("quote_tweet_id", "")
632
- rt = target.get("reply_to_id", "")
633
- context_lines = []
634
- if qt:
635
- context_lines.append(f"Quote tweet: https://x.com/i/status/{qt}")
636
- if rt:
637
- context_lines.append(f"Reply to: https://x.com/i/status/{rt}")
638
- context = "\n".join(context_lines)
639
- body = f"APPROVED — post this manually:\n\n---\n{target['text']}\n---\n\n{context}"
640
- send_email(
641
- subject=f"APPROVED X Post: {draft_id}",
642
- body=body,
643
- )
644
- except Exception:
645
- pass
646
-
647
- return {"draft_id": draft_id, "status": "approved", "mode": "manual_post", "message": "Emailed to founder for manual posting. Auto-posting is disabled."}
648
-
649
-
650
- def reject_draft(draft_id: str) -> dict:
651
- """Reject a draft. It will not be posted."""
652
- all_entries = _load_all_drafts()
653
- target = None
654
- for entry in all_entries:
655
- if entry.get("draft_id") == draft_id:
656
- target = entry
657
- break
658
- if not target:
659
- return {"error": f"Draft '{draft_id}' not found"}
660
- if target.get("status") != "pending":
661
- return {"error": f"Draft '{draft_id}' is already {target.get('status')}"}
662
-
663
- target["status"] = "rejected"
664
- target["rejected_at"] = datetime.now(timezone.utc).isoformat()
665
- _rewrite_drafts(all_entries)
666
- return {"draft_id": draft_id, "status": "rejected"}