@m13v/s4l 1.7.6-rc.32 → 1.7.6-rc.34

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/README.md CHANGED
@@ -157,7 +157,7 @@ The plugin exposes MCP tools for the user-facing workflow:
157
157
  - `project_config` configures projects, products, voice, topics, and X auth.
158
158
  - `engagement_mode` chooses personal-brand and product-promotion lanes.
159
159
  - `dashboard` opens the review dashboard.
160
- - `post_drafts` posts only the drafts the user selected.
160
+ - `approve_drafts` posts only the drafts the user selected.
161
161
  - `get_stats` reads X/Twitter stats.
162
162
  - `pause_s4l` pauses or resumes scheduled S4L jobs.
163
163
  - `runtime` installs, updates, and diagnoses the local runtime.
package/mcp/dist/index.js CHANGED
@@ -6,7 +6,7 @@
6
6
  // via the host create_scheduled_task. The autopilot then drafts
7
7
  // on its own (launchd kicker + queue worker); there is no manual
8
8
  // "draft now" tool.
9
- // post_drafts - post the drafts the user chose by number from a batch.
9
+ // approve_drafts - post the drafts the user chose by number from a batch.
10
10
  // get_stats - read-only post + engagement stats.
11
11
  //
12
12
  // THIN wrapper. The pipeline brain (scan, score, drafting prompts, posting)
@@ -37,7 +37,7 @@ const PANEL_URI = "ui://social-autoposter/panel.html";
37
37
  const PRODUCT_LINK_URI = "ui://social-autoposter/product-link.html";
38
38
  // Stable id for the accumulating draft review queue. Each draft cycle appends its
39
39
  // drafts here (dedup by tweet URL) so the menu-bar cards PILE UP across a
40
- // continuous autopilot instead of each run overwriting the last; post_drafts posts
40
+ // continuous autopilot instead of each run overwriting the last; approve_drafts posts
41
41
  // the approved subset and marks them posted (filtered out of the cards thereafter).
42
42
  const REVIEW_QUEUE_ID = "review-queue";
43
43
  // ---- Queue-backed drafting (2026-06-23) -----------------------------------
