@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 +1 -1
- package/mcp/dist/index.js +28 -28
- package/mcp/dist/version.json +2 -2
- package/mcp/manifest.json +2 -2
- package/mcp/menubar/s4l_menubar.py +29 -58
- package/mcp/menubar/s4l_state.py +21 -15
- package/mcp/package.json +1 -1
- package/package.json +1 -1
- package/scripts/github_tools.py +16 -6
- package/scripts/post_github.py +139 -12
- package/scripts/s4l_ctl.sh +2 -2
- package/scripts/s4l_posting_mode.py +4 -2
- package/scripts/strike_alert.py +5 -1
- package/scripts/test_percard_posting.py +3 -3
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
|
-
- `
|
|
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
|
-
//
|
|
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;
|
|
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 -> `
|
|
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
|
-
"(`
|
|
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
|
-
|
|
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 `
|
|
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
|
|
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 (
|
|
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
|
|
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 (
|
|
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
|
|
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
|
-
//
|
|
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
|
|
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
|
|
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
|
|
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`), `#
|
|
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
|
|
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(`
|
|
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
|
-
// ----
|
|
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("
|
|
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
|
|
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("[
|
|
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-
|
|
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
|
-
"
|
|
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
|
|
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
|
|
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
|
|
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 (
|
|
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
|
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.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": "
|
|
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
|
|
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)
|
|
2972
|
-
#
|
|
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
|
|
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.
|
|
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.
|
|
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
|
|
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
|
-
#
|
|
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.
|
|
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.
|
|
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 —
|
|
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;
|
|
4196
|
-
#
|
|
4197
|
-
#
|
|
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
|
-
|
|
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__":
|
package/mcp/menubar/s4l_state.py
CHANGED
|
@@ -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
|
|
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
|
-
#
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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("
|
|
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.
|
|
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
package/scripts/github_tools.py
CHANGED
|
@@ -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
|
|
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"])
|
package/scripts/post_github.py
CHANGED
|
@@ -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
|
|
324
|
-
issue is no longer
|
|
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
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
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
|
|
423
|
+
if human_comments:
|
|
357
424
|
try:
|
|
358
425
|
sorted_c = sorted(
|
|
359
|
-
|
|
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(
|
|
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}
|
|
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"],
|
package/scripts/s4l_ctl.sh
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
package/scripts/strike_alert.py
CHANGED
|
@@ -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 >=
|
|
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
|
|
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
|
|
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.
|
|
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
|