@m13v/s4l 1.7.6-rc.24 → 1.7.6-rc.26

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,4 +1,4 @@
1
1
  {
2
- "version": "1.7.6-rc.24",
3
- "installedAt": "2026-07-17T23:02:02.361Z"
2
+ "version": "1.7.6-rc.26",
3
+ "installedAt": "2026-07-17T23:10:55.248Z"
4
4
  }
package/mcp/manifest.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "dxt_version": "0.1",
3
3
  "name": "social-autoposter",
4
4
  "display_name": "S4L",
5
- "version": "1.7.6-rc.24",
5
+ "version": "1.7.6-rc.26",
6
6
  "description": "Draft, review, approve, and autopilot X/Twitter posts.",
7
7
  "long_description": "## **⚠️ The disclaimer above is generic Claude boilerplate.** Anthropic shows the same warning on every plugin regardless of what it does; any plugin has the same level of access as any app you download from the internet.\n\nS4L is an open source product developed by Mediar.ai Incorporated, a VC-backed San Francisco-based startup.\n\nTo get started:\n\n1\\. Copy this prompt: **Set me up on S4L plugin end to end**\n\n2\\. Quit with CMD+Q, reopen Claude, paste into a new chat.\n\nWhat happens next:\n\n* About every 5 minutes S4L scans X for posts that match your topics and drafts replies in your voice.\n* Drafts show up as review cards, usually the first within a few minutes. Nothing is posted automatically; you approve each one.\n* Posting autopilot stays off until you explicitly turn it on.",
