@m13v/s4l 1.7.6-rc.35 → 1.7.6-rc.37

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/bin/cli.js CHANGED
@@ -434,19 +434,43 @@ function installBrowserHarness() {
434
434
  // we don't control the upstream repo. If it stops applying, upstream has
435
435
  // either merged the fix (detected; fine) or refactored (warn); never fail
436
436
  // the install over it.
437
- const harnessPatch = path.join(PKG_ROOT, 'scripts', 'patches', 'browser-harness-loopback-cdp-proxy.patch');
438
- if (fs.existsSync(harnessPatch)) {
439
- const chk = spawnSync('git', ['-C', harnessDir, 'apply', '--check', harnessPatch], { stdio: 'pipe' });
437
+ // Vendored fixes applied on top of the pin (we don't control upstream).
438
+ // Each: patch file + a sentinel (file, substring) that means "already
439
+ // present" (upstream merged it, or we applied it) so a non-applying patch
440
+ // only warns when the fix is genuinely absent. NEVER fail the install.
441
+ const vendoredPatches = [
442
+ {
443
+ file: 'browser-harness-loopback-cdp-proxy.patch',
444
+ sentinelFile: path.join('src', 'browser_harness', 'admin.py'),
445
+ sentinelStr: 'cdp_urlopen',
446
+ label: 'loopback-proxy',
447
+ absentWarn: 'CDP probes may 403 behind a system proxy',
448
+ },
449
+ {
450
+ // Suppress OS-focus-stealing Target.activateTarget on offscreen
451
+ // automation harnesses (twitter/reddit/linkedin) — the shared fix for
452
+ // the 2026-07 Chrome focus-pops. See helpers.py _is_offscreen_harness.
453
+ file: 'browser-harness-focus-and-observability.patch',
454
+ sentinelFile: path.join('src', 'browser_harness', 'helpers.py'),
455
+ sentinelStr: '_is_offscreen_harness',
456
+ label: 'focus-suppression + tab-event observability',
457
+ absentWarn: 'harness Chrome may steal OS focus on every tab switch',
458
+ },
459
+ ];
460
+ for (const p of vendoredPatches) {
461
+ const patchPath = path.join(PKG_ROOT, 'scripts', 'patches', p.file);
462
+ if (!fs.existsSync(patchPath)) continue;
463
+ const chk = spawnSync('git', ['-C', harnessDir, 'apply', '--check', patchPath], { stdio: 'pipe' });
440
464
  if (chk.status === 0) {
441
- spawnSync('git', ['-C', harnessDir, 'apply', harnessPatch], { stdio: 'inherit' });
442
- console.log(' applied loopback-proxy patch to browser-harness');
465
+ spawnSync('git', ['-C', harnessDir, 'apply', patchPath], { stdio: 'inherit' });
466
+ console.log(` applied ${p.label} patch to browser-harness`);
443
467
  } else {
444
- let fixedUpstream = false;
468
+ let present = false;
445
469
  try {
446
- fixedUpstream = fs.readFileSync(path.join(harnessDir, 'src', 'browser_harness', 'admin.py'), 'utf-8').includes('cdp_urlopen');
470
+ present = fs.readFileSync(path.join(harnessDir, p.sentinelFile), 'utf-8').includes(p.sentinelStr);
447
471
  } catch { /* file moved — fall through to the warning */ }
448
- if (!fixedUpstream) {
449
- console.warn(' WARNING: browser-harness loopback-proxy patch no longer applies and the fix is absent upstream; CDP probes may 403 behind a system proxy.');
472
+ if (!present) {
473
+ console.warn(` WARNING: browser-harness ${p.label} patch no longer applies and the fix is absent upstream; ${p.absentWarn}.`);
450
474
  }
451
475
  }
452
476
  }
@@ -139,12 +139,21 @@ export function checkVersionChange() {
139
139
  }
140
140
  const isFirstBoot = from === null;
141
141
  const changed = !isFirstBoot && from !== to;
142
- try {
143
- fs.mkdirSync(snapshotStateDir(), { recursive: true });
144
- fs.writeFileSync(lastVersionPath(), JSON.stringify({ version: to, seen_at: new Date().toISOString() }), "utf-8");
145
- }
146
- catch {
147
- /* best-effort; a failed write just means the next boot re-detects from "from: null" */
142
+ // Write ONLY when the version actually changed (or first boot / unreadable
143
+ // file). The old rewrite-every-boot kept the file's timestamp meaning "when
144
+ // the server last booted", which reads like an install time and misled a
145
+ // 2026-07-18 investigation into concluding an update hadn't applied. With
146
+ // the guard, running_since is exactly what it says: when THIS version first
147
+ // booted here. Same-version boots leave the file untouched, so detection
148
+ // semantics are unchanged (the file already holds the current version).
149
+ if (changed || isFirstBoot) {
150
+ try {
151
+ fs.mkdirSync(snapshotStateDir(), { recursive: true });
152
+ fs.writeFileSync(lastVersionPath(), JSON.stringify({ version: to, running_since: new Date().toISOString() }), "utf-8");
153
+ }
154
+ catch {
155
+ /* best-effort; a failed write just means the next boot re-detects from "from: null" */
156
+ }
148
157
  }
149
158
  if (changed) {
150
159
  console.error(`[social-autoposter-mcp] self-update detected: ${from} -> ${to}`);
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "1.7.6-rc.35",
3
- "installedAt": "2026-07-18T02:51:25.957Z"
2
+ "version": "1.7.6-rc.37",
3
+ "installedAt": "2026-07-18T16:22:24.449Z"
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.35",
5
+ "version": "1.7.6-rc.37",
6
6
  "description": "Draft, review, approve, and autopilot X/Twitter posts.",
7
7
  "long_description": "## **⚠️ The disclaimer above is generic Claude boilerplate.** Anthropic shows the same warning on every plugin regardless of what it does; any plugin has the same level of access as any app you download from the internet.\n\nS4L is an open source product developed by Mediar.ai Incorporated, a VC-backed San Francisco-based startup.\n\nTo get started:\n\n1\\. Copy this prompt: **Set me up on S4L plugin end to end**\n\n2\\. Quit with CMD+Q, reopen Claude, paste into a new chat.\n\nWhat happens next:\n\n* About every 5 minutes S4L scans X for posts that match your topics and drafts replies in your voice.\n* Drafts show up as review cards, usually the first within a few minutes. Nothing is posted automatically; you approve each one.\n* Posting autopilot stays off until you explicitly turn it on.",
8
8
  "author": {
package/mcp/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m13v/s4l-mcp",
3
- "version": "1.7.6-rc.35",
3
+ "version": "1.7.6-rc.37",
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.35",
3
+ "version": "1.7.6-rc.37",
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",
@@ -0,0 +1,130 @@
1
+ diff --git a/src/browser_harness/helpers.py b/src/browser_harness/helpers.py
2
+ index 2014887..2887b3f 100644
3
+ --- a/src/browser_harness/helpers.py
4
+ +++ b/src/browser_harness/helpers.py
5
+ @@ -157,6 +157,7 @@ def _has_return_statement(expression):
6
+
7
+ # --- navigation / page ---
8
+ def goto_url(url):
9
+ + _tab_event("goto_url", f"url={url[:90]}")
10
+ r = cdp("Page.navigate", url=url)
11
+ if os.environ.get("BH_DOMAIN_SKILLS") != "1":
12
+ return r
13
+ @@ -295,12 +296,55 @@ def current_tab():
14
+ r = _send({"meta": "current_tab"})
15
+ return {"targetId": r["targetId"], "url": r["url"], "title": r["title"]}
16
+
17
+ +def _tab_event(fn, detail=""):
18
+ + """One stderr line per tab-mutating CDP call, stamped with the entry
19
+ + script. These calls (Target.createTarget / activateTarget / closeTarget)
20
+ + can ACTIVATE Chrome on macOS — raise the window and steal app focus. The
21
+ + S4L menubar's browser-foreground telemetry (s4l_browser_foreground.py,
22
+ + [browser-foreground] lines in menubar.err.log) records every activation
23
+ + with a timestamp; this line is the other half of the correlation: WHICH
24
+ + harness call fired at that moment, from WHICH pipeline step. Added
25
+ + 2026-07-14 to attribute chronic 1-5x/hour focus steals."""
26
+ + try:
27
+ + ts = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
28
+ + entry = os.path.basename(sys.argv[0] or "?") if sys.argv else "?"
29
+ + print(
30
+ + f"[bh_tab_event] {ts} fn={fn} entry={entry} "
31
+ + f"bu={os.environ.get('BU_NAME', '?')} {detail}",
32
+ + file=sys.stderr, flush=True,
33
+ + )
34
+ + except Exception:
35
+ + pass
36
+ +
37
+ +
38
+ def _mark_tab():
39
+ """Prepend horse emoji to tab title so the user can see which tab the agent controls."""
40
+ try: cdp("Runtime.evaluate", expression="if(!document.title.startsWith('\U0001F434'))document.title='\U0001F434 '+document.title")
41
+ except Exception: pass
42
+
43
+ -def switch_tab(target):
44
+ +def _is_offscreen_harness():
45
+ + """True when this harness window is parked offscreen (BH_WINDOW_POS with a
46
+ + negative coordinate), i.e. a background automation harness that must NEVER
47
+ + steal the user's OS focus. The three S4L harnesses (twitter/reddit/linkedin)
48
+ + all launch at y=-1032; interactive browsers sit onscreen (e.g. 100,100).
49
+ + Cached; env is fixed for a process's life."""
50
+ + global _OFFSCREEN_CACHE
51
+ + try:
52
+ + return _OFFSCREEN_CACHE
53
+ + except NameError:
54
+ + pass
55
+ + off = False
56
+ + try:
57
+ + xy = os.environ.get("BH_WINDOW_POS", "")
58
+ + if xy:
59
+ + off = any(int(float(v)) < 0 for v in xy.split(",")[:2])
60
+ + except Exception:
61
+ + off = False
62
+ + globals()["_OFFSCREEN_CACHE"] = off
63
+ + return off
64
+ +
65
+ +
66
+ +def switch_tab(target, activate=None):
67
+ # Accept either a raw targetId string or the dict returned by current_tab() / list_tabs(),
68
+ # so `switch_tab(current_tab())` works without a manual ["targetId"] dance.
69
+ target_id = target.get("targetId") if isinstance(target, dict) else target
70
+ @@ -308,18 +352,48 @@ def switch_tab(target):
71
+ # plus the trailing space = 3 code units, so slice(3) cleanly removes the prefix.
72
+ try: cdp("Runtime.evaluate", expression="if(document.title.startsWith('\U0001F434 '))document.title=document.title.slice(3)")
73
+ except Exception: pass
74
+ - cdp("Target.activateTarget", targetId=target_id)
75
+ + _tab_event("switch_tab", f"target={target_id}")
76
+ + # Target.activateTarget raises/focuses the OS window (2026-07-15: 7/7
77
+ + # same-second harness_browser_foregrounded correlation) but is NOT needed
78
+ + # for CDP automation — Page.captureScreenshot and Input.dispatchMouseEvent
79
+ + # are session-scoped and the occlusion flags keep offscreen tabs painting.
80
+ + # So on an offscreen automation harness (twitter/reddit/linkedin) skip it:
81
+ + # this is the SINGLE shared fix for the reddit focus-pops that twitter had
82
+ + # to work around per-caller. Callers wanting the old behavior pass
83
+ + # activate=True; interactive (onscreen) harnesses still activate by default.
84
+ + if activate is None:
85
+ + activate = not _is_offscreen_harness()
86
+ + if activate:
87
+ + cdp("Target.activateTarget", targetId=target_id)
88
+ sid = cdp("Target.attachToTarget", targetId=target_id, flatten=True)["sessionId"]
89
+ _send({"meta": "set_session", "session_id": sid, "target_id": target_id})
90
+ _mark_tab()
91
+ return sid
92
+
93
+ -def new_tab(url="about:blank"):
94
+ +def new_tab(url="about:blank", background=False):
95
+ # Always create blank, then goto: passing url to createTarget races with
96
+ # attach, so the brief about:blank is "complete" by the time the caller
97
+ # polls and wait_for_load() returns before navigation actually starts.
98
+ - tid = cdp("Target.createTarget", url="about:blank")["targetId"]
99
+ - switch_tab(tid)
100
+ + #
101
+ + # background=True creates the target without focusing it AND attaches
102
+ + # without Target.activateTarget — activation is proven to raise/focus the
103
+ + # Chrome window on macOS (2026-07-15: 7/7 same-second correlation with
104
+ + # harness_browser_foregrounded). Recovery paths that mint a replacement
105
+ + # tab for a wedged one must never pop the window.
106
+ + # On an offscreen automation harness, ALWAYS create in background (no
107
+ + # window raise) regardless of the caller's default — same rationale as
108
+ + # switch_tab. This is what makes the reddit DM/engage flow (which calls
109
+ + # new_tab(background=False) per destination) stop popping without touching
110
+ + # every caller. Onscreen/interactive harnesses keep the requested behavior.
111
+ + bg = background or _is_offscreen_harness()
112
+ + _tab_event("new_tab", f"url={url} background={bg}")
113
+ + tid = cdp("Target.createTarget", url="about:blank", background=bg)["targetId"]
114
+ + if bg:
115
+ + sid = cdp("Target.attachToTarget", targetId=tid, flatten=True)["sessionId"]
116
+ + _send({"meta": "set_session", "session_id": sid, "target_id": tid})
117
+ + _mark_tab()
118
+ + else:
119
+ + switch_tab(tid)
120
+ if url != "about:blank":
121
+ goto_url(url)
122
+ return tid
123
+ @@ -330,6 +404,7 @@ def close_tab(target=None):
124
+ target_id = target.get("targetId") if isinstance(target, dict) else target
125
+ if target_id is None:
126
+ target_id = current_tab()["targetId"]
127
+ + _tab_event("close_tab", f"target={target_id}")
128
+ cdp("Target.closeTarget", targetId=target_id)
129
+
130
+
@@ -85,20 +85,43 @@ def _emit_run_summary_oneshot():
85
85
  return
86
86
  _RUN_STATE["emitted"] = True
87
87
  elapsed = int(time.time() - _RUN_STATE["run_start"])
88
+ signaled = _RUN_STATE.get("signaled")
89
+ if signaled:
90
+ # A signal ended the run; surface it the same way run-github.sh's
91
+ # trap used to (failed>=1 + sigterm reason) so the dashboard still
92
+ # sees signal-killed cycles, now with accurate counters attached.
93
+ _RUN_STATE["failed"] = max(int(_RUN_STATE["failed"]), 1)
94
+ # Print the same "=== SUMMARY: elapsed=" marker main()'s happy path emits.
95
+ # run-github.sh's EXIT trap greps the cycle log for this marker to decide
96
+ # whether Python already wrote its own run_monitor row; before 2026-07-18
97
+ # every early exit (no candidates, cap=0, Claude failure) lacked the
98
+ # marker, so the trap double-logged each of those cycles as a spurious
99
+ # "failed=1 sigterm:1" row + a "possible silent failure" warning. With
100
+ # the marker printed here, the bash trap only fires when Python died
101
+ # without running atexit at all (SIGKILL, interpreter crash).
88
102
  try:
89
- subprocess.run(
90
- [
91
- PYTHON, os.path.join(os.path.dirname(os.path.abspath(__file__)), "log_run.py"),
92
- "--script", "post_github",
93
- "--posted", str(_RUN_STATE["posted"]),
94
- "--skipped", str(_RUN_STATE["skipped"]),
95
- "--failed", str(_RUN_STATE["failed"]),
96
- "--cost", f"{_RUN_STATE['cost']:.4f}",
97
- "--elapsed", str(elapsed),
98
- ],
99
- timeout=15,
100
- check=False,
103
+ suffix = f"(signal {signaled})" if signaled else "(safety-net)"
104
+ print(
105
+ f"=== SUMMARY: elapsed={elapsed}s "
106
+ f"posted={_RUN_STATE['posted']} failed={_RUN_STATE['failed']} "
107
+ f"=== {suffix}",
108
+ flush=True,
101
109
  )
110
+ except Exception:
111
+ pass
112
+ cmd = [
113
+ PYTHON, os.path.join(os.path.dirname(os.path.abspath(__file__)), "log_run.py"),
114
+ "--script", "post_github",
115
+ "--posted", str(_RUN_STATE["posted"]),
116
+ "--skipped", str(_RUN_STATE["skipped"]),
117
+ "--failed", str(_RUN_STATE["failed"]),
118
+ "--cost", f"{_RUN_STATE['cost']:.4f}",
119
+ "--elapsed", str(elapsed),
120
+ ]
121
+ if signaled:
122
+ cmd.extend(["--failure-reasons", "sigterm:1"])
123
+ try:
124
+ subprocess.run(cmd, timeout=15, check=False)
102
125
  except Exception:
103
126
  # Trap context: never raise from the safety net. Better to lose this
104
127
  # one summary line than to crash a shutdown sequence that might be
@@ -107,7 +130,9 @@ def _emit_run_summary_oneshot():
107
130
 
108
131
 
109
132
  def _signal_to_exit(signum, _frame):
110
- # Convert the signal into a normal-looking exit so atexit fires.
133
+ # Record which signal ended us so the atexit summary can tag the run,
134
+ # then convert the signal into a normal-looking exit so atexit fires.
135
+ _RUN_STATE["signaled"] = signum
111
136
  sys.exit(128 + signum)
112
137
 
113
138
 
@@ -215,10 +240,14 @@ KNOWN_BOT_LOGINS = {
215
240
 
216
241
 
217
242
  def _is_bot_login(login):
243
+ # gh's GraphQL surfaces GitHub App actors with an "app/" prefix
244
+ # (app/github-actions, app/renovate, sometimes bare "app/"); seen live in
245
+ # the 2026-07-18 phase-1 logs. Match the prefix, the known-login set, and
246
+ # the [bot]/-bot suffixes.
218
247
  l = (login or "").lower()
219
248
  return bool(l) and (
220
- l in KNOWN_BOT_LOGINS or l.endswith("[bot]") or l.endswith("-bot")
221
- or l.endswith("bot]")
249
+ l.startswith("app/") or l in KNOWN_BOT_LOGINS or l.endswith("[bot]")
250
+ or l.endswith("-bot") or l.endswith("bot]")
222
251
  )
223
252
 
224
253
 
@@ -235,6 +264,72 @@ def _our_github_handle(_memo={}):
235
264
  return _memo["h"]
236
265
 
237
266
 
267
+ # ---------- github_candidates history (2026-07-18) ---------------------------
268
+ # Per-candidate history mirroring the twitter/reddit candidates principle:
269
+ # every discovered issue becomes a github_candidates row via /api/v1 (never
270
+ # direct SQL), and every decision flips status with skip_stage + skip_reason.
271
+ # Stages: gate_maintainer | gate_no_audience | gate_tiny_repo |
272
+ # gate_maintainer_t1 | momentum_cut | claude | post_failed; posted rows get
273
+ # status='posted' + post_id. All calls are best-effort: a candidates-API
274
+ # outage must never block or slow the posting rail.
275
+
276
+ def _cand_upsert(row):
277
+ try:
278
+ from http_api import api_post
279
+ api_post("/api/v1/github-candidates", {
280
+ "thread_url": row.get("url"),
281
+ "repo": row.get("repo"),
282
+ "issue_number": row.get("number"),
283
+ "thread_title": (row.get("title") or "")[:500],
284
+ "thread_author": row.get("author"),
285
+ "author_is_owner": bool(row.get("author_is_owner")),
286
+ "outside_participants": row.get("outside_participants"),
287
+ "repo_stars": row.get("repo_stars"),
288
+ "comment_count_t0": row.get("comment_count_t0"),
289
+ "reaction_count_t0": row.get("reaction_count_t0"),
290
+ "matched_project": row.get("matched_project"),
291
+ "search_topic": row.get("search_topic"),
292
+ "batch_id": os.environ.get("SA_CYCLE_ID"),
293
+ })
294
+ except Exception as e:
295
+ log(f" WARNING: candidate upsert failed for {row.get('url')}: {e}")
296
+
297
+
298
+ def _cand_mark_skipped(url, stage, reason=""):
299
+ try:
300
+ from http_api import api_patch
301
+ api_patch("/api/v1/github-candidates/by-thread-url", {
302
+ "thread_url": url, "action": "mark_skipped",
303
+ "skip_stage": stage, "skip_reason": str(reason or "")[:1000],
304
+ }, ok_on_404=True)
305
+ except Exception as e:
306
+ log(f" WARNING: candidate mark_skipped failed for {url}: {e}")
307
+
308
+
309
+ def _cand_set_t1(url, comment_count_t1, reaction_count_t1, delta):
310
+ try:
311
+ from http_api import api_patch
312
+ api_patch("/api/v1/github-candidates/by-thread-url", {
313
+ "thread_url": url, "action": "set_t1",
314
+ "comment_count_t1": comment_count_t1,
315
+ "reaction_count_t1": reaction_count_t1,
316
+ "delta_score": delta,
317
+ }, ok_on_404=True)
318
+ except Exception as e:
319
+ log(f" WARNING: candidate set_t1 failed for {url}: {e}")
320
+
321
+
322
+ def _cand_mark_posted(url, post_id=None):
323
+ try:
324
+ from http_api import api_patch
325
+ body = {"thread_url": url, "action": "mark_posted"}
326
+ if isinstance(post_id, int):
327
+ body["post_id"] = post_id
328
+ api_patch("/api/v1/github-candidates/by-thread-url", body, ok_on_404=True)
329
+ except Exception as e:
330
+ log(f" WARNING: candidate mark_posted failed for {url}: {e}")
331
+
332
+
238
333
  def log(msg):
239
334
  print(f"[{datetime.now().strftime('%H:%M:%S')}] [post_github] {msg}", flush=True)
240
335
 
@@ -717,8 +812,17 @@ Return ONLY a single JSON object. No prose, no markdown fencing, no Bash calls:
717
812
  }}
718
813
  ],
719
814
  "skipped": [
720
- {{ "url": "<issue url>", "reason": "<short reason; use 'low_relevance' when relevance < {min_relevance}>" }}
815
+ {{ "url": "<issue url>", "reason": "<code>: <one-line why>" }}
721
816
  ]
817
+
818
+ For skip reasons, start with one snake_case code so the dashboard can
819
+ aggregate, then a colon and a one-line specific why. Codes: low_relevance
820
+ (relevance < {min_relevance}), restates_thread (your point is already in the
821
+ issue/comments), owner_spec_ticket (owner's self-addressed work ticket),
822
+ maintainer_direction (maintainer set direction), thin_audience (1 outside
823
+ participant, nothing new to add), manufactured_expertise (would need fake
824
+ experience), pitch_pileup (other commenters pitching tools), off_domain
825
+ (would have to pivot topics), other (anything else).
722
826
  }}
723
827
 
724
828
  If, and ONLY if, none of the listed styles fits, you may invent one. Set
@@ -970,6 +1074,26 @@ def main():
970
1074
  counts = gh_view_counts(repo, number)
971
1075
  if not counts:
972
1076
  continue
1077
+ _owner, _name = repo.split("/", 1)
1078
+ _stars = _repo_state(_owner, _name).get("stars")
1079
+ # Record the sighting BEFORE the gates so gate skips become queryable
1080
+ # history (github_candidates), not just log lines. Gate branches then
1081
+ # flip the row to skipped with their stage.
1082
+ _cand_url = counts["url"] or item.get("url")
1083
+ _cand_upsert({
1084
+ "url": _cand_url,
1085
+ "repo": repo,
1086
+ "number": number,
1087
+ "title": counts["title"],
1088
+ "author": counts["author"],
1089
+ "author_is_owner": counts.get("author_is_owner"),
1090
+ "outside_participants": counts.get("outside_participants"),
1091
+ "repo_stars": _stars,
1092
+ "comment_count_t0": counts["comment_count"],
1093
+ "reaction_count_t0": counts["reaction_count"],
1094
+ "matched_project": project_name,
1095
+ "search_topic": item.get("search_topic"),
1096
+ })
973
1097
  # Maintainer-just-spoke gate. If the most recent comment is from someone
974
1098
  # with push access (OWNER/MEMBER/COLLABORATOR), they are driving the
975
1099
  # thread, so piling on reads as noise and risks LOW_QUALITY hide.
@@ -980,6 +1104,10 @@ def main():
980
1104
  f"(last={counts.get('last_commenter')}/"
981
1105
  f"{counts.get('last_comment_assoc')})"
982
1106
  )
1107
+ _cand_mark_skipped(
1108
+ _cand_url, "gate_maintainer",
1109
+ f"last={counts.get('last_commenter')}/"
1110
+ f"{counts.get('last_comment_assoc')}")
983
1111
  continue
984
1112
  # Zero-audience gate. Nobody but the issue author (and bots) has ever
985
1113
  # touched this thread, so a comment's only reader is the author's
@@ -993,13 +1121,15 @@ def main():
993
1121
  f"(author={counts.get('author')}, "
994
1122
  f"owner_authored={counts.get('author_is_owner')})"
995
1123
  )
1124
+ _cand_mark_skipped(
1125
+ _cand_url, "gate_no_audience",
1126
+ f"author={counts.get('author')}, "
1127
+ f"owner_authored={counts.get('author_is_owner')}")
996
1128
  continue
997
1129
  # Tiny-repo audience floor. A repo with almost no stars has no
998
1130
  # bystander audience reading its issues; require a real multi-person
999
1131
  # discussion before engaging there. stars=None (fetch failed) passes:
1000
1132
  # fail open, the participant gate above already ran.
1001
- _owner, _name = repo.split("/", 1)
1002
- _stars = _repo_state(_owner, _name).get("stars")
1003
1133
  if (_stars is not None and _stars < STAR_FLOOR
1004
1134
  and counts.get("outside_participants", 0) < 2):
1005
1135
  skipped_small_repo += 1
@@ -1008,6 +1138,10 @@ def main():
1008
1138
  f"and thin discussion "
1009
1139
  f"({counts.get('outside_participants')} outside participants)"
1010
1140
  )
1141
+ _cand_mark_skipped(
1142
+ _cand_url, "gate_tiny_repo",
1143
+ f"{_stars} stars < {STAR_FLOOR}, "
1144
+ f"{counts.get('outside_participants')} outside participants")
1011
1145
  continue
1012
1146
  candidates.append({
1013
1147
  "repo": repo,
@@ -1057,6 +1191,11 @@ def main():
1057
1191
  f"during sleep (last={counts.get('last_commenter')}/"
1058
1192
  f"{counts.get('last_comment_assoc')})"
1059
1193
  )
1194
+ _cand_mark_skipped(
1195
+ c["url"], "gate_maintainer_t1",
1196
+ f"maintainer arrived during sleep: "
1197
+ f"{counts.get('last_commenter')}/"
1198
+ f"{counts.get('last_comment_assoc')}")
1060
1199
  continue
1061
1200
  c["comment_count_t1"] = counts["comment_count"]
1062
1201
  c["reaction_count_t1"] = counts["reaction_count"]
@@ -1068,6 +1207,8 @@ def main():
1068
1207
  c["comment_count_t0"], c["reaction_count_t0"],
1069
1208
  c["comment_count_t1"], c["reaction_count_t1"],
1070
1209
  )
1210
+ _cand_set_t1(c["url"], c["comment_count_t1"], c["reaction_count_t1"],
1211
+ c["delta_score"])
1071
1212
  survivors.append(c)
1072
1213
  if skipped_maintainer_phase2:
1073
1214
  log(
@@ -1089,6 +1230,11 @@ def main():
1089
1230
  candidates.sort(key=lambda c: c["delta_score"], reverse=True)
1090
1231
  top = candidates[:CLAUDE_CANDIDATE_LIMIT]
1091
1232
  log(f"Phase 2b: showing Claude top {len(top)} by delta, cap = {cap}")
1233
+ # Candidates below the top-N cut never reach Claude; record why.
1234
+ for c in candidates[CLAUDE_CANDIDATE_LIMIT:]:
1235
+ _cand_mark_skipped(
1236
+ c["url"], "momentum_cut",
1237
+ f"delta={c['delta_score']:.1f} below top {CLAUDE_CANDIDATE_LIMIT}")
1092
1238
 
1093
1239
  if cap <= 0:
1094
1240
  log("cap=0, nothing to post. Exiting.")
@@ -1175,6 +1321,11 @@ def main():
1175
1321
  if not ok:
1176
1322
  log(f"Claude FAILED: {output[:300]}")
1177
1323
  _RUN_STATE["failed"] = 1
1324
+ # Marker line for run-github.sh's EXIT trap (see
1325
+ # _emit_run_summary_oneshot): without it this branch was double-logged
1326
+ # by the trap as a spurious sigterm:1 row.
1327
+ log(f"=== SUMMARY: elapsed={int(time.time() - run_start)}s "
1328
+ f"posted=0 failed=1 === (claude-failure)")
1178
1329
  subprocess.run([
1179
1330
  PYTHON, os.path.join(SCRIPTS, "log_run.py"),
1180
1331
  "--script", "post_github",
@@ -1191,6 +1342,18 @@ def main():
1191
1342
  posts = decisions.get("posts", []) or []
1192
1343
  skipped = decisions.get("skipped", []) or []
1193
1344
  log(f"Claude picked {len(posts)}, skipped {len(skipped)}")
1345
+ # Full-funnel visibility (2026-07-18): phase-1 gate skips already log one
1346
+ # line each; do the same for Claude's judgment-level skips so the cycle
1347
+ # log answers "why didn't we post" end to end. Mirrors the twitter
1348
+ # principle (twitter_candidates.skip_reason) at log level; GitHub has no
1349
+ # candidates table, so the cycle log is the system of record here.
1350
+ for _s in skipped:
1351
+ if isinstance(_s, dict):
1352
+ _s_url = _s.get("url") or _s.get("thread_url") or "?"
1353
+ _s_reason = str(_s.get("reason") or "(no reason given)")
1354
+ log(f" claude-skip {_s_url}: {_s_reason[:220]}")
1355
+ if _s_url != "?":
1356
+ _cand_mark_skipped(_s_url, "claude", _s_reason)
1194
1357
 
1195
1358
  # Relevance gate. Anything Claude scored below MIN_RELEVANCE goes to the
1196
1359
  # skipped bucket, NOT posted, regardless of how confident the comment_text
@@ -1206,8 +1369,8 @@ def main():
1206
1369
  relevance_dropped.append({
1207
1370
  "url": d.get("thread_url", ""),
1208
1371
  "reason": (
1209
- f"low_relevance (relevance={rel}, "
1210
- f"rationale={(d.get('relevance_rationale') or '').strip()[:120]})"
1372
+ f"low_relevance: relevance={rel}, "
1373
+ f"rationale={(d.get('relevance_rationale') or '').strip()[:120]}"
1211
1374
  ),
1212
1375
  })
1213
1376
  else:
@@ -1219,6 +1382,8 @@ def main():
1219
1382
  )
1220
1383
  for r in relevance_dropped:
1221
1384
  log(f" drop {r['url']}: {r['reason']}")
1385
+ if r.get("url"):
1386
+ _cand_mark_skipped(r["url"], "claude", r["reason"])
1222
1387
  skipped.extend(relevance_dropped)
1223
1388
  posts = kept_posts
1224
1389
 
@@ -1257,6 +1422,9 @@ def main():
1257
1422
  log(f"SKIP: bad URL or empty text: {thread_url}")
1258
1423
  failed += 1
1259
1424
  _RUN_STATE["failed"] = failed
1425
+ if thread_url:
1426
+ _cand_mark_skipped(thread_url, "post_failed",
1427
+ "bad URL or empty text")
1260
1428
  continue
1261
1429
 
1262
1430
  # URL-wrap before sending to GitHub. project for wrapping is the
@@ -1310,6 +1478,8 @@ def main():
1310
1478
  log(f"POST FAILED: {url_or_err}")
1311
1479
  failed += 1
1312
1480
  _RUN_STATE["failed"] = failed
1481
+ _cand_mark_skipped(thread_url, "post_failed",
1482
+ str(url_or_err)[:300])
1313
1483
  time.sleep(3)
1314
1484
  continue
1315
1485
 
@@ -1343,6 +1513,7 @@ def main():
1343
1513
  _RUN_STATE["failed"] = failed
1344
1514
  _RUN_STATE["skipped"] = len(skipped)
1345
1515
  log(f"POSTED: {url_or_err or 'ok'}")
1516
+ _cand_mark_posted(thread_url, new_post_id)
1346
1517
 
1347
1518
  # ---- Optional self-reply with ONE github blob URL ------------------
1348
1519
  # Restored 2026-05-17 after the April 13 over-correction stripped
@@ -1423,6 +1594,18 @@ def main():
1423
1594
 
1424
1595
  time.sleep(3)
1425
1596
 
1597
+ # Any candidate shown to Claude but absent from BOTH posts and skipped is
1598
+ # an undocumented drop (the prompt asks for every candidate to appear in
1599
+ # one bucket, but models drift); close its row so nothing stays pending.
1600
+ _decided_urls = {(d.get("thread_url") or "").strip() for d in posts}
1601
+ _decided_urls |= {
1602
+ (s.get("url") or s.get("thread_url") or "").strip()
1603
+ for s in skipped if isinstance(s, dict)
1604
+ }
1605
+ for c in top:
1606
+ if (c.get("url") or "").strip() not in _decided_urls:
1607
+ _cand_mark_skipped(c["url"], "claude", "not_mentioned_in_decisions")
1608
+
1426
1609
  # Clean up the generation_trace temp file. By this point every post
1427
1610
  # that landed has its trace persisted to posts.generation_trace JSONB,
1428
1611
  # so the on-disk JSON is redundant. macOS would eventually purge
@@ -1443,7 +1626,23 @@ def main():
1443
1626
  _RUN_STATE["failed"] = failed
1444
1627
  _RUN_STATE["skipped"] = len(skipped)
1445
1628
  _RUN_STATE["cost"] = usage["cost_usd"]
1446
- subprocess.run([
1629
+ # Aggregate Claude's skip codes into the same `skip_reasons=code:count`
1630
+ # segment run-twitter-cycle.sh ships, so the dashboard's yellow
1631
+ # "skipped: <reason>" pills work for github runs too. The code is the
1632
+ # snake_case token before the first ':' in each skip reason (see the
1633
+ # prompt's skip-code list); free-form text degrades to a slug.
1634
+ _skip_counts = {}
1635
+ for _s in skipped:
1636
+ _r = (_s.get("reason") if isinstance(_s, dict) else str(_s)) or "unspecified"
1637
+ _code = re.sub(r"[^a-z0-9_]+", "_",
1638
+ str(_r).split(":", 1)[0].strip().lower()).strip("_")[:40]
1639
+ _code = _code or "unspecified"
1640
+ _skip_counts[_code] = _skip_counts.get(_code, 0) + 1
1641
+ _skip_arg = ",".join(
1642
+ f"{k}:{v}" for k, v in
1643
+ sorted(_skip_counts.items(), key=lambda kv: -kv[1])[:8]
1644
+ )
1645
+ _cmd = [
1447
1646
  PYTHON, os.path.join(SCRIPTS, "log_run.py"),
1448
1647
  "--script", "post_github",
1449
1648
  "--posted", str(posted),
@@ -1451,7 +1650,10 @@ def main():
1451
1650
  "--failed", str(failed),
1452
1651
  "--cost", f"{usage['cost_usd']:.4f}",
1453
1652
  "--elapsed", f"{int(total_elapsed)}",
1454
- ])
1653
+ ]
1654
+ if _skip_arg:
1655
+ _cmd.extend(["--skip-reasons", _skip_arg])
1656
+ subprocess.run(_cmd)
1455
1657
  _RUN_STATE["emitted"] = True
1456
1658
 
1457
1659