@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.
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "1.7.6-rc.25",
3
- "installedAt": "2026-07-17T23:05:36.790Z"
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.25",
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.25",
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.25",
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",
@@ -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())