8
8
  "author": {
package/mcp/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m13v/s4l-mcp",
3
- "version": "1.7.6-rc.24",
3
+ "version": "1.7.6-rc.26",
4
4
  "private": true,
5
5
  "description": "Desktop MCP client for social-autoposter (X/Twitter rail): manual draft/review/approve loop, autopilot control, and stats. Thin wrapper over the existing pipeline scripts.",
6
6
  "license": "MIT",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m13v/s4l",
3
- "version": "1.7.6-rc.24",
3
+ "version": "1.7.6-rc.26",
4
4
  "description": "Automated social posting pipeline for Reddit, X/Twitter, LinkedIn, and Moltbook. Install as a Claude Code agent skill.",
5
5
  "bin": {
6
6
  "social-autoposter": "bin/cli.js",
@@ -290,9 +290,10 @@ def build_prompt(project: dict, events: list[dict], overall_events: list[dict] |
290
290
  "before approving it, so every FINAL is a direct statement of the "
291
291
  "voice they want. The drafting model NO LONGER sees these pairs "
292
292
  "directly, so the ONLY way a rewrite's lesson reaches drafting is "
293
- "if you distill it into a draft_style_notes entry. When 2+ examples "
294
- "share a correction (a phrase type removed, a structure replaced, "
295
- "tone or length shifted), write ONE draft_style_notes entry and "
293
+ "if you distill it into a draft_style_notes entry. Even a SINGLE "
294
+ "example with a clear correction (a phrase type removed, a "
295
+ "structure replaced, tone or length shifted) is enough to write a "
296
+ "draft_style_notes entry and "
296
297
  "embed a short before/after fragment as an anchor (e.g. rewrote "
297
298
  "'X' to 'Y') so the concrete rewrite travels with the rule. Ignore "
298
299
  "lead-specific or cosmetic edits (typo fixes, one-off facts); learn "
@@ -314,9 +315,9 @@ CURRENT content_guardrails.do_not: {json.dumps(guard_do_not)}{edit_examples_bloc
314
315
  NEW REVIEW EVENTS since the last digest ({len(rejected)} rejected, {len(no_reason)} of the rejects without a stated reason, {len(approved)} approved, {len(loved)} of the approvals loved, {len(plat_removed)} platform moderation strikes):
315
316
  {ev_lines}{overall_block}{platform_block}
316
317
 
317
- Categories: wrong_author = the thread's author/audience was a bad fit; off_topic = the thread itself was a bad fit; bad_draft = thread was fine but the written reply was off; other = see the note. "no_reason_given" means the user rejected without picking a category or typing a note: the rejection itself is real, but WHY is your inference from the author/thread/draft context alone, so treat it as weak evidence. It can corroborate a pattern that reasoned events already show, but a no_reason_given reject never justifies a new entry or an author block on its own, and 2+ of them agreeing still only justify an entry when the shared pattern in their context is unmistakable. "edited_before_approving" with an ORIGINAL/REWROTE pair means the user hand-corrected our draft before posting: the rewrite is a direct statement of the voice they want. Diff the pair; when 2+ edits show the same correction (a phrase type removed, a structure replaced, tone shifted, length cut), distill that recurring pattern into draft_style_notes. Ignore edit content that is lead-specific or cosmetic (typo fixes, one-off facts); learn only what generalizes. "user_checked=profile_click" means the user opened the author's profile before deciding (a strong author-quality signal even without a note). "[approved+loved]" means the user picked the heart in the approve row ("this was a really good one"; approve_level_N in interactions carries the strength, 2 = best of the best): strong positive evidence for audience_prefer and thread selection, worth roughly two plain approvals.
318
+ Categories: wrong_author = the thread's author/audience was a bad fit; off_topic = the thread itself was a bad fit; bad_draft = thread was fine but the written reply was off; other = see the note. "no_reason_given" means the user rejected without picking a category or typing a note: the rejection itself is real, but WHY is your inference from the author/thread/draft context alone, so treat it as weak evidence. It can corroborate a pattern that reasoned events already show, but a no_reason_given reject never justifies a new entry or an author block on its own, and 2+ of them agreeing still only justify an entry when the shared pattern in their context is unmistakable. "edited_before_approving" with an ORIGINAL/REWROTE pair means the user hand-corrected our draft before posting: the rewrite is a direct statement of the voice they want. Diff the pair; even a single edit that shows a clear correction (a phrase type removed, a structure replaced, tone shifted, length cut) is enough to distill into draft_style_notes, and several agreeing only make it stronger. Ignore edit content that is lead-specific or cosmetic (typo fixes, one-off facts); learn only what generalizes. "user_checked=profile_click" means the user opened the author's profile before deciding (a strong author-quality signal even without a note). "[approved+loved]" means the user picked the heart in the approve row ("this was a really good one"; approve_level_N in interactions carries the strength, 2 = best of the best): strong positive evidence for audience_prefer and thread selection, worth roughly two plain approvals.
318
319
 
319
- Two-draft cards show a "did NOT pick" pair. "picked_draft_b_over_default_a" means the card offered two drafts with A preselected and the user deliberately clicked into B and approved it: a direct head-to-head preference for the picked draft over the shown alternative, evidence on par with a hand rewrite. "chose_default_a_after_trying_b" means they clicked into B (trying it as the selection) and then came back and approved A: equally explicit, the same head-to-head strength as a switch, just with the default as the winner. "kept_default_a_after_reading_b=Xs" means they kept the preselected A but spent Xs with the pointer over B first: an informed keep, weaker than either explicit choice (reading B does not prove they weighed it; treat like no_reason_given, corroborating a pattern that stronger events already show rather than founding one). "second_draft_not_read" means they approved the default without touching or reading the alternative: NO pairwise signal, never infer anything against the unread draft. When 2+ pairwise events agree, diff the picked texts against the not-picked ones and distill WHAT recurs (tone, structure, length, opener type, directness) into draft_style_notes; the "styles:" line names each side's engagement style, useful when the same style keeps winning or losing.
320
+ Two-draft cards show a "did NOT pick" pair. "picked_draft_b_over_default_a" means the card offered two drafts with A preselected and the user deliberately clicked into B and approved it: a direct head-to-head preference for the picked draft over the shown alternative, evidence on par with a hand rewrite. "chose_default_a_after_trying_b" means they clicked into B (trying it as the selection) and then came back and approved A: equally explicit, the same head-to-head strength as a switch, just with the default as the winner. "kept_default_a_after_reading_b=Xs" means they kept the preselected A but spent Xs with the pointer over B first: an informed keep, weaker than either explicit choice (reading B does not prove they weighed it; treat like no_reason_given, corroborating a pattern that stronger events already show rather than founding one). "second_draft_not_read" means they approved the default without touching or reading the alternative: NO pairwise signal, never infer anything against the unread draft. A single pairwise choice is enough: diff the picked text against the not-picked one and distill WHAT differs (tone, structure, length, opener type, directness) into draft_style_notes, and several agreeing only make it stronger; the "styles:" line names each side's engagement style, useful when the same style keeps winning or losing.
320
321
 
321
322
  "[platform_removed]" and "[platform_banned]" events are MACHINE-DERIVED moderation outcomes, not human card decisions; weigh them on their own track, never as a statement of the reviewer's taste. platform_removed means a moderator or platform filter removed one of our posts: the lesson is the CONTENT pattern (what reads as promotional, off-topic, or AI-written in that kind of venue), feeding thread_avoid or draft_style_notes. One removal is one community's judgment: treat it like a reasoned reject (it may justify one narrowly-scoped entry since the moderation context states the venue and outcome explicitly), and generalize only when 2+ removals share a pattern. platform_banned means a community blocked our account outright: the strongest platform-side evidence, but the specific venue is ALREADY hard-blocked by the pipeline, so never spend an entry naming an individual community; the only lesson worth an entry is a venue TYPE pattern (e.g. what kinds of communities keep rejecting us) once 2+ bans or removals show it. Platform events never justify block_authors (there is no author at fault) and never touch audience_prefer.
322
323
 
@@ -324,7 +325,7 @@ You can also block SPECIFIC authors via the plan's block_authors list. A block i
324
325
 
325
326
  Propose changes to the block. RULES, in priority order:
326
327
  1. Be conservative. Prefer NO changes over speculative ones. An empty plan is a good plan when the evidence is thin.
327
- 2. Generalize only what the evidence supports: 2+ events agreeing justify a general entry; a single reject justifies at most one narrowly-scoped entry, and only when its note, interactions, or author context (follower count, their post, discovery topic) makes the reason explicit. Exceptions: a single loved approval can justify one audience_prefer/thread entry when the pattern it shows is clear, and OVERALL FEEDBACK lines are explicit user instructions that outrank inferred signals; reflect each one in the most fitting list even from a single line, rewritten as a standing preference.
328
+ 2. A SINGLE reasoned event is enough to justify an entry; you do NOT need multiple events agreeing. When a reject carries a category, a note, a profile-click, or clear author/thread context, or an approval is loved, or the user edited the draft, or they made an explicit two-draft choice, distill the lesson right away into the most fitting list, generalized to the TYPE (never an individual handle or a one-off fact). More events agreeing only make the pattern stronger; they are not a precondition. OVERALL FEEDBACK lines are explicit user instructions that outrank inferred signals; reflect each one even from a single line, rewritten as a standing preference. The one exception is no_reason_given rejects, which stay weak (see below), because there the WHY is unknown, not because the count is low.
328
329
  3. Describe author/audience TYPES, never individual handles. "crypto/web3-native accounts shilling tokens" is right; "@someguy" is wrong. Preferences must generalize.
329
330
  4. Approvals are counter-evidence. If approvals contradict an existing entry, propose removing or narrowing it. Also propose removing entries that events show are stale.
330
331
  5. bad_draft events feed draft_style_notes (or, ONLY for a clearly recurring phrasing complaint, voice_never_add / guardrails_do_not_add; use those sparingly, they touch curated fields).
@@ -710,6 +710,14 @@ def parse_claude_json(output):
710
710
  # ---------- Posting + logging ------------------------------------------------
711
711
 
712
712
  def post_comment(owner, repo, number, body):
713
+ # Identity gate (mirrors twitter_browser.reply_to_tweet /
714
+ # reddit_browser.post_comment): the gh CLI would happily post as whatever
715
+ # account it is authed as, but with no resolved accounts.github.username
716
+ # the row's attribution is blank and self-filtering breaks. Refuse loudly.
717
+ from account_resolver import resolve as _resolve_gh
718
+ if not _resolve_gh("github"):
719
+ return False, ("no_account_configured (set accounts.github.username in "
720
+ "config.json or AUTOPOSTER_GITHUB_USERNAME)")
713
721
  try:
714
722
  out = subprocess.check_output(
715
723
  ["gh", "issue", "comment", str(number), "-R", f"{owner}/{repo}", "--body", body],
@@ -536,6 +536,15 @@ def post_comment(thread_url, text):
536
536
 
537
537
  Returns: {"ok": true, "permalink": "..."} or {"ok": false, "error": "..."}
538
538
  """
539
+ # Identity gate (mirrors twitter_browser.reply_to_tweet): refuse to post
540
+ # when no Reddit account resolves. Without it we cannot attribute the
541
+ # comment or find our own permalink afterwards, and posting with a blank
542
+ # identity pollutes the shared DB. Fail fast and loud instead.
543
+ if not OUR_USERNAME:
544
+ print("[reddit_browser] no reddit account configured "
545
+ "(accounts.reddit.username / reddit_account.username / "
546
+ "AUTOPOSTER_REDDIT_USERNAME); refusing to post.", file=sys.stderr)
547
+ return {"ok": False, "error": "no_account_configured"}
539
548
  from playwright.sync_api import sync_playwright
540
549
 
541
550
  with sync_playwright() as p:
@@ -837,6 +846,13 @@ def reply_to_comment(comment_permalink, text, dm_id=None):
837
846
  Returns: {"ok": true, "applied_campaigns": [...], "reply_text": "..."}
838
847
  or {"ok": false, "error": "..."}
839
848
  """
849
+ # Identity gate (mirrors twitter_browser.reply_to_tweet): no resolved
850
+ # account -> no post. See post_comment above for the rationale.
851
+ if not OUR_USERNAME:
852
+ print("[reddit_browser] no reddit account configured "
853
+ "(accounts.reddit.username / reddit_account.username / "
854
+ "AUTOPOSTER_REDDIT_USERNAME); refusing to post.", file=sys.stderr)
855
+ return {"ok": False, "error": "no_account_configured"}
840
856
  from playwright.sync_api import sync_playwright
841
857
 
842
858
  # Pre-flight: refuse to attempt a reply in a sub we know we can't comment in.
@@ -204,6 +204,15 @@ say "Regression gate: scripts/test_no_silent_fallbacks.py"
204
204
  python3 "$REPO_ROOT/scripts/test_no_silent_fallbacks.py" \
205
205
  || die "regression gate failed -- a silent-fallback / bare-python3 issue was (re)introduced. See output above; do not release until it's clean or the finding is reviewed into the test's ALLOWLIST with a reason."
206
206
 
207
+ # Cross-language lockstep gate: candidate_state (s4l_state.py vs index.ts) and
208
+ # the short-link fallback host constant (dm_short_links.py vs bin/server.js)
209
+ # are duplicated across runtimes BY NECESSITY; this test is what turns their
210
+ # "keep in lockstep" comments into an enforced invariant. Divergence here has
211
+ # shipped real incidents (post_failed drain asymmetry 2026-07-17).
212
+ say "Regression gate: scripts/test_cross_language_parity.py"
213
+ python3 "$REPO_ROOT/scripts/test_cross_language_parity.py" \
214
+ || die "cross-language parity gate failed -- a TS/Python lockstep pair diverged (candidate_state or fallback host). Fix BOTH sides before releasing."
215
+
207
216
  # ---- 1. Resolve + WRITE version into the repo-root package.json -------------
208
217
  # The repo-root package.json is the SINGLE source of truth: `npm pack` reads it
209
218
  # to build the embedded pipeline.tgz, so the bundle shell and the bundled
@@ -538,8 +538,10 @@ def upsert_candidates(tweets, config, batch_id=None, attempts_map=None, scored_s
538
538
  # composite unique gives each account its own candidate row.
539
539
  # Without this, account A's 'posted' status on tweet X would lock
540
540
  # account B out of the same tweet (ON CONFLICT preserved 'posted').
541
- # Defaults server-side to 'm13v_' if omitted; new callers should
542
- # always pass it explicitly.
541
+ # If omitted, the server stamps a per-install 'unknown:<install_id>'
542
+ # sentinel (2026-07-18; it used to default to 'm13v_', silently
543
+ # impersonating the repo owner). Callers should always pass it —
544
+ # _resolve_twitter_handle() covers env -> config -> cookie mirror.
543
545
  "our_account": _twitter_handle or "",
544
546
  # Repost provenance (2026-06-04). The scan derives the author from
545
547
  # the status URL, so author_handle/tweet_url already point at the
@@ -0,0 +1,185 @@
1
+ #!/usr/bin/env python3
2
+ """Cross-language parity gate: constants and state machines that exist in BOTH
3
+ the Python pipeline and the Node/TS MCP server MUST agree. Each pair here has a
4
+ "keep in lockstep" comment at both definition sites; this test is what makes
5
+ that comment enforceable instead of aspirational. Runs in the release gate
6
+ (scripts/release-mcpb.sh) right after test_no_silent_fallbacks.py.
7
+
8
+ Checks:
9
+ 1. candidate_state parity — mcp/menubar/s4l_state.py::candidate_state (the
10
+ Python authority) vs mcp/src/index.ts::candidateState (the TS mirror),
11
+ evaluated with node on a fixture matrix covering every flag combination
12
+ and truthiness edge case. Divergence between hand-rolled raw-flag filters
13
+ and the state machine has produced real incidents (post_failed drain
14
+ asymmetry 2026-07-17, get_stats lane blindness); this pins the two
15
+ implementations to each other.
16
+ 2. Short-link fallback host parity — scripts/dm_short_links.py
17
+ DEFAULT_FALLBACK_HOST vs bin/server.js SHORT_LINK_FALLBACK_HOST (CLAUDE.md:
18
+ "keep them in sync").
19
+
20
+ Zero third-party deps. Needs `node` on PATH (the release script already does).
21
+ Exit 0 = all pass; exit 1 with a FAIL line = parity broken, do not release.
22
+ """
23
+
24
+ import json
25
+ import os
26
+ import re
27
+ import shutil
28
+ import subprocess
29
+ import sys
30
+
31
+ REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
32
+ FAILURES = []
33
+
34
+
35
+ def report(ok, label, detail=""):
36
+ tag = "PASS" if ok else "FAIL"
37
+ line = f"{tag} {label}"
38
+ if detail:
39
+ line += f" -- {detail}"
40
+ print(line)
41
+ if not ok:
42
+ FAILURES.append(label)
43
+
44
+
45
+ # ---------------------------------------------------------------------------
46
+ # 1. candidate_state parity
47
+ # ---------------------------------------------------------------------------
48
+
49
+ # Every flag combination that matters, plus truthiness edge cases: Python uses
50
+ # `is True` for posted/terminal/approved but bare truthiness for post_failed;
51
+ # TS uses `=== true` and bare truthiness respectively. The matrix locks in that
52
+ # BOTH implementations make the same call on non-boolean junk values too.
53
+ FIXTURES = [
54
+ {},
55
+ {"approved": True},
56
+ {"approved": True, "post_failed": True},
57
+ {"post_failed": True},
58
+ {"post_failed": "posted_0"},
59
+ {"post_failed": ""},
60
+ {"post_failed": 0},
61
+ {"terminal": True},
62
+ {"terminal": True, "approved": True},
63
+ {"terminal": True, "post_failed": True, "approved": True},
64
+ {"posted": True},
65
+ {"posted": True, "approved": True},
66
+ {"posted": True, "terminal": True, "post_failed": True, "approved": True},
67
+ {"posted": False, "terminal": False, "approved": False},
68
+ {"posted": "yes"},
69
+ {"approved": 1},
70
+ {"terminal": 1, "approved": True},
71
+ {"posted": 1, "terminal": True},
72
+ ]
73
+
74
+
75
+ def python_states():
76
+ sys.path.insert(0, os.path.join(REPO_ROOT, "mcp", "menubar"))
77
+ from s4l_state import candidate_state # noqa: E402
78
+ return [candidate_state(dict(c)) for c in FIXTURES]
79
+
80
+
81
+ def extract_ts_function():
82
+ """Pull the candidateState function body out of mcp/src/index.ts and strip
83
+ the TS type annotations so plain node can evaluate it. Brittle on purpose:
84
+ if the signature changes shape, this test fails loudly and must be updated
85
+ alongside it -- which is exactly the review moment the lockstep needs."""
86
+ src_path = os.path.join(REPO_ROOT, "mcp", "src", "index.ts")
87
+ with open(src_path, encoding="utf-8") as f:
88
+ src = f.read()
89
+ m = re.search(r"function candidateState\(c: PlanCandidate\): CandidateState \{", src)
90
+ if not m:
91
+ return None
92
+ start = m.start()
93
+ depth = 0
94
+ for i in range(m.end() - 1, len(src)):
95
+ if src[i] == "{":
96
+ depth += 1
97
+ elif src[i] == "}":
98
+ depth -= 1
99
+ if depth == 0:
100
+ body = src[start : i + 1]
101
+ return body.replace(
102
+ "function candidateState(c: PlanCandidate): CandidateState {",
103
+ "function candidateState(c) {",
104
+ )
105
+ return None
106
+
107
+
108
+ def ts_states(fn_src):
109
+ node = shutil.which("node") or "/opt/homebrew/bin/node"
110
+ script = (
111
+ fn_src
112
+ + "\nconst fixtures = JSON.parse(require('fs').readFileSync(0, 'utf-8'));"
113
+ + "\nprocess.stdout.write(JSON.stringify(fixtures.map((c) => candidateState(c))));"
114
+ )
115
+ out = subprocess.run(
116
+ [node, "-e", script],
117
+ input=json.dumps(FIXTURES),
118
+ capture_output=True,
119
+ text=True,
120
+ timeout=30,
121
+ )
122
+ if out.returncode != 0:
123
+ raise RuntimeError(f"node evaluation failed: {out.stderr.strip()[:300]}")
124
+ return json.loads(out.stdout)
125
+
126
+
127
+ def check_candidate_state():
128
+ fn_src = extract_ts_function()
129
+ if fn_src is None:
130
+ report(False, "candidateState() found in mcp/src/index.ts",
131
+ "signature changed? update extract_ts_function() alongside it")
132
+ return
133
+ try:
134
+ ts = ts_states(fn_src)
135
+ except Exception as e:
136
+ report(False, "candidateState() evaluates under node", str(e)[:200])
137
+ return
138
+ py = python_states()
139
+ for c, p, t in zip(FIXTURES, py, ts):
140
+ report(p == t, f"candidate_state parity for {json.dumps(c)}",
141
+ f"py={p} ts={t}")
142
+
143
+
144
+ # ---------------------------------------------------------------------------
145
+ # 2. short-link fallback host parity
146
+ # ---------------------------------------------------------------------------
147
+
148
+ def check_fallback_host():
149
+ py_path = os.path.join(REPO_ROOT, "scripts", "dm_short_links.py")
150
+ js_path = os.path.join(REPO_ROOT, "bin", "server.js")
151
+ py_m = js_m = None
152
+ try:
153
+ with open(py_path, encoding="utf-8") as f:
154
+ py_m = re.search(r"^DEFAULT_FALLBACK_HOST\s*=\s*['\"]([^'\"]+)['\"]",
155
+ f.read(), re.M)
156
+ with open(js_path, encoding="utf-8") as f:
157
+ js_m = re.search(r"^const SHORT_LINK_FALLBACK_HOST\s*=\s*['\"]([^'\"]+)['\"]",
158
+ f.read(), re.M)
159
+ except OSError as e:
160
+ report(False, "fallback-host files readable", str(e))
161
+ return
162
+ if not py_m or not js_m:
163
+ report(False, "fallback-host constants found",
164
+ f"py={'ok' if py_m else 'MISSING'} js={'ok' if js_m else 'MISSING'}")
165
+ return
166
+ report(py_m.group(1) == js_m.group(1),
167
+ "DEFAULT_FALLBACK_HOST (py) == SHORT_LINK_FALLBACK_HOST (js)",
168
+ f"py={py_m.group(1)} js={js_m.group(1)}")
169
+
170
+
171
+ def main():
172
+ print("-- check 1: candidate_state TS/Python parity --")
173
+ check_candidate_state()
174
+ print("-- check 2: short-link fallback host parity --")
175
+ check_fallback_host()
176
+ print()
177
+ if FAILURES:
178
+ print(f"{len(FAILURES)} FAILURE(S)")
179
+ return 1
180
+ print("ALL PASS")
181
+ return 0
182
+
183
+
184
+ if __name__ == "__main__":
185
+ sys.exit(main())