@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 +33 -9
- package/mcp/dist/telemetry.js +15 -6
- package/mcp/dist/version.json +2 -2
- package/mcp/manifest.json +1 -1
- package/mcp/package.json +1 -1
- package/package.json +1 -1
- package/scripts/patches/browser-harness-focus-and-observability.patch +130 -0
- package/scripts/post_github.py +224 -22
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
|
-
|
|
438
|
-
|
|
439
|
-
|
|
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',
|
|
442
|
-
console.log(
|
|
465
|
+
spawnSync('git', ['-C', harnessDir, 'apply', patchPath], { stdio: 'inherit' });
|
|
466
|
+
console.log(` applied ${p.label} patch to browser-harness`);
|
|
443
467
|
} else {
|
|
444
|
-
let
|
|
468
|
+
let present = false;
|
|
445
469
|
try {
|
|
446
|
-
|
|
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 (!
|
|
449
|
-
console.warn(
|
|
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
|
}
|
package/mcp/dist/telemetry.js
CHANGED
|
@@ -139,12 +139,21 @@ export function checkVersionChange() {
|
|
|
139
139
|
}
|
|
140
140
|
const isFirstBoot = from === null;
|
|
141
141
|
const changed = !isFirstBoot && from !== to;
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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}`);
|
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.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.
|
|
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
|
@@ -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
|
+
|
package/scripts/post_github.py
CHANGED
|
@@ -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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
#
|
|
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]")
|
|
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": "<
|
|
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
|
|
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
|
-
|
|
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
|
|