@@ -397,12 +397,12 @@ const server = new McpServer({
397
397
  "(configure the product project) -> `queue_setup` + " +
398
398
  "`create_scheduled_task` (set up the draft autopilot once) -> the autopilot then runs on its own " +
399
399
  "(scans, drafts via the queue + worker, and merges into the approval cards; nothing posts) -> the " +
400
- "user approves in the menu bar -> `post_drafts` (post the approved ones) -> `get_stats` (see " +
400
+ "user approves in the menu bar -> `approve_drafts` (post the approved ones) -> `get_stats` (see " +
401
401
  "performance). Run `project_config` first; the other tools refuse until a " +
402
402
  "project is fully configured. To change anything about a project later, call `project_config` " +
403
403
  "again with the project's name and just the changed fields — there is no separate config editor.\n\n" +
404
404
  "RENDER THE DASHBOARD AFTER ACTIONS. After any state-changing or results-producing tool call " +
405
- "(`post_drafts`, `get_stats`, `project_config`), end your turn by " +
405
+ "(`approve_drafts`, `get_stats`, `project_config`), end your turn by " +
406
406
  "calling the `dashboard` tool so the user sees the updated state visually. Do NOT call " +
407
407
  "`dashboard` after pure Q&A, config explanations, or status-only checks that changed nothing.\n\n" +
408
408
  "REPORT FIELD DIAGNOSES. Whenever an S4L setup, healing, or troubleshooting flow FAILS, or " +
@@ -421,7 +421,7 @@ const baseRegisterTool = server.registerTool.bind(server);
421
421
  // show a spinner + label while they run (either invocation path). draft_cycle is
422
422
  // NOT here — it writes finer scanning/drafting phases itself (see produceDrafts).
423
423
  const TOOL_ACTIVITY = {
424
- post_drafts: "posting",
424
+ approve_drafts: "posting",
425
425
  get_stats: "stats…",
426
426
  };
427
427
  function toolActivityLabel(name, args) {
@@ -748,7 +748,7 @@ async function produceDrafts(project, onProgress) {
748
748
  }
749
749
  // Render every draft in a batch as a numbered, human-readable table. This IS the
750
750
  // review surface now: the model relays this table to the user and asks which
751
- // numbers to post / edit, then posts the chosen ones via the `post_drafts` tool.
751
+ // numbers to post / edit, then posts the chosen ones via the `approve_drafts` tool.
752
752
  //
753
753
  // We used to gather approvals through MCP elicitation (a checkbox form), but the
754
754
  // desktop "Code tab" host doesn't advertise the `elicitation` capability (only
@@ -757,7 +757,7 @@ async function produceDrafts(project, onProgress) {
757
757
  function renderDraftsTable(plan) {
758
758
  const candidates = plan.candidates || [];
759
759
  return candidates
760
- // Number by FULL-array index (matches post_drafts + the menu bar), then drop
760
+ // Number by FULL-array index (matches approve_drafts + the menu bar), then drop
761
761
  // already-finished entries so the cards only show what's still pending.
762
762
  .map((c, i) => ({ c, n: i + 1 }))
763
763
  // awaiting_review is the ONLY state the review surfaces present (same rule
@@ -773,7 +773,7 @@ function renderDraftsTable(plan) {
773
773
  const reply = c.reply_text ?? "(empty)";
774
774
  // The literal tail URL is NOT known yet: at post time a short link is minted
775
775
  // from this target (e.g. fazm.ai/cc -> s4l.ai/r/<code>). Approved drafts
776
- // always carry the link (post_drafts forces TWITTER_TAIL_LINK_RATE=1.0), so
776
+ // always carry the link (approve_drafts forces TWITTER_TAIL_LINK_RATE=1.0), so
777
777
  // this is the target that WILL be appended. Show the TARGET only; never
778
778
  // pre-mint the real /r/ code (that would waste pool codes / split clicks).
779
779
  const link = c.link_url
@@ -1126,7 +1126,7 @@ async function postApproved(batchId, plan) {
1126
1126
  attempted: 0,
1127
1127
  exit_code: 0,
1128
1128
  summary: "another posting drain has been running for 8+ minutes; approved cards stay " +
1129
- "queued in the review store — re-run post_drafts once it finishes",
1129
+ "queued in the review store — re-run approve_drafts once it finishes",
1130
1130
  };
1131
1131
  }
1132
1132
  if (waited) {
@@ -1255,7 +1255,7 @@ async function postApproved(batchId, plan) {
1255
1255
  redditFailed++;
1256
1256
  continue;
1257
1257
  }
1258
- // The card's reply_text is the CANONICAL text (post_drafts edits write
1258
+ // The card's reply_text is the CANONICAL text (approve_drafts edits write
1259
1259
  // there); the embedded decision still carries the draft-time original.
1260
1260
  // Posting dec.text verbatim would silently discard a human edit.
1261
1261
  const decPost = {
@@ -1329,7 +1329,7 @@ async function postApproved(batchId, plan) {
1329
1329
  }
1330
1330
  else {
1331
1331
  // Leave the approval sticky (approved && !posted && !terminal) so the
1332
- // next post_drafts call retries, mirroring twitter's failed-drain
1332
+ // next approve_drafts call retries, mirroring twitter's failed-drain
1333
1333
  // semantics; only stamp terminal on a conclusive CDP refusal. This
1334
1334
  // list MUST stay a superset of post_reddit.py's own
1335
1335
  // _PERMANENT_CDP_ERRORS (account_blocked_in_sub, no_permalink) —
@@ -1433,7 +1433,7 @@ async function postApproved(batchId, plan) {
1433
1433
  // we can write our own pid) — a pathological edge case, not the normal path.
1434
1434
  // Bail out rather than proceed without ever confirming we hold it. Approved
1435
1435
  // cards stay sticky (approved && !posted && !terminal), so the very next
1436
- // post_drafts call — the next approval, or this same tool retried — picks
1436
+ // approve_drafts call — the next approval, or this same tool retried — picks
1437
1437
  // them straight back up; nothing is lost or re-queued.
1438
1438
  postingActive = false;
1439
1439
  stopPostingFlagHeartbeat();
@@ -1441,7 +1441,7 @@ async function postApproved(batchId, plan) {
1441
1441
  attempted: 0,
1442
1442
  exit_code: 0,
1443
1443
  summary: "couldn't pin down the twitter-browser lock after repeated attempts; approved cards " +
1444
- "stay queued — re-run post_drafts to retry",
1444
+ "stay queued — re-run approve_drafts to retry",
1445
1445
  };
1446
1446
  }
1447
1447
  const approvedBatch = `${batchId}_approved`;
@@ -1497,9 +1497,9 @@ async function postApproved(batchId, plan) {
1497
1497
  // are now no-ops for the normal path — every approved candidate
1498
1498
  // already carries tail_link_variant by the time it reaches this MCP
1499
1499
  // tool. They're left in place as a defense-in-depth fallback for the
1500
- // rare case a candidate reaches post_drafts unstamped (e.g. a plan
1500
+ // rare case a candidate reaches approve_drafts unstamped (e.g. a plan
1501
1501
  // already in flight from before this change): S4L_SKIP_LINK_TAIL=1
1502
- // still guarantees post_drafts (a synchronous call the user is
1502
+ // still guarantees approve_drafts (a synchronous call the user is
1503
1503
  // waiting on) never makes a blocking Claude/queue call at post time,
1504
1504
  // no matter what.
1505
1505
  TWITTER_TAIL_LINK_RATE: "1.0",
@@ -1541,7 +1541,7 @@ async function postApproved(batchId, plan) {
1541
1541
  const postLogDir = path.join(repoDir(), "skill", "logs");
1542
1542
  fs.mkdirSync(postLogDir, { recursive: true });
1543
1543
  const stamp = new Date().toISOString().replace(/[:.]/g, "-");
1544
- fs.writeFileSync(path.join(postLogDir, `post-${stamp}.log`), `# post_drafts batch=${batchId} approved=${approvedTwitter.length} exit=${res.code} ` +
1544
+ fs.writeFileSync(path.join(postLogDir, `post-${stamp}.log`), `# approve_drafts batch=${batchId} approved=${approvedTwitter.length} exit=${res.code} ` +
1545
1545
  `shell_lock=${heldShellLock}\n\n=== stdout ===\n${res.stdout}\n\n=== stderr ===\n${res.stderr}\n`);
1546
1546
  }
1547
1547
  catch {
@@ -1620,7 +1620,7 @@ async function postApproved(batchId, plan) {
1620
1620
  // ... waited 45s, giving up."). None of those mean the draft is
1621
1621
  // doomed, only that this attempt didn't get a turn with the shared
1622
1622
  // browser. Leave the card sticky (approved && !posted && !terminal)
1623
- // so the next post_drafts call retries it, mirroring Reddit's
1623
+ // so the next approve_drafts call retries it, mirroring Reddit's
1624
1624
  // _TRANSIENT_CDP_ERRORS handling. Unconditionally marking terminal
1625
1625
  // here (found 2026-07-16) silently and permanently discarded 7 real
1626
1626
  // approved drafts on nothing worse than lock contention — Reddit's
@@ -1673,7 +1673,7 @@ async function postApproved(batchId, plan) {
1673
1673
  // actionable error on its own.
1674
1674
  const hasRealFailure = res.code !== 0 || Boolean(summObj?.failure_reasons);
1675
1675
  if (hasRealFailure) {
1676
- captureError(new Error(`post_drafts: ${realPosted}/${approvedTwitter.length} posted (exit=${res.code})`), {
1676
+ captureError(new Error(`approve_drafts: ${realPosted}/${approvedTwitter.length} posted (exit=${res.code})`), {
1677
1677
  component: "post",
1678
1678
  exit_code: String(res.code),
1679
1679
  attempted: String(approvedTwitter.length),
@@ -2863,11 +2863,11 @@ tool("project_config", {
2863
2863
  return textContent(`Setup failed: ${e.message}`);
2864
2864
  }
2865
2865
  });
2866
- // ---- post_drafts: post the user's chosen drafts from a batch ---------------
2866
+ // ---- approve_drafts: post the user's chosen drafts from a batch ---------------
2867
2867
  // Second half of the manual loop. The user reviewed the menu-bar cards a draft
2868
2868
  // cycle produced and said which numbers to post / edit; this posts exactly those.
2869
2869
  // Editing a draft implies posting it. Indices are 1-based, matching the table.
2870
- tool("post_drafts", {
2870
+ tool("approve_drafts", {
2871
2871
  title: "Post chosen drafts",
2872
2872
  description: "Post the drafts the user approved from a draft cycle. Pass the batch_id from the " +
2873
2873
  "approval cards and the user's decision by NUMBER (1-based, matching the table): `post` is " +
@@ -3054,7 +3054,7 @@ tool("post_drafts", {
3054
3054
  }
3055
3055
  // STICKY approve: record the approval DURABLY and never clear another card's
3056
3056
  // prior approval. The old `c.approved = approve.has(i+1)` reset every card on
3057
- // each call, so a later post_drafts for a different card dropped a
3057
+ // each call, so a later approve_drafts for a different card dropped a
3058
3058
  // restart-interrupted approved card back into "pending". postApproved filters
3059
3059
  // posted/terminal, so the approved set only ever drains what's genuinely left.
3060
3060
  approve.forEach((n) => {
@@ -3092,7 +3092,7 @@ tool("post_drafts", {
3092
3092
  });
3093
3093
  storeWriteDone = await patchReviewStore(patches);
3094
3094
  if (!storeWriteDone)
3095
- console.error("[post_drafts] store_patch.py failed; falling back to unlocked plan write");
3095
+ console.error("[approve_drafts] store_patch.py failed; falling back to unlocked plan write");
3096
3096
  }
3097
3097
  if (!storeWriteDone)
3098
3098
  writePlan(batch_id, plan);
@@ -4052,12 +4052,12 @@ function queueWorkerAllowedTools() {
4052
4052
  "Grep",
4053
4053
  // This server's tools, both namespaces (manifest name + protocol name).
4054
4054
  "mcp__social-autoposter__queue_setup",
4055
- "mcp__social-autoposter__post_drafts",
4055
+ "mcp__social-autoposter__approve_drafts",
4056
4056
  "mcp__social-autoposter__project_config",
4057
4057
  "mcp__social-autoposter__get_stats",
4058
4058
  "mcp__social-autoposter__dashboard",
4059
4059
  "mcp__S4L__queue_setup",
4060
- "mcp__S4L__post_drafts",
4060
+ "mcp__S4L__approve_drafts",
4061
4061
  "mcp__S4L__project_config",
4062
4062
  "mcp__S4L__get_stats",
4063
4063
  "mcp__S4L__dashboard",
@@ -4263,7 +4263,7 @@ function kickerEnv() {
4263
4263
  // The persona lane exports =0 per cycle via s4l_mode.py env (link-free
4264
4264
  // organic replies); promotion cycles keep the script's own default so
4265
4265
  // draft-only-OFF posts carry the project link per the A/B gate, and card posts
4266
- // still force =1.0 inside post_drafts.
4266
+ // still force =1.0 inside approve_drafts.
4267
4267
  // Virality bar percentile is NOT set here: it is hardcoded to 0.90 in
4268
4268
  // skill/run-twitter-cycle.sh (single source of truth, no env dependency).
4269
4269
  };
@@ -5469,7 +5469,7 @@ function persistStatusSummary(snap) {
5469
5469
  // chat-table review path is unchanged and still works; this just ALSO lets the
5470
5470
  // corner cards drive review (both surfaces de-dup via the plan's `posted` flag).
5471
5471
  // The menu bar reads review-request.json, presents the cards, posts via the
5472
- // loopback post_drafts tool, then clears the file. Best-effort: a write failure
5472
+ // loopback approve_drafts tool, then clears the file. Best-effort: a write failure
5473
5473
  // just means no pop-ups this batch (chat review still works).
5474
5474
  function writeReviewRequest(req) {
5475
5475
  try {
@@ -5653,7 +5653,7 @@ function sigkillAllScans() {
5653
5653
  }
5654
5654
  // ---- Lock grace-hold: hold the /tmp lock CONTINUOUSLY across per-card posts ----
5655
5655
  // The plist pipeline acquires the browser lock ONCE and holds it through the whole
5656
- // posting phase. The MCP posts per approved card (separate post_drafts calls), and
5656
+ // posting phase. The MCP posts per approved card (separate approve_drafts calls), and
5657
5657
  // the old code acquired+released the lock PER CARD — leaving a release window
5658
5658
  // BETWEEN every card that a parked scan stale-reclaimed (the hijack). Instead we
5659
5659
  // keep the lock and only release it after SHELL_LOCK_GRACE_MS of no posting, so the
@@ -5808,7 +5808,7 @@ appTool("dashboard", {
5808
5808
  "connection, autopilot state, and 7-day stats, with buttons to set up the schedule, connect X, " +
5809
5809
  "and refresh. Use when the user asks to see the dashboard, panel, " +
5810
5810
  "status, or controls. ALSO call this at the end of any state-changing or results-producing " +
5811
- "action (post_drafts, get_stats, project_config) so the user sees the " +
5811
+ "action (approve_drafts, get_stats, project_config) so the user sees the " +
5812
5812
  "updated dashboard. Hosts without UI support get the same data as text.",
5813
5813
  inputSchema: {},
5814
5814
  // fallback_url is set only when the host can't render the ui:// resource and
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "1.7.6-rc.32",
3
- "installedAt": "2026-07-18T01:56:01.177Z"
2
+ "version": "1.7.6-rc.34",
3
+ "installedAt": "2026-07-18T02:38:17.233Z"
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.32",
5
+ "version": "1.7.6-rc.34",
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": {
@@ -33,7 +33,7 @@
33
33
  "description": "Configure or edit a project"
34
34
  },
35
35
  {
36
- "name": "post_drafts",
36
+ "name": "approve_drafts",
37
37
  "description": "Post chosen drafts"
38
38
  },
39
39
  {
@@ -580,7 +580,7 @@ class S4LMenuBar(rumps.App):
580
580
  self._review_unattended_notified = False
581
581
  self._review_unattended_captured = False
582
582
  # This session's card decisions, for store reconciliation: the MCP
583
- # server's post_drafts rewrites the whole review store from a copy read
583
+ # server's approve_drafts rewrites the whole review store from a copy read
584
584
  # before a minutes-long posting run, so a decision stamped mid-drain can
585
585
  # be clobbered; the tick re-stamps anything missing (_reconcile_store).
586
586
  self._session_decisions = []
@@ -1379,7 +1379,9 @@ class S4LMenuBar(rumps.App):
1379
1379
  "S4L posting volume",
1380
1380
  f"Posting volume: {self._posting_mode_label(stored)}. Applies from the next cycle.",
1381
1381
  )
1382
- except Exception as e:
1382
+ except (Exception, SystemExit) as e:
1383
+ # http_api raises SystemExit on terminal network failure; it must
1384
+ # not escape a rumps menu callback.
1383
1385
  sys.stderr.write(f"[s4l-menubar] posting-mode write failed: {e}\n")
1384
1386
  sys.stderr.flush()
1385
1387
  self._notify(
@@ -2961,17 +2963,11 @@ class S4LMenuBar(rumps.App):
2961
2963
  else None,
2962
2964
  os.path.exists(PAUSE_FLAG),
2963
2965
  pending_count,
2964
- # Snooze end (0 = not snoozed): rebuilds the menu when a snooze is
2965
- # set, cleared, or lapses, so the "Snoozed until HH:MM" label and
2966
- # the review item stay honest.
2967
- int(self._review_snooze_until)
2968
- if time.time() < self._review_snooze_until
2969
- else 0,
2970
2966
  # Reveal cadence: rebuilds when the preset changes (checkmark +
2971
- # submenu title) and when a hold starts or lapses (the "Next cards
2972
- # around HH:MM" line in _state_c).
2967
+ # submenu title). The snooze-end and reveal-hold rebuild triggers
2968
+ # were dropped with the _state_c courtesy sublines they drove
2969
+ # (2026-07-17) — nothing visible depends on them anymore.
2973
2970
  round(st.read_reveal_cadence()),
2974
- int(self._reveal_hold_until(pending_count)),
2975
2971
  # View mode: rebuilds when the review layout changes (checkmark
2976
2972
  # in the "View Mode" submenu + whether the cadence item shows).
2977
2973
  st.read_review_layout(),
@@ -3018,7 +3014,7 @@ class S4LMenuBar(rumps.App):
3018
3014
  def _posting_activity_label_locked(self):
3019
3015
  """Progress for the current menu-bar approval burst.
3020
3016
 
3021
- The server receives one post_drafts call per approved card, so its native
3017
+ The server receives one approve_drafts call per approved card, so its native
3022
3018
  view is always 1/1. The menu bar owns the burst queue and can show the
3023
3019
  useful progress: current approved post / total approved so far.
3024
3020
  """
@@ -3495,7 +3491,7 @@ class S4LMenuBar(rumps.App):
3495
3491
 
3496
3492
  def _persist_reject():
3497
3493
  try:
3498
- st.post_drafts(batch, reject=[n], timeout=30)
3494
+ st.approve_drafts(batch, reject=[n], timeout=30)
3499
3495
  except Exception:
3500
3496
  pass
3501
3497
 
@@ -3615,7 +3611,7 @@ class S4LMenuBar(rumps.App):
3615
3611
 
3616
3612
  def _persist_discard():
3617
3613
  try:
3618
- st.post_drafts(batch, reject=ns, timeout=60)
3614
+ st.approve_drafts(batch, reject=ns, timeout=60)
3619
3615
  except Exception:
3620
3616
  pass
3621
3617
  try:
@@ -3750,7 +3746,7 @@ class S4LMenuBar(rumps.App):
3750
3746
  def _post_worker_loop(self):
3751
3747
  # Serialized poster: one approved card at a time so two posts never drive
3752
3748
  # the shared harness Chrome simultaneously. The menu bar passes a burst
3753
- # progress label into post_drafts, so the spinner shows e.g. "posting 17/95"
3749
+ # progress label into approve_drafts, so the spinner shows e.g. "posting 17/95"
3754
3750
  # even though each server call is still one approved draft.
3755
3751
  while True:
3756
3752
  batch, decision = self._post_q.get() # blocks until a card is approved
@@ -3767,7 +3763,7 @@ class S4LMenuBar(rumps.App):
3767
3763
  # through edits=[...] even with no hand-typed change, so the
3768
3764
  # candidate's style/assigned_style/assigned_mode switch to
3769
3765
  # match whichever draft is actually posting (mcp/src/index.ts
3770
- # post_drafts applies both from the same entry). Without
3766
+ # approve_drafts applies both from the same entry). Without
3771
3767
  # this, a card where the human accepted the recommended
3772
3768
  # draft as-is (edited=False) would still need its variant
3773
3769
  # tag carried through for consistency with a later switch.
@@ -3776,19 +3772,19 @@ class S4LMenuBar(rumps.App):
3776
3772
  edit = {"n": n, "text": decision.get("text") or ""}
3777
3773
  if draft_variant:
3778
3774
  edit["variant"] = draft_variant
3779
- res = st.post_drafts(
3775
+ res = st.approve_drafts(
3780
3776
  batch,
3781
3777
  edits=[edit],
3782
3778
  clear_link=cl,
3783
3779
  activity_label=activity_label,
3784
3780
  )
3785
3781
  else:
3786
- res = st.post_drafts(batch, post=[n], clear_link=cl, activity_label=activity_label)
3782
+ res = st.approve_drafts(batch, post=[n], clear_link=cl, activity_label=activity_label)
3787
3783
  if res is None:
3788
3784
  # Loopback unreachable (Claude closed, mid-restart, or an
3789
3785
  # account switch tore down the old MCP server before a new
3790
3786
  # one registered). Unlike a real posting failure, nothing
3791
- # was ever attempted here — post_drafts never reached a
3787
+ # was ever attempted here — approve_drafts never reached a
3792
3788
  # server — so there's no double-post risk in retrying.
3793
3789
  # Deliberately do NOT call store_mark_post_failed: it would
3794
3790
  # exclude this card from store_pending_posts(), which is
@@ -4192,9 +4188,10 @@ class S4LMenuBar(rumps.App):
4192
4188
  items.append(cadence_menu)
4193
4189
 
4194
4190
  # Posting volume: server-side per-install throttle (virality bar).
4195
- # read_posting_mode() is cached and non-blocking; unknown/unset shows
4196
- # as Steady (the server resolves unset the same way), corrected on the
4197
- # rebuild after the first refresh lands.
4191
+ # read_posting_mode() is cached and non-blocking; the cache is seeded
4192
+ # from the on-disk last-known copy at boot, so a restart keeps showing
4193
+ # the user's chosen mode. Genuinely unset shows as Steady (the server
4194
+ # resolves unset the same way), corrected after the first refresh.
4198
4195
  pv_mode = st.read_posting_mode() or "medium"
4199
4196
  pv_menu = rumps.MenuItem(
4200
4197
  f"Posting volume: {self._posting_mode_label(pv_mode).split(' (')[0].lower()}"
@@ -4370,47 +4367,21 @@ class S4LMenuBar(rumps.App):
4370
4367
  if pending_count <= 0:
4371
4368
  return []
4372
4369
  plural = "s" if pending_count != 1 else ""
4373
- items = [
4370
+ # Just the clickable review item — no secondary grey subline. The old
4371
+ # snooze / reveal-cadence courtesy lines ("Snoozed until…", "Next cards
4372
+ # around…", "Cards hidden…", "Canvas opens from the review item above")
4373
+ # were removed per user request (2026-07-17): "Review N pending drafts"
4374
+ # opens them right now regardless of the pop-up pacing, so the extra line
4375
+ # only restated the click behavior. The reveal-cadence + snooze controls
4376
+ # still live in _build_menu; this just drops the redundant readout.
4377
+ # No bulk-discard item here either: "Discard all…" lives in the review
4378
+ # card's title bar (2026-07-10).
4379
+ return [
4374
4380
  rumps.MenuItem(
4375
4381
  f"Review {pending_count} pending draft{plural}",
4376
4382
  callback=self._review_now,
4377
4383
  )
4378
4384
  ]
4379
- # The review-surface picker ("View Mode") lives in _build_menu now
4380
- # (2026-07-16): always visible, not just while something is pending.
4381
- if time.time() < self._review_snooze_until:
4382
- items.append(
4383
- self._label(
4384
- "Snoozed until "
4385
- + time.strftime(
4386
- "%H:%M", time.localtime(self._review_snooze_until)
4387
- )
4388
- )
4389
- )
4390
- else:
4391
- # Reveal-cadence hold: the backlog exists but the pop-up is being
4392
- # paced, or suppressed entirely ("Never"). Same courtesy line as
4393
- # the snooze; "Review N pending drafts" above shows them right
4394
- # now regardless.
4395
- hold_until = self._reveal_hold_until(pending_count)
4396
- if hold_until < 0:
4397
- if st.read_review_layout() == "cards":
4398
- items.append(self._label("Cards hidden — see count in the menu bar"))
4399
- else:
4400
- # Canvas mode: it never auto-opens; the review item above
4401
- # is the one way in.
4402
- items.append(self._label("Canvas opens from the review item above"))
4403
- elif hold_until:
4404
- items.append(
4405
- self._label(
4406
- "Next cards around "
4407
- + time.strftime("%H:%M", time.localtime(hold_until))
4408
- )
4409
- )
4410
- # No bulk-discard item here anymore: "Discard all…" lives in the
4411
- # review card's title bar (2026-07-10). Reaching it with no card open
4412
- # is "Review N pending drafts" -> Discard all, two clicks.
4413
- return items
4414
4385
 
4415
4386
 
4416
4387
  if __name__ == "__main__":
@@ -269,7 +269,7 @@ def autopilot_loaded() -> bool:
269
269
  # A machine with any system-wide proxy configured (corporate VPN client,
270
270
  # security software, a residential-IP proxy for platform fingerprinting,
271
271
  # etc.) whose bypass list doesn't happen to include 127.0.0.1/localhost will
272
- # have every loopback health check and post_drafts call silently routed out
272
+ # have every loopback health check and approve_drafts call silently routed out
273
273
  # through that proxy instead of hitting the MCP server directly — surfacing
274
274
  # as an opaque connection error/403 with no indication the proxy is at
275
275
  # fault. This is a local process talking to its own local server: it must
@@ -534,7 +534,7 @@ def accessibility_trusted() -> bool:
534
534
  # draft_cycle writes review-request.json when a fresh batch is ready; we read
535
535
  # the linked plan file (the /tmp/twitter_cycle_plan_<batch>.json the pipeline
536
536
  # produced), present the cards, then post the approved subset via the loopback
537
- # post_drafts tool. The chat-table review still works in parallel; both surfaces
537
+ # approve_drafts tool. The chat-table review still works in parallel; both surfaces
538
538
  # de-dup on the plan's per-candidate `posted` flag.
539
539
  # How long an activity signal may go un-refreshed before the menu bar treats it
540
540
  # as idle. This is the SELF-HEAL for a frozen spinner: a writer can set a label
@@ -585,7 +585,7 @@ def read_activity():
585
585
  def write_activity(state: str, label: str):
586
586
  """Best-effort local activity update. The MCP server normally owns this file,
587
587
  but the menu-bar posting queue knows the whole approved-card burst while the
588
- server only sees one post_drafts call at a time."""
588
+ server only sees one approve_drafts call at a time."""
589
589
  try:
590
590
  p = Path(state_dir()) / "activity.json"
591
591
  p.parent.mkdir(parents=True, exist_ok=True)
@@ -928,7 +928,7 @@ def store_mark_post_failed(batch, n, candidate_id=None, error=None):
928
928
 
929
929
  def store_pending_posts(batch="review-queue"):
930
930
  """Approved-but-unposted candidates, for the restart resume path. Skips
931
- posted/terminal/failed rows; each entry carries everything post_drafts
931
+ posted/terminal/failed rows; each entry carries everything approve_drafts
932
932
  needs (n, final text, drop_link)."""
933
933
  plan = read_plan(store_path())
934
934
  out = []
@@ -951,7 +951,7 @@ def store_pending_posts(batch="review-queue"):
951
951
 
952
952
  def store_reconcile_decisions(batch, decisions):
953
953
  """Re-stamp any of this session's decisions the store no longer shows. The
954
- MCP server's post_drafts does a whole-file read-modify-write while a batch
954
+ MCP server's approve_drafts does a whole-file read-modify-write while a batch
955
955
  posts (minutes), so a decision stamped mid-drain can be clobbered by its
956
956
  stale rewrite; the menubar calls this every timer tick with its in-memory
957
957
  decision list so the store always converges back to what the user chose.
@@ -985,7 +985,7 @@ def review_queue_posted_count():
985
985
  """Posts that have LANDED in the review-queue plan — the durable, cross-process
986
986
  truth. Independent of the menu bar's in-memory burst queue (which dies on a
987
987
  restart) and of WHICH process is posting (the menu bar worker, the autopilot,
988
- or a host agent draining via post_drafts). Returns the posted count, or None
988
+ or a host agent draining via approve_drafts). Returns the posted count, or None
989
989
  when the plan can't be read. Drives the menu-bar posting indicator so progress
990
990
  stays visible regardless of how the drain is driven."""
991
991
  plan_path = None
@@ -1022,7 +1022,7 @@ def review_drafts(plan, batch="review-queue"):
1022
1022
  continue
1023
1023
  out.append(
1024
1024
  {
1025
- "n": i + 1, # 1-based, matches post_drafts numbering
1025
+ "n": i + 1, # 1-based, matches approve_drafts numbering
1026
1026
  # Platform tag (reddit cards, 2026-07-14): absent/None means
1027
1027
  # twitter (every plan written before the field shipped). The
1028
1028
  # card renders the platform mark and profile link from this.
@@ -1336,7 +1336,10 @@ def _refresh_posting_mode():
1336
1336
  write_cached_mode(mode)
1337
1337
  with _posting_mode_lock:
1338
1338
  _posting_mode_cache.update(mode=mode, known=True, at=time.time())
1339
- except Exception as e:
1339
+ except (Exception, SystemExit) as e:
1340
+ # http_api raises SystemExit (not Exception) on terminal failure;
1341
+ # catching only Exception let it kill this thread BEFORE the retry
1342
+ # throttle below, so offline boxes hot-looped refresh attempts.
1340
1343
  sys.stderr.write(f"[s4l-state] posting-mode refresh failed: {e}\n")
1341
1344
  with _posting_mode_lock:
1342
1345
  # Throttle retries to the TTL window; keep last-known value.
@@ -1364,12 +1367,15 @@ def write_posting_mode(mode):
1364
1367
  r = api_post("/api/v1/installations/posting-mode", {"mode": mode})
1365
1368
  d = (r or {}).get("data") or {}
1366
1369
  stored = d.get("mode")
1370
+ stored_valid = stored if stored in ("high", "medium", "low") else None
1371
+ try:
1372
+ from s4l_posting_mode import write_cached_mode
1373
+
1374
+ write_cached_mode(stored_valid)
1375
+ except Exception:
1376
+ pass
1367
1377
  with _posting_mode_lock:
1368
- _posting_mode_cache.update(
1369
- mode=stored if stored in ("high", "medium", "low") else None,
1370
- known=True,
1371
- at=time.time(),
1372
- )
1378
+ _posting_mode_cache.update(mode=stored_valid, known=True, at=time.time())
1373
1379
  return stored
1374
1380
 
1375
1381
 
@@ -1388,7 +1394,7 @@ def toggle_lane(lane):
1388
1394
  # + store_pending_posts) is the only decision record and resume source now.
1389
1395
 
1390
1396
 
1391
- def post_drafts(batch_id, post=None, edits=None, reject=None, clear_link=None, timeout=900, activity_label=None):
1397
+ def approve_drafts(batch_id, post=None, edits=None, reject=None, clear_link=None, timeout=900, activity_label=None):
1392
1398
  """Post / reject drafts via the loopback tool. `post` = 1-based numbers to post
1393
1399
  as-is; `edits` = [{n, text}] to rewrite then post; `reject` = numbers to mark
1394
1400
  DONE so they're never shown for review again (not posted); `clear_link` =
@@ -1398,7 +1404,7 @@ def post_drafts(batch_id, post=None, edits=None, reject=None, clear_link=None, t
1398
1404
  args = {"batch_id": batch_id, "post": post or [], "edits": edits or [], "reject": reject or [], "clear_link": clear_link or []}
1399
1405
  if activity_label:
1400
1406
  args["__s4l_activity_label"] = activity_label
1401
- return loopback_tool("post_drafts", args, timeout=timeout)
1407
+ return loopback_tool("approve_drafts", args, timeout=timeout)
1402
1408
 
1403
1409
 
1404
1410
  # ---- review-events outbox (2026-07-02) --------------------------------------
package/mcp/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m13v/s4l-mcp",
3
- "version": "1.7.6-rc.32",
3
+ "version": "1.7.6-rc.34",
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.32",
3
+ "version": "1.7.6-rc.34",
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",
@@ -91,9 +91,10 @@ def _save_repo_gone_cache(cache):
91
91
 
92
92
 
93
93
  def _fetch_repo_state(owner, repo, _mem={}, _disk={"loaded": False, "data": {}}):
94
- """Fetch and cache (gone, has_issues, has_discussions) for owner/repo.
95
- Two-tier cache (in-process + 24h on-disk JSON). Returns a dict
96
- {gone: bool, has_issues: bool, has_discussions: bool}."""
94
+ """Fetch and cache (gone, has_issues, has_discussions, stars) for
95
+ owner/repo. Two-tier cache (in-process + 24h on-disk JSON). Returns a dict
96
+ {gone: bool, has_issues: bool, has_discussions: bool, stars: int|None}.
97
+ stars is None when the repo payload was unavailable (404, network)."""
97
98
  key = f"{owner}/{repo}".lower()
98
99
  if key in _mem:
99
100
  return _mem[key]
@@ -103,11 +104,12 @@ def _fetch_repo_state(owner, repo, _mem={}, _disk={"loaded": False, "data": {}})
103
104
  entry = _disk["data"].get(key)
104
105
  now = int(time.time())
105
106
  if (entry and (now - int(entry.get("checked_at", 0))) < _REPO_GONE_TTL_SEC
106
- and "has_issues" in entry):
107
+ and "has_issues" in entry and "stars" in entry):
107
108
  state = {
108
109
  "gone": bool(entry.get("gone")),
109
110
  "has_issues": bool(entry.get("has_issues", True)),
110
111
  "has_discussions": bool(entry.get("has_discussions", True)),
112
+ "stars": entry.get("stars"),
111
113
  }
112
114
  _mem[key] = state
113
115
  return state
@@ -117,7 +119,8 @@ def _fetch_repo_state(owner, repo, _mem={}, _disk={"loaded": False, "data": {}})
117
119
  capture_output=True, text=True, timeout=20,
118
120
  )
119
121
  except Exception:
120
- state = {"gone": False, "has_issues": True, "has_discussions": True}
122
+ state = {"gone": False, "has_issues": True, "has_discussions": True,
123
+ "stars": None}
121
124
  _mem[key] = state
122
125
  return state
123
126
  if proc.returncode == 0:
@@ -125,20 +128,27 @@ def _fetch_repo_state(owner, repo, _mem={}, _disk={"loaded": False, "data": {}})
125
128
  data = json.loads(proc.stdout or "{}")
126
129
  except Exception:
127
130
  data = {}
131
+ try:
132
+ stars = int(data.get("stargazers_count"))
133
+ except (TypeError, ValueError):
134
+ stars = None
128
135
  state = {
129
136
  "gone": False,
130
137
  "has_issues": bool(data.get("has_issues", True)),
131
138
  "has_discussions": bool(data.get("has_discussions", True)),
139
+ "stars": stars,
132
140
  }
133
141
  else:
134
142
  err = ((proc.stderr or "") + (proc.stdout or "")).lower()
135
143
  gone = ("not found" in err or "http 404" in err)
136
- state = {"gone": gone, "has_issues": True, "has_discussions": True}
144
+ state = {"gone": gone, "has_issues": True, "has_discussions": True,
145
+ "stars": None}
137
146
  _mem[key] = state
138
147
  _disk["data"][key] = {
139
148
  "gone": state["gone"],
140
149
  "has_issues": state["has_issues"],
141
150
  "has_discussions": state["has_discussions"],
151
+ "stars": state["stars"],
142
152
  "checked_at": now,
143
153
  }
144
154
  _save_repo_gone_cache(_disk["data"])
@@ -157,6 +157,10 @@ SKILL_FILE = os.path.join(REPO_DIR, "SKILL.md")
157
157
  GITHUB_TOOLS = os.path.join(SCRIPTS, "github_tools.py")
158
158
  RUN_CLAUDE = os.path.join(SCRIPTS, "run_claude.sh")
159
159
 
160
+ # Repo-state lookup (stars, gone, has_issues) with github_tools' shared
161
+ # two-tier cache; feeds the tiny-repo audience floor in phase 1.
162
+ from github_tools import _fetch_repo_state as _repo_state
163
+
160
164
  # Interpreter every child subprocess must run under. A bare PYTHON resolved
161
165
  # to the user's system python, which lacks the pipeline deps that live only in
162
166
  # the owned uv runtime — the same fresh-box failure class that broke the Twitter
@@ -187,6 +191,49 @@ MAINTAINER_ASSOCIATIONS = {"OWNER", "MEMBER", "COLLABORATOR"}
187
191
  # help here." 0/1 = off-domain. Tunable.
188
192
  MIN_RELEVANCE = 2
189
193
 
194
+ # Zero-audience gate (2026-07-17). Root cause of the vestlang #554 spam
195
+ # minimization (post #46421): the issue was the repo owner's self-authored work
196
+ # ticket with zero other commenters, so our comment's only audience was the
197
+ # owner, who hid it as spam within the hour. Require at least one non-bot
198
+ # commenter besides the issue author before we engage, and skip tiny-audience
199
+ # repos (fewer than STAR_FLOOR stars) unless a real multi-person discussion
200
+ # (2+ outside participants) is underway.
201
+ MIN_OUTSIDE_PARTICIPANTS = 1
202
+ STAR_FLOOR = 5
203
+
204
+ # Bot detection for `gh issue view` comment authors. GraphQL strips the
205
+ # "[bot]" suffix REST shows (github-actions[bot] arrives as plain
206
+ # "github-actions"), so we combine a known-login set with suffix heuristics.
207
+ # A missed bot only weakens the participant/momentum signal marginally.
208
+ KNOWN_BOT_LOGINS = {
209
+ "github-actions", "dependabot", "dependabot-preview", "renovate",
210
+ "renovate-bot", "codecov", "codecov-commenter", "vercel", "netlify",
211
+ "stale", "snyk-bot", "greenkeeper", "coderabbitai", "copilot",
212
+ "sonarcloud", "cypress", "changeset-bot", "allcontributors",
213
+ "google-cla", "cla-assistant", "codesandbox-ci", "circleci",
214
+ }
215
+
216
+
217
+ def _is_bot_login(login):
218
+ l = (login or "").lower()
219
+ return bool(l) and (
220
+ l in KNOWN_BOT_LOGINS or l.endswith("[bot]") or l.endswith("-bot")
221
+ or l.endswith("bot]")
222
+ )
223
+
224
+
225
+ def _our_github_handle(_memo={}):
226
+ """Our own posting handle, lowercased ('' if unresolvable). Excluded from
227
+ outside_participants so a thread we already commented in doesn't count us
228
+ as its audience."""
229
+ if "h" not in _memo:
230
+ try:
231
+ from account_resolver import resolve as _resolve
232
+ _memo["h"] = (_resolve("github") or "").lower()
233
+ except Exception:
234
+ _memo["h"] = ""
235
+ return _memo["h"]
236
+
190
237
 
191
238
  def log(msg):
192
239
  print(f"[{datetime.now().strftime('%H:%M:%S')}] [post_github] {msg}", flush=True)
@@ -320,13 +367,21 @@ def gh_search(query, limit=SEARCH_PER_TOPIC):
320
367
 
321
368
  def gh_view_counts(repo, number):
322
369
  """Return dict{comment_count, reaction_count, title, body, author, url,
323
- maintainer_last_speaker, last_commenter, last_comment_assoc} or None if the
324
- issue is no longer open / unfetchable.
370
+ maintainer_last_speaker, last_commenter, last_comment_assoc,
371
+ outside_participants, author_is_owner} or None if the issue is no longer
372
+ open / unfetchable.
325
373
 
326
374
  `gh issue view --json comments` returns each comment with `authorAssociation`
327
375
  (OWNER/MEMBER/COLLABORATOR/CONTRIBUTOR/NONE/...) and `createdAt`. We use the
328
376
  most recent comment to detect "maintainer just spoke" so phase 1 can drop
329
- those candidates without an extra API call."""
377
+ those candidates without an extra API call.
378
+
379
+ Bot comments (github-actions, dependabot, etc.) are excluded from
380
+ comment_count (so CI chatter can't inflate delta_score momentum), from the
381
+ maintainer-last-speaker check (a bot posting after a maintainer shouldn't
382
+ shadow the maintainer's word), and from outside_participants (the count of
383
+ distinct non-bot commenters other than the issue author, which feeds the
384
+ zero-audience gate)."""
330
385
  try:
331
386
  out = subprocess.check_output(
332
387
  ["gh", "issue", "view", str(number), "-R", repo,
@@ -345,18 +400,30 @@ def gh_view_counts(repo, number):
345
400
  (g.get("users") or {}).get("totalCount", 0) or g.get("totalCount", 0) or 0
346
401
  )
347
402
 
348
- # Maintainer-just-spoke gate. Sort comments by createdAt desc, look at the
349
- # most recent one (regardless of timing). If the issue's last word came from
350
- # someone with push access, the thread is being driven and we shouldn't pile
351
- # on. The OP's authorAssociation is checked separately (issue.author isn't
352
- # included in `comments`, only in the top-level `author` field).
403
+ issue_author = ((data.get("author") or {}).get("login", "") or "").lower()
404
+ repo_owner = (repo.split("/", 1)[0] or "").lower()
405
+ human_comments = [
406
+ c for c in comments
407
+ if not _is_bot_login(((c.get("author") or {}).get("login", "")))
408
+ ]
409
+ outside_participants = {
410
+ ((c.get("author") or {}).get("login", "") or "").lower()
411
+ for c in human_comments
412
+ } - {issue_author, _our_github_handle(), ""}
413
+
414
+ # Maintainer-just-spoke gate. Sort non-bot comments by createdAt desc, look
415
+ # at the most recent one (regardless of timing). If the issue's last human
416
+ # word came from someone with push access, the thread is being driven and we
417
+ # shouldn't pile on. The OP's authorAssociation is checked separately
418
+ # (issue.author isn't included in `comments`, only in the top-level `author`
419
+ # field).
353
420
  maintainer_last_speaker = False
354
421
  last_commenter = ""
355
422
  last_comment_assoc = ""
356
- if comments:
423
+ if human_comments:
357
424
  try:
358
425
  sorted_c = sorted(
359
- comments,
426
+ human_comments,
360
427
  key=lambda c: c.get("createdAt", "") or "",
361
428
  reverse=True,
362
429
  )
@@ -369,7 +436,7 @@ def gh_view_counts(repo, number):
369
436
  pass
370
437
 
371
438
  return {
372
- "comment_count": len(comments),
439
+ "comment_count": len(human_comments),
373
440
  "reaction_count": reaction_count,
374
441
  "title": data.get("title", ""),
375
442
  "body": (data.get("body") or ""),
@@ -378,6 +445,8 @@ def gh_view_counts(repo, number):
378
445
  "maintainer_last_speaker": maintainer_last_speaker,
379
446
  "last_commenter": last_commenter,
380
447
  "last_comment_assoc": last_comment_assoc,
448
+ "outside_participants": len(outside_participants),
449
+ "author_is_owner": bool(issue_author) and issue_author == repo_owner,
381
450
  }
382
451
 
383
452
 
@@ -437,6 +506,12 @@ def build_prompt(project, config, candidates, cap, top_report, recent_comments,
437
506
  f"last_commenter: {c['last_commenter']} "
438
507
  f"({c.get('last_comment_assoc') or 'NONE'})\n"
439
508
  )
509
+ audience_line = (
510
+ f"outside_participants: {c.get('outside_participants', '?')}"
511
+ )
512
+ if c.get("author_is_owner"):
513
+ audience_line += " | author_is_owner: true"
514
+ audience_line += "\n"
440
515
  history_block = ""
441
516
  try:
442
517
  _hb = _render_author_history(
@@ -452,6 +527,7 @@ def build_prompt(project, config, candidates, cap, top_report, recent_comments,
452
527
  f"rx {c['reaction_count_t0']}->{c['reaction_count_t1']}) ---\n"
453
528
  f"{seed_line}"
454
529
  f"{last_speaker_line}"
530
+ f"{audience_line}"
455
531
  f"title: {c['title']}\n"
456
532
  f"author: {c['author']}\n"
457
533
  f"url: {c['url']}\n"
@@ -590,6 +666,19 @@ takes that ignored what the maintainer just said. Skip when:
590
666
  grouped with them by the maintainer.
591
667
  - You'd cite a precedent you can't actually link to (Apple ?ppid, Stripe X,
592
668
  Shopify Y, etc.). Hand-wavy precedent name-drops read as fake-expert.
669
+ - The point you'd make is already stated in the issue body or an existing
670
+ comment. Re-deriving the author's own caveats in your voice reads as an
671
+ LLM paraphrasing them back at themselves; that exact pattern got our
672
+ vestlang comment hidden as spam within the hour. If you can't add
673
+ something the thread doesn't already contain, skip.
674
+ - `author_is_owner: true` and the issue reads like the owner's internal
675
+ work ticket (committed scope, acceptance criteria, "design decision to
676
+ settle", self-addressed spec). The owner's notification inbox is the
677
+ only audience, and they didn't ask for outside input.
678
+ - `outside_participants` is 1 and your comment doesn't offer that one
679
+ other person something genuinely new. Thin threads reach you already
680
+ filtered (zero-participant threads are dropped upstream), but 1 is
681
+ still thin; treat it as a high bar, not a green light.
593
682
 
594
683
  ## YOUR JOB
595
684
 
@@ -872,6 +961,8 @@ def main():
872
961
 
873
962
  candidates = []
874
963
  skipped_maintainer = 0
964
+ skipped_no_audience = 0
965
+ skipped_small_repo = 0
875
966
  for item in raw[:CLAUDE_CANDIDATE_LIMIT * 3]:
876
967
  repo, number = parse_repo_number(item.get("url"))
877
968
  if not repo:
@@ -890,6 +981,34 @@ def main():
890
981
  f"{counts.get('last_comment_assoc')})"
891
982
  )
892
983
  continue
984
+ # Zero-audience gate. Nobody but the issue author (and bots) has ever
985
+ # touched this thread, so a comment's only reader is the author's
986
+ # notification inbox. Worst case is the author being the repo owner
987
+ # writing their own work ticket (vestlang #554, post #46421): the one
988
+ # person notified is the one person who can hide us as spam.
989
+ if counts.get("outside_participants", 0) < MIN_OUTSIDE_PARTICIPANTS:
990
+ skipped_no_audience += 1
991
+ log(
992
+ f" skip {repo}#{number}: no outside participants "
993
+ f"(author={counts.get('author')}, "
994
+ f"owner_authored={counts.get('author_is_owner')})"
995
+ )
996
+ continue
997
+ # Tiny-repo audience floor. A repo with almost no stars has no
998
+ # bystander audience reading its issues; require a real multi-person
999
+ # discussion before engaging there. stars=None (fetch failed) passes:
1000
+ # fail open, the participant gate above already ran.
1001
+ _owner, _name = repo.split("/", 1)
1002
+ _stars = _repo_state(_owner, _name).get("stars")
1003
+ if (_stars is not None and _stars < STAR_FLOOR
1004
+ and counts.get("outside_participants", 0) < 2):
1005
+ skipped_small_repo += 1
1006
+ log(
1007
+ f" skip {repo}#{number}: {_stars} stars < {STAR_FLOOR} "
1008
+ f"and thin discussion "
1009
+ f"({counts.get('outside_participants')} outside participants)"
1010
+ )
1011
+ continue
893
1012
  candidates.append({
894
1013
  "repo": repo,
895
1014
  "number": number,
@@ -899,11 +1018,15 @@ def main():
899
1018
  "author": counts["author"],
900
1019
  "comment_count_t0": counts["comment_count"],
901
1020
  "reaction_count_t0": counts["reaction_count"],
1021
+ "outside_participants": counts.get("outside_participants", 0),
1022
+ "author_is_owner": bool(counts.get("author_is_owner")),
902
1023
  "search_topic": item.get("search_topic"),
903
1024
  })
904
1025
  log(
905
1026
  f"Phase 1: {len(candidates)} candidates with T0 snapshot "
906
- f"(skipped {skipped_maintainer} for maintainer-just-spoke)"
1027
+ f"(skipped {skipped_maintainer} maintainer-just-spoke, "
1028
+ f"{skipped_no_audience} no-outside-participants, "
1029
+ f"{skipped_small_repo} tiny-repo)"
907
1030
  )
908
1031
  if not candidates:
909
1032
  log("No live open issues to re-poll. Exiting.")
@@ -937,6 +1060,10 @@ def main():
937
1060
  continue
938
1061
  c["comment_count_t1"] = counts["comment_count"]
939
1062
  c["reaction_count_t1"] = counts["reaction_count"]
1063
+ # Participants only grow, so no re-skip needed; refresh so the prompt
1064
+ # shows the T1 value.
1065
+ c["outside_participants"] = counts.get(
1066
+ "outside_participants", c.get("outside_participants", 0))
940
1067
  c["delta_score"] = delta_score(
941
1068
  c["comment_count_t0"], c["reaction_count_t0"],
942
1069
  c["comment_count_t1"], c["reaction_count_t1"],
@@ -63,13 +63,13 @@ PYEOF
63
63
  if [ "$YES" != "1" ]; then
64
64
  echo "REFUSING: 'approve $*' will POST those cards to live X. Re-run with --yes to confirm." >&2; exit 3; fi
65
65
  echo "posting cards [$nums] ..."
66
- tool post_drafts 900 "{\"batch_id\":\"$BATCH\",\"post\":[$nums]}" ; echo ;;
66
+ tool approve_drafts 900 "{\"batch_id\":\"$BATCH\",\"post\":[$nums]}" ; echo ;;
67
67
  approve-all)
68
68
  n="$(pending_count)"
69
69
  if [ "$YES" != "1" ]; then
70
70
  echo "REFUSING: approve-all will POST all $n pending cards to live X. Re-run with --yes to confirm." >&2; exit 3; fi
71
71
  echo "posting all $n pending cards ..."
72
- tool post_drafts 1800 "{\"batch_id\":\"$BATCH\",\"post_all\":true}" ; echo ;;
72
+ tool approve_drafts 1800 "{\"batch_id\":\"$BATCH\",\"post_all\":true}" ; echo ;;
73
73
  *)
74
74
  echo "usage: s4l_ctl.sh {status|count|drafts|approve <n...>|approve-all} [--yes]" >&2; exit 64 ;;
75
75
  esac
@@ -76,8 +76,10 @@ def main() -> int:
76
76
  try:
77
77
  if cmd == "get":
78
78
  try:
79
+ # http_api raises SystemExit (not Exception) on terminal
80
+ # failure, so both must be caught here and below.
79
81
  r = api_get("/api/v1/installations/posting-mode")
80
- except Exception as e:
82
+ except (Exception, SystemExit) as e:
81
83
  # Offline / server unreachable: answer from the last-known
82
84
  # cache so UIs keep showing the user's chosen mode instead of
83
85
  # silently falling back to "Steady". No "error" key: consumers
@@ -109,7 +111,7 @@ def main() -> int:
109
111
  return 0
110
112
  print(json.dumps({"error": f"unknown command {cmd!r}"}))
111
113
  return 2
112
- except Exception as e: # network / API failure: JSON error, non-zero exit
114
+ except (Exception, SystemExit) as e: # network / API failure: JSON error, non-zero exit
113
115
  print(json.dumps({"error": str(e)}))
114
116
  return 1
115
117
 
@@ -700,6 +700,10 @@ def _next_steps(platform):
700
700
  " pattern. Watch the 'moderated here in last 90 days' count: a\n"
701
701
  " rising count on a not-yet-banned sub is an early bad-fit signal.\n"
702
702
  )
703
+ try:
704
+ from github_tools import DYNAMIC_BLOCK_THRESHOLD as _thr
705
+ except Exception:
706
+ _thr = "N"
703
707
  return (
704
708
  "--- Next steps ---\n"
705
709
  "1. Inspect the thread to see if the comment was deleted, hidden,\n"
@@ -708,7 +712,7 @@ def _next_steps(platform):
708
712
  " config.json -> exclusions.github_repos. Owner-level entries\n"
709
713
  " match all repos under that owner.\n"
710
714
  "3. The auto-blocklist (github_tools._dynamic_owner_blocklist)\n"
711
- " already covers any owner with >=2 strikes in 90 days.\n"
715
+ f" already covers any owner with >={_thr} real strikes in 90 days.\n"
712
716
  )
713
717
 
714
718
 
@@ -37,14 +37,14 @@ sentry_init.init_sentry = lambda *a, **k: None
37
37
  sentry_init.capture = lambda *a, **k: None
38
38
  sys.modules["sentry_init"] = sentry_init
39
39
 
40
- # Track concurrency + record every post_drafts call.
40
+ # Track concurrency + record every approve_drafts call.
41
41
  overlap_detected = []
42
42
  inflight = {"n": 0}
43
43
  inflight_lock = threading.Lock()
44
44
  calls = []
45
45
  activity_events = []
46
46
 
47
- def fake_post_drafts(batch_id, post=None, edits=None, timeout=900, activity_label=None):
47
+ def fake_approve_drafts(batch_id, post=None, edits=None, timeout=900, activity_label=None):
48
48
  with inflight_lock:
49
49
  inflight["n"] += 1
50
50
  if inflight["n"] > 1:
@@ -65,7 +65,7 @@ def fake_post_drafts(batch_id, post=None, edits=None, timeout=900, activity_labe
65
65
  return {"posted": n_posted}
66
66
 
67
67
  st = types.ModuleType("s4l_state")
68
- st.post_drafts = fake_post_drafts
68
+ st.approve_drafts = fake_approve_drafts
69
69
  st.write_activity = lambda state, label: activity_events.append((state, label))
70
70
  st.accessibility_trusted = lambda: True
71
71
  st.clear_review_request = lambda: None