@m13v/s4l 1.7.6-rc.25 → 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.
- package/mcp/dist/version.json +2 -2
- package/mcp/manifest.json +1 -1
- package/mcp/package.json +1 -1
- package/package.json +1 -1
- package/scripts/post_github.py +8 -0
- package/scripts/reddit_browser.py +16 -0
- package/scripts/release-mcpb.sh +9 -0
- package/scripts/score_twitter_candidates.py +4 -2
- package/scripts/test_cross_language_parity.py +185 -0
package/mcp/dist/version.json
CHANGED
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.
|
|
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.
|
|
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
package/scripts/post_github.py
CHANGED
|
@@ -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.
|
package/scripts/release-mcpb.sh
CHANGED
|
@@ -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
|
-
#
|
|
542
|
-
#
|
|
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())